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>
500 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|