Files
foxhunt/crates/database/src/lib.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

500 lines
14 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used)]
//! High-performance PostgreSQL database abstraction library
//!
//! This library provides a clean, type-safe abstraction over PostgreSQL with:
//! - Connection pooling with health monitoring
//! - Type-safe query building
//! - Transaction management with savepoints
//! - Comprehensive error handling
//! - Performance metrics and monitoring
//!
//! # Examples
//!
//! ## Basic Usage
//!
//! ```rust,no_run
//! use database::{Database, DatabaseConfig};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = DatabaseConfig::default();
//! let db = Database::new(config).await?;
//!
//! // Execute a simple query
//! let count: i64 = db.fetch_one("SELECT COUNT(*) FROM users").await?;
//! println!("User count: {}", count);
//!
//! Ok(())
//! }
//! ```
//!
//! ## Transaction Usage
//!
//! ```rust,no_run
//! use database::{Database, DatabaseConfig};
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = DatabaseConfig::default();
//! let db = Database::new(config).await?;
//!
//! // Use transaction with retry logic
//! let result = db.with_transaction(|mut tx| async move {
//! tx.execute("INSERT INTO users (name) VALUES ('Alice')").await?;
//! tx.execute("UPDATE settings SET user_count = user_count + 1").await?;
//! Ok((42, tx)) // Return value and transaction
//! }).await?;
//!
//! println!("Transaction result: {}", result);
//! Ok(())
//! }
//! ```
pub mod error;
pub mod pool;
pub mod query;
pub mod transaction;
use crate::error::ErrorContext;
use config::database::DatabaseConfig;
// Re-export commonly used types for external crate usage
pub use crate::error::{DatabaseError, DatabaseResult};
pub use crate::pool::{DatabasePool, PoolStats};
pub use crate::query::{OrderDirection, QueryBuilder};
pub use crate::transaction::{DatabaseTransaction, TransactionManager, TransactionStats};
// serde imports removed - not needed
use sqlx::migrate::Migrator;
use sqlx::postgres::PgRow;
use sqlx::FromRow;
use std::future::Future;
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info};
/// Main database interface providing high-level operations
#[derive(Debug, Clone)]
pub struct Database {
pool: DatabasePool,
transaction_manager: TransactionManager,
config: DatabaseConfig,
}
impl Database {
/// Create a new database instance
pub async fn new(config: DatabaseConfig) -> DatabaseResult<Self> {
// Validate configuration
config
.validate()
.map_err(|e| DatabaseError::Configuration { message: e })?;
info!(
"Initializing database with application name: {}",
config.application_name.as_deref().unwrap_or("foxhunt")
);
// Create connection pool
let pool = DatabasePool::new(config.pool.clone()).await?;
// Create transaction manager
let transaction_manager = TransactionManager::new(pool.clone(), config.transaction.clone());
let database = Self {
pool,
transaction_manager,
config,
};
// Perform initial health check
database.health_check().await?;
info!("Database initialization completed successfully");
Ok(database)
}
/// Create a database instance from environment variables
pub async fn from_env() -> DatabaseResult<Self> {
let database_url =
std::env::var("DATABASE_URL").map_err(|_| DatabaseError::Configuration {
message: "DATABASE_URL environment variable not set".to_string(),
})?;
let mut config = DatabaseConfig::new();
config.url = database_url;
Self::new(config).await
}
/// Get a connection from the pool
pub async fn acquire(&self) -> DatabaseResult<sqlx::pool::PoolConnection<sqlx::Postgres>> {
self.pool.acquire().await
}
/// Execute a query and return affected rows
pub async fn execute(&self, query: &str) -> DatabaseResult<u64> {
if self.config.enable_query_logging {
debug!("Executing query: {}", query);
}
let result = sqlx::query(query)
.execute(self.pool.inner())
.await
.with_query_context(query)?;
Ok(result.rows_affected())
}
/// Execute a parameterized query with a single parameter
pub async fn execute_with_param<T>(&self, query: &str, param: T) -> DatabaseResult<u64>
where
T: for<'q> sqlx::Encode<'q, sqlx::Postgres> + sqlx::Type<sqlx::Postgres> + Send,
{
if self.config.enable_query_logging {
debug!("Executing parameterized query: {}", query);
}
let result = sqlx::query(query)
.bind(param)
.execute(self.pool.inner())
.await
.with_query_context(query)?;
Ok(result.rows_affected())
}
/// Fetch all rows from a query
pub async fn fetch_all<T>(&self, query: &str) -> DatabaseResult<Vec<T>>
where
T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
{
if self.config.enable_query_logging {
debug!("Fetching all rows: {}", query);
}
let rows = sqlx::query_as(query)
.fetch_all(self.pool.inner())
.await
.with_query_context(query)?;
Ok(rows)
}
/// Fetch one row from a query
pub async fn fetch_one<T>(&self, query: &str) -> DatabaseResult<T>
where
T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
{
if self.config.enable_query_logging {
debug!("Fetching one row: {}", query);
}
let row = sqlx::query_as(query)
.fetch_one(self.pool.inner())
.await
.with_query_context(query)?;
Ok(row)
}
/// Fetch optional row from a query
pub async fn fetch_optional<T>(&self, query: &str) -> DatabaseResult<Option<T>>
where
T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
{
if self.config.enable_query_logging {
debug!("Fetching optional row: {}", query);
}
let row = sqlx::query_as(query)
.fetch_optional(self.pool.inner())
.await
.with_query_context(query)?;
Ok(row)
}
/// Begin a new transaction
pub async fn begin_transaction(&self) -> DatabaseResult<DatabaseTransaction> {
self.transaction_manager.begin().await
}
/// Begin a transaction with custom timeout
pub async fn begin_transaction_with_timeout(
&self,
timeout: Duration,
) -> DatabaseResult<DatabaseTransaction> {
self.transaction_manager.begin_with_timeout(timeout).await
}
/// Execute a closure within a transaction
pub async fn with_transaction<F, R, Fut>(&self, f: F) -> DatabaseResult<R>
where
F: Fn(DatabaseTransaction) -> Fut + Send + Sync,
Fut: Future<Output = DatabaseResult<(R, DatabaseTransaction)>> + Send,
R: Send,
{
self.transaction_manager.with_transaction(f).await
}
/// Execute a closure within a transaction with custom timeout
pub async fn with_transaction_timeout<F, R, Fut>(
&self,
f: F,
timeout: Duration,
) -> DatabaseResult<R>
where
F: Fn(DatabaseTransaction) -> Fut + Send + Sync,
Fut: Future<Output = DatabaseResult<(R, DatabaseTransaction)>> + Send,
R: Send,
{
self.transaction_manager
.with_transaction_timeout(f, timeout)
.await
}
/// Create a new query builder
pub fn query_builder() -> QueryBuilder {
QueryBuilder::new()
}
/// Create a SELECT query builder
pub fn select<T: AsRef<str>>(columns: &[T]) -> crate::query::SelectBuilder {
QueryBuilder::select(columns)
}
/// Create an INSERT query builder
pub fn insert(table: &str) -> crate::query::InsertBuilder {
QueryBuilder::insert(table)
}
/// Create an UPDATE query builder
pub fn update(table: &str) -> crate::query::UpdateBuilder {
QueryBuilder::update(table)
}
/// Create a DELETE query builder
pub fn delete(table: &str) -> crate::query::DeleteBuilder {
QueryBuilder::delete(table)
}
/// Perform a health check on the database
pub async fn health_check(&self) -> DatabaseResult<bool> {
self.pool.health_check().await
}
/// Get connection pool statistics
pub async fn pool_stats(&self) -> PoolStats {
self.pool.stats().await
}
/// Get transaction statistics
pub fn transaction_stats(&self) -> TransactionStats {
self.transaction_manager.stats()
}
/// Get database configuration
pub fn config(&self) -> &DatabaseConfig {
&self.config
}
/// Test database connectivity
pub async fn ping(&self) -> DatabaseResult<()> {
self.pool.ping().await
}
/// Reset all statistics
pub async fn reset_stats(&self) {
self.pool.reset_stats().await;
self.transaction_manager.reset_stats();
info!("All database statistics reset");
}
/// Close the database connection pool
pub async fn close(&self) {
info!("Closing database connection pool");
self.pool.close().await;
}
/// Check if the database connection pool is closed
pub fn is_closed(&self) -> bool {
self.pool.is_closed()
}
/// Get current pool size
pub fn pool_size(&self) -> u32 {
self.pool.size()
}
/// Get number of idle connections
pub fn idle_connections(&self) -> usize {
self.pool.num_idle()
}
/// Execute a database migration
pub async fn migrate(&self) -> DatabaseResult<()> {
info!("Running database migrations");
let migrator = Migrator::new(Path::new("./migrations"))
.await
.map_err(|e| DatabaseError::Migration {
message: format!("Migration setup failed: {}", e),
})?;
migrator
.run(self.pool.inner())
.await
.map_err(|e| DatabaseError::Migration {
message: format!("Migration failed: {}", e),
})?;
info!("Database migrations completed successfully");
Ok(())
}
/// Execute raw SQL with a single parameter
pub async fn execute_raw<T>(&self, sql: &str, arg: T) -> DatabaseResult<u64>
where
T: for<'q> sqlx::Encode<'q, sqlx::Postgres> + sqlx::Type<sqlx::Postgres> + Send,
{
if self.config.enable_query_logging {
debug!("Executing raw SQL: {}", sql);
}
let result = sqlx::query(sql)
.bind(arg)
.execute(self.pool.inner())
.await
.with_query_context(sql)?;
Ok(result.rows_affected())
}
/// Check if a table exists
pub async fn table_exists(&self, table_name: &str) -> DatabaseResult<bool> {
let exists = sqlx::query_scalar(
"SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)",
)
.bind(table_name)
.fetch_one(self.pool.inner())
.await
.with_context(&format!("Failed to check if table '{}' exists", table_name))?;
Ok(exists)
}
/// Get database version
pub async fn version(&self) -> DatabaseResult<String> {
let version: String = sqlx::query_scalar("SELECT version()")
.fetch_one(self.pool.inner())
.await
.with_context("Failed to get database version")?;
Ok(version)
}
/// Get database size in bytes
pub async fn database_size(&self) -> DatabaseResult<i64> {
let size: i64 = sqlx::query_scalar("SELECT pg_database_size(current_database())")
.fetch_one(self.pool.inner())
.await
.with_context("Failed to get database size")?;
Ok(size)
}
}
/// Database health information
#[derive(Debug, Clone)]
pub struct HealthInfo {
/// Database is healthy
pub healthy: bool,
/// Database version
pub version: String,
/// Connection pool status
pub pool_stats: PoolStats,
/// Transaction statistics
pub transaction_stats: TransactionStats,
/// Database size in bytes
pub database_size: i64,
/// Response time in milliseconds
pub response_time_ms: u64,
}
impl Database {
/// Get comprehensive health information
pub async fn health_info(&self) -> DatabaseResult<HealthInfo> {
let start = std::time::Instant::now();
// Perform health check
let healthy = self.health_check().await.unwrap_or(false);
// Get version (may fail if unhealthy)
let version = self
.version()
.await
.unwrap_or_else(|_| "unknown".to_string());
// Get statistics
let pool_stats = self.pool_stats().await;
let transaction_stats = self.transaction_stats();
// Get database size (may fail if unhealthy)
let database_size = self.database_size().await.unwrap_or(0);
let response_time_ms = start.elapsed().as_millis() as u64;
Ok(HealthInfo {
healthy,
version,
pool_stats,
transaction_stats,
database_size,
response_time_ms,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use config::database::{PoolConfig, TransactionConfig};
#[test]
fn test_database_config_new() {
let config = DatabaseConfig::new();
assert_eq!(config.application_name, Some("foxhunt".to_string()));
assert!(!config.enable_query_logging);
assert!(!config.url.is_empty());
}
#[test]
fn test_database_config_validation() {
let mut config = DatabaseConfig::new();
assert!(config.validate().is_ok());
config.url = String::new();
assert!(config.validate().is_err());
}
#[test]
fn test_pool_config_defaults() {
let pool_config = PoolConfig::default();
assert!(pool_config.max_connections > 0);
assert!(pool_config.min_connections > 0);
assert!(pool_config.health_check_enabled);
}
#[test]
fn test_transaction_config_defaults() {
let tx_config = TransactionConfig::default();
assert!(tx_config.max_retries > 0);
assert!(tx_config.enable_retry);
assert!(!tx_config.isolation_level.is_empty());
}
}