Skip to main content

forge_core/config/
database.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{ForgeError, Result};
4
5/// Database configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct DatabaseConfig {
8    /// PostgreSQL connection URL.
9    #[serde(default)]
10    pub url: String,
11
12    /// Connection pool size.
13    #[serde(default = "default_pool_size")]
14    pub pool_size: u32,
15
16    /// Pool checkout timeout in seconds.
17    #[serde(default = "default_pool_timeout")]
18    pub pool_timeout_secs: u64,
19
20    /// Statement timeout in seconds.
21    #[serde(default = "default_statement_timeout")]
22    pub statement_timeout_secs: u64,
23
24    /// Read replica URLs for scaling reads.
25    #[serde(default)]
26    pub replica_urls: Vec<String>,
27
28    /// Whether to route read queries to replicas.
29    #[serde(default)]
30    pub read_from_replica: bool,
31
32    /// Minimum connections to keep alive in the pool (pre-warming).
33    #[serde(default)]
34    pub min_pool_size: u32,
35
36    /// Run a health check query before handing out connections.
37    /// Disabling this halves round-trips for read queries.
38    #[serde(default = "default_true")]
39    pub test_before_acquire: bool,
40
41    /// Connection pool isolation configuration.
42    #[serde(default)]
43    pub pools: PoolsConfig,
44}
45
46impl Default for DatabaseConfig {
47    fn default() -> Self {
48        Self {
49            url: String::new(),
50            pool_size: default_pool_size(),
51            pool_timeout_secs: default_pool_timeout(),
52            statement_timeout_secs: default_statement_timeout(),
53            replica_urls: Vec::new(),
54            read_from_replica: false,
55            min_pool_size: 0,
56            test_before_acquire: true,
57            pools: PoolsConfig::default(),
58        }
59    }
60}
61
62impl DatabaseConfig {
63    /// Create a config with a database URL.
64    pub fn new(url: impl Into<String>) -> Self {
65        Self {
66            url: url.into(),
67            ..Default::default()
68        }
69    }
70
71    /// Get the database URL.
72    pub fn url(&self) -> &str {
73        &self.url
74    }
75
76    /// Validate the database configuration.
77    pub fn validate(&self) -> Result<()> {
78        if self.url.is_empty() {
79            return Err(ForgeError::Config(
80                "database.url is required. \
81                 Set database.url to a PostgreSQL connection string \
82                 (e.g., \"postgres://user:pass@localhost/mydb\")."
83                    .into(),
84            ));
85        }
86        Ok(())
87    }
88}
89
90fn default_pool_size() -> u32 {
91    50
92}
93
94fn default_pool_timeout() -> u64 {
95    30
96}
97
98fn default_statement_timeout() -> u64 {
99    30
100}
101
102fn default_true() -> bool {
103    true
104}
105
106/// Pool isolation configuration for different workloads.
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct PoolsConfig {
109    /// Default pool for queries/mutations.
110    #[serde(default)]
111    pub default: Option<PoolConfig>,
112
113    /// Pool for background jobs.
114    #[serde(default)]
115    pub jobs: Option<PoolConfig>,
116
117    /// Pool for observability writes.
118    #[serde(default)]
119    pub observability: Option<PoolConfig>,
120
121    /// Pool for long-running analytics.
122    #[serde(default)]
123    pub analytics: Option<PoolConfig>,
124}
125
126/// Individual pool configuration.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct PoolConfig {
129    /// Pool size.
130    pub size: u32,
131
132    /// Checkout timeout in seconds.
133    #[serde(default = "default_pool_timeout")]
134    pub timeout_secs: u64,
135
136    /// Statement timeout in seconds (optional override).
137    pub statement_timeout_secs: Option<u64>,
138
139    /// Minimum connections to keep alive.
140    #[serde(default)]
141    pub min_size: u32,
142
143    /// Run a health check query before handing out connections.
144    #[serde(default = "default_true")]
145    pub test_before_acquire: bool,
146}
147
148#[cfg(test)]
149#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_default_database_config() {
155        let config = DatabaseConfig::default();
156        assert_eq!(config.pool_size, 50);
157        assert_eq!(config.pool_timeout_secs, 30);
158        assert!(config.url.is_empty());
159    }
160
161    #[test]
162    fn test_new_config() {
163        let config = DatabaseConfig::new("postgres://localhost/test");
164        assert_eq!(config.url(), "postgres://localhost/test");
165    }
166
167    #[test]
168    fn test_parse_config() {
169        let toml = r#"
170            url = "postgres://localhost/test"
171            pool_size = 100
172            replica_urls = ["postgres://replica1/test", "postgres://replica2/test"]
173            read_from_replica = true
174        "#;
175
176        let config: DatabaseConfig = toml::from_str(toml).unwrap();
177        assert_eq!(config.pool_size, 100);
178        assert_eq!(config.url(), "postgres://localhost/test");
179        assert_eq!(config.replica_urls.len(), 2);
180        assert!(config.read_from_replica);
181    }
182
183    #[test]
184    fn test_validate_with_url() {
185        let config = DatabaseConfig::new("postgres://localhost/test");
186        assert!(config.validate().is_ok());
187    }
188
189    #[test]
190    fn test_validate_empty_url() {
191        let config = DatabaseConfig::default();
192        let result = config.validate();
193        assert!(result.is_err());
194        let err_msg = result.unwrap_err().to_string();
195        assert!(err_msg.contains("database.url is required"));
196    }
197}