mockforge_http/
database.rs

1//! Database connection and migration support for mockforge-http
2//!
3//! This module provides optional database support for persistent storage
4//! of drift budgets, incidents, and consumer contracts.
5
6#[cfg(feature = "database")]
7use anyhow::Result as AnyhowResult;
8#[cfg(feature = "database")]
9use sqlx::{postgres::PgPoolOptions, PgPool};
10#[cfg(feature = "database")]
11use std::sync::Arc;
12
13/// Database connection wrapper
14#[derive(Clone)]
15pub struct Database {
16    #[cfg(feature = "database")]
17    pool: Option<Arc<PgPool>>,
18    #[cfg(not(feature = "database"))]
19    _phantom: std::marker::PhantomData<()>,
20}
21
22impl Database {
23    /// Create a new database connection (optional)
24    ///
25    /// If DATABASE_URL is not set or database feature is disabled,
26    /// returns a Database with no connection.
27    /// This allows the application to run without a database.
28    #[cfg(feature = "database")]
29    pub async fn connect_optional(database_url: Option<&str>) -> AnyhowResult<Self> {
30        let pool = if let Some(url) = database_url {
31            if url.is_empty() {
32                None
33            } else {
34                let pool = PgPoolOptions::new().max_connections(10).connect(url).await?;
35                Some(Arc::new(pool))
36            }
37        } else {
38            None
39        };
40
41        Ok(Self { pool })
42    }
43
44    /// Connect to database (no-op when database feature is disabled)
45    #[cfg(not(feature = "database"))]
46    pub async fn connect_optional(_database_url: Option<&str>) -> anyhow::Result<Self> {
47        Ok(Self {
48            _phantom: std::marker::PhantomData,
49        })
50    }
51
52    /// Run migrations if database is connected
53    #[cfg(feature = "database")]
54    pub async fn migrate_if_connected(&self) -> AnyhowResult<()> {
55        if let Some(ref pool) = self.pool {
56            // Run migrations from the migrations directory
57            // Note: This requires the migrations directory to be accessible at runtime
58            match sqlx::migrate!("./migrations").run(pool.as_ref()).await {
59                Ok(_) => {
60                    tracing::info!("Database migrations completed successfully");
61                    Ok(())
62                }
63                Err(e) => {
64                    // If migration was manually applied, log warning but continue
65                    if e.to_string().contains("previously applied but is missing") {
66                        tracing::warn!(
67                            "Migration tracking issue (manually applied migration): {:?}",
68                            e
69                        );
70                        tracing::info!(
71                            "Continuing despite migration tracking issue - database is up to date"
72                        );
73                        Ok(())
74                    } else {
75                        Err(e.into())
76                    }
77                }
78            }
79        } else {
80            tracing::debug!("No database connection, skipping migrations");
81            Ok(())
82        }
83    }
84
85    /// Run database migrations (no-op when database feature is disabled)
86    #[cfg(not(feature = "database"))]
87    pub async fn migrate_if_connected(&self) -> anyhow::Result<()> {
88        tracing::debug!("Database feature not enabled, skipping migrations");
89        Ok(())
90    }
91
92    /// Get the database pool if connected
93    #[cfg(feature = "database")]
94    pub fn pool(&self) -> Option<&PgPool> {
95        self.pool.as_deref()
96    }
97
98    /// Get the database pool (returns None when database feature is disabled)
99    #[cfg(not(feature = "database"))]
100    pub fn pool(&self) -> Option<()> {
101        None
102    }
103
104    /// Check if database is connected
105    pub fn is_connected(&self) -> bool {
106        #[cfg(feature = "database")]
107        {
108            self.pool.is_some()
109        }
110        #[cfg(not(feature = "database"))]
111        {
112            false
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[tokio::test]
122    async fn test_database_connect_optional_none() {
123        let db = Database::connect_optional(None).await.unwrap();
124        assert!(!db.is_connected());
125    }
126
127    #[tokio::test]
128    async fn test_database_connect_optional_empty_string() {
129        let db = Database::connect_optional(Some("")).await.unwrap();
130        assert!(!db.is_connected());
131    }
132
133    #[tokio::test]
134    async fn test_database_pool_returns_none_when_not_connected() {
135        let db = Database::connect_optional(None).await.unwrap();
136        assert!(db.pool().is_none());
137    }
138
139    #[tokio::test]
140    async fn test_database_migrate_skips_when_not_connected() {
141        let db = Database::connect_optional(None).await.unwrap();
142        // Should succeed even without a connection
143        let result = db.migrate_if_connected().await;
144        assert!(result.is_ok());
145    }
146
147    #[test]
148    fn test_database_is_connected_returns_false_by_default() {
149        // Without database feature, is_connected always returns false
150        #[cfg(not(feature = "database"))]
151        {
152            let db = Database {
153                _phantom: std::marker::PhantomData,
154            };
155            assert!(!db.is_connected());
156        }
157    }
158
159    #[test]
160    fn test_database_clone() {
161        // Database should be Clone
162        #[cfg(not(feature = "database"))]
163        {
164            let db = Database {
165                _phantom: std::marker::PhantomData,
166            };
167            let cloned = db.clone();
168            assert!(!cloned.is_connected());
169        }
170    }
171}