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    /// Connection pool isolation configuration.
33    #[serde(default)]
34    pub pools: PoolsConfig,
35}
36
37impl Default for DatabaseConfig {
38    fn default() -> Self {
39        Self {
40            url: String::new(),
41            pool_size: default_pool_size(),
42            pool_timeout_secs: default_pool_timeout(),
43            statement_timeout_secs: default_statement_timeout(),
44            replica_urls: Vec::new(),
45            read_from_replica: false,
46            pools: PoolsConfig::default(),
47        }
48    }
49}
50
51impl DatabaseConfig {
52    /// Create a config with a database URL.
53    pub fn new(url: impl Into<String>) -> Self {
54        Self {
55            url: url.into(),
56            ..Default::default()
57        }
58    }
59
60    /// Get the database URL.
61    pub fn url(&self) -> &str {
62        &self.url
63    }
64
65    /// Validate the database configuration.
66    pub fn validate(&self) -> Result<()> {
67        if self.url.is_empty() {
68            return Err(ForgeError::Config(
69                "database.url is required. \
70                 Set database.url to a PostgreSQL connection string \
71                 (e.g., \"postgres://user:pass@localhost/mydb\")."
72                    .into(),
73            ));
74        }
75        Ok(())
76    }
77}
78
79fn default_pool_size() -> u32 {
80    50
81}
82
83fn default_pool_timeout() -> u64 {
84    30
85}
86
87fn default_statement_timeout() -> u64 {
88    30
89}
90
91/// Pool isolation configuration for different workloads.
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct PoolsConfig {
94    /// Default pool for queries/mutations.
95    #[serde(default)]
96    pub default: Option<PoolConfig>,
97
98    /// Pool for background jobs.
99    #[serde(default)]
100    pub jobs: Option<PoolConfig>,
101
102    /// Pool for observability writes.
103    #[serde(default)]
104    pub observability: Option<PoolConfig>,
105
106    /// Pool for long-running analytics.
107    #[serde(default)]
108    pub analytics: Option<PoolConfig>,
109}
110
111/// Individual pool configuration.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct PoolConfig {
114    /// Pool size.
115    pub size: u32,
116
117    /// Checkout timeout in seconds.
118    #[serde(default = "default_pool_timeout")]
119    pub timeout_secs: u64,
120
121    /// Statement timeout in seconds (optional override).
122    pub statement_timeout_secs: Option<u64>,
123}
124
125#[cfg(test)]
126#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_default_database_config() {
132        let config = DatabaseConfig::default();
133        assert_eq!(config.pool_size, 50);
134        assert_eq!(config.pool_timeout_secs, 30);
135        assert!(config.url.is_empty());
136    }
137
138    #[test]
139    fn test_new_config() {
140        let config = DatabaseConfig::new("postgres://localhost/test");
141        assert_eq!(config.url(), "postgres://localhost/test");
142    }
143
144    #[test]
145    fn test_parse_config() {
146        let toml = r#"
147            url = "postgres://localhost/test"
148            pool_size = 100
149            replica_urls = ["postgres://replica1/test", "postgres://replica2/test"]
150            read_from_replica = true
151        "#;
152
153        let config: DatabaseConfig = toml::from_str(toml).unwrap();
154        assert_eq!(config.pool_size, 100);
155        assert_eq!(config.url(), "postgres://localhost/test");
156        assert_eq!(config.replica_urls.len(), 2);
157        assert!(config.read_from_replica);
158    }
159
160    #[test]
161    fn test_validate_with_url() {
162        let config = DatabaseConfig::new("postgres://localhost/test");
163        assert!(config.validate().is_ok());
164    }
165
166    #[test]
167    fn test_validate_empty_url() {
168        let config = DatabaseConfig::default();
169        let result = config.validate();
170        assert!(result.is_err());
171        let err_msg = result.unwrap_err().to_string();
172        assert!(err_msg.contains("database.url is required"));
173    }
174}