Skip to main content

modkit_db/
config.rs

1//! Database configuration types.
2//!
3//! This module contains the canonical definitions of all database configuration
4//! structures used throughout the system. These types are deserialized directly
5//! from Figment configuration.
6//!
7//! # Configuration Precedence Rules
8//!
9//! The database configuration system follows a strict precedence hierarchy when
10//! merging global server configurations with module-specific overrides:
11//!
12//! | Priority | Source | Description | Example |
13//! |----------|--------|-------------|---------|
14//! | 1 (Highest) | Module `params` map | Key-value parameters in module config | `params: {synchronous: "FULL"}` |
15//! | 2 | Module DSN query params | Parameters in module-level DSN | `sqlite://file.db?synchronous=NORMAL` |
16//! | 3 | Module fields | Individual connection fields | `host: "localhost", port: 5432` |
17//! | 4 | Module DSN base | Core DSN without query params | `postgres://user:pass@host/db` |
18//! | 5 | Server `params` map | Key-value parameters in server config | Global server `params` |
19//! | 6 | Server DSN query params | Parameters in server-level DSN | Server DSN query string |
20//! | 7 | Server fields | Individual connection fields in server | Server `host`, `port`, etc. |
21//! | 8 (Lowest) | Server DSN base | Core server DSN without query params | Base server connection string |
22//!
23//! ## Merge Rules
24//!
25//! 1. **Field Precedence**: Module fields always override server fields
26//! 2. **DSN Precedence**: Module DSN overrides server DSN completely
27//! 3. **Params Merging**: `params` maps are merged, with module params taking precedence
28//! 4. **Pool Configuration**: Module pool config overrides server pool config entirely
29//! 5. **`SQLite` Paths**: `file`/`path` fields are module-only and never inherited from servers
30//!
31//! ## Conflict Detection
32//!
33//! The system validates configurations and returns [`DbError::ConfigConflict`] for:
34//! - `SQLite` DSN with server fields (`host`/`port`)
35//! - Non-SQLite DSN with `SQLite` fields (`file`/`path`)
36//! - Both `file` and `path` specified for `SQLite`
37//! - `SQLite` fields mixed with server connection fields
38//!
39//! ## Test Coverage
40//!
41//! These precedence rules are verified by:
42//! - [`test_precedence_module_fields_override_server`]
43//! - [`test_precedence_module_dsn_override_server`]
44//! - [`test_precedence_params_merging`]
45//! - [`test_conflict_detection_sqlite_dsn_with_server_fields`]
46//! - [`test_conflict_detection_nonsqlite_dsn_with_sqlite_fields`]
47//!
48//! See the test suite in `tests/precedence_tests.rs` for complete verification.
49
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::path::PathBuf;
53use std::time::Duration;
54
55/// Global database configuration with server-based DBs.
56#[derive(Debug, Clone, Deserialize, Serialize)]
57#[serde(deny_unknown_fields)]
58pub struct GlobalDatabaseConfig {
59    /// Server-based DBs (postgres/mysql/sqlite/etc.), keyed by server name.
60    #[serde(default)]
61    pub servers: HashMap<String, DbConnConfig>,
62    /// Optional dev-only flag to auto-provision DB/schema when missing.
63    #[serde(default)]
64    pub auto_provision: Option<bool>,
65}
66
67/// Reusable DB connection config for both global servers and modules.
68/// DSN must be a FULL, valid DSN if provided (dsn crate compliant).
69#[derive(Debug, Clone, Deserialize, Serialize, Default)]
70#[serde(deny_unknown_fields)]
71pub struct DbConnConfig {
72    // DSN-style (full, valid). Optional: can be absent and rely on fields.
73    pub dsn: Option<String>,
74
75    // Field-based style; any of these override DSN parts when present:
76    pub host: Option<String>,
77    pub port: Option<u16>,
78    pub user: Option<String>,
79    pub password: Option<String>, // literal password or ${VAR} for env expansion
80    pub dbname: Option<String>,   // MUST be present in final for server-based DBs
81    #[serde(default)]
82    pub params: Option<HashMap<String, String>>,
83
84    // SQLite file-based helpers (module-level only; ignored for global):
85    pub file: Option<String>,  // relative name under home_dir/module
86    pub path: Option<PathBuf>, // absolute path
87
88    // Connection pool overrides:
89    #[serde(default)]
90    pub pool: Option<PoolCfg>,
91
92    // Module-level only: reference to a global server by name.
93    // If absent, this module config must be fully self-sufficient (dsn or fields).
94    pub server: Option<String>,
95}
96
97#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
98#[serde(deny_unknown_fields)]
99pub struct PoolCfg {
100    pub max_conns: Option<u32>,
101    pub min_conns: Option<u32>,
102    #[serde(with = "modkit_utils::humantime_serde::option", default)]
103    pub acquire_timeout: Option<Duration>,
104    #[serde(with = "modkit_utils::humantime_serde::option", default)]
105    pub idle_timeout: Option<Duration>,
106    #[serde(with = "modkit_utils::humantime_serde::option", default)]
107    pub max_lifetime: Option<Duration>,
108    pub test_before_acquire: Option<bool>,
109}
110
111impl PoolCfg {
112    /// Apply pool configuration to `PostgreSQL` pool options.
113    #[cfg(feature = "pg")]
114    #[must_use]
115    pub fn apply_pg(
116        &self,
117        mut opts: sea_orm::sqlx::postgres::PgPoolOptions,
118    ) -> sea_orm::sqlx::postgres::PgPoolOptions {
119        if let Some(max_conns) = self.max_conns {
120            opts = opts.max_connections(max_conns);
121        }
122        if let Some(min_conns) = self.min_conns {
123            opts = opts.min_connections(min_conns);
124        }
125        if let Some(acquire_timeout) = self.acquire_timeout {
126            opts = opts.acquire_timeout(acquire_timeout);
127        }
128        if let Some(idle_timeout) = self.idle_timeout {
129            opts = opts.idle_timeout(Some(idle_timeout));
130        }
131        if let Some(max_lifetime) = self.max_lifetime {
132            opts = opts.max_lifetime(Some(max_lifetime));
133        }
134        if let Some(test_before_acquire) = self.test_before_acquire {
135            opts = opts.test_before_acquire(test_before_acquire);
136        }
137        opts
138    }
139
140    /// Apply pool configuration to `MySQL` pool options.
141    #[cfg(feature = "mysql")]
142    #[must_use]
143    pub fn apply_mysql(
144        &self,
145        mut opts: sea_orm::sqlx::mysql::MySqlPoolOptions,
146    ) -> sea_orm::sqlx::mysql::MySqlPoolOptions {
147        if let Some(max_conns) = self.max_conns {
148            opts = opts.max_connections(max_conns);
149        }
150        if let Some(min_conns) = self.min_conns {
151            opts = opts.min_connections(min_conns);
152        }
153        if let Some(acquire_timeout) = self.acquire_timeout {
154            opts = opts.acquire_timeout(acquire_timeout);
155        }
156        if let Some(idle_timeout) = self.idle_timeout {
157            opts = opts.idle_timeout(Some(idle_timeout));
158        }
159        if let Some(max_lifetime) = self.max_lifetime {
160            opts = opts.max_lifetime(Some(max_lifetime));
161        }
162        if let Some(test_before_acquire) = self.test_before_acquire {
163            opts = opts.test_before_acquire(test_before_acquire);
164        }
165        opts
166    }
167
168    /// Apply pool configuration to `SQLite` pool options.
169    #[cfg(feature = "sqlite")]
170    #[must_use]
171    pub fn apply_sqlite(
172        &self,
173        mut opts: sea_orm::sqlx::sqlite::SqlitePoolOptions,
174    ) -> sea_orm::sqlx::sqlite::SqlitePoolOptions {
175        if let Some(max_conns) = self.max_conns {
176            opts = opts.max_connections(max_conns);
177        }
178        if let Some(min_conns) = self.min_conns {
179            opts = opts.min_connections(min_conns);
180        }
181        if let Some(acquire_timeout) = self.acquire_timeout {
182            opts = opts.acquire_timeout(acquire_timeout);
183        }
184        if let Some(idle_timeout) = self.idle_timeout {
185            opts = opts.idle_timeout(Some(idle_timeout));
186        }
187        if let Some(max_lifetime) = self.max_lifetime {
188            opts = opts.max_lifetime(Some(max_lifetime));
189        }
190        if let Some(test_before_acquire) = self.test_before_acquire {
191            opts = opts.test_before_acquire(test_before_acquire);
192        }
193        opts
194    }
195}