klauthed_core/config/schema/
database.rs1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum DbSystem {
15 #[default]
17 Postgres,
18 #[serde(rename = "mysql")]
20 MySql,
21 #[serde(rename = "mariadb")]
23 MariaDb,
24 Sqlite,
26 #[serde(rename = "mongodb")]
28 MongoDb,
29}
30
31impl DbSystem {
32 pub fn default_port(&self) -> Option<u16> {
34 match self {
35 DbSystem::Postgres => Some(5432),
36 DbSystem::MySql | DbSystem::MariaDb => Some(3306),
37 DbSystem::MongoDb => Some(27017),
38 DbSystem::Sqlite => None,
39 }
40 }
41
42 pub fn scheme(&self) -> &'static str {
44 match self {
45 DbSystem::Postgres => "postgres",
46 DbSystem::MySql | DbSystem::MariaDb => "mysql",
47 DbSystem::Sqlite => "sqlite",
48 DbSystem::MongoDb => "mongodb",
49 }
50 }
51
52 pub fn is_relational(&self) -> bool {
54 !matches!(self, DbSystem::MongoDb)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct PoolConfig {
61 #[serde(default = "PoolConfig::default_max_connections")]
63 pub max_connections: u32,
64 #[serde(default)]
66 pub min_connections: u32,
67 #[serde(default = "PoolConfig::default_acquire_timeout_secs")]
69 pub acquire_timeout_secs: u64,
70 #[serde(default)]
72 pub idle_timeout_secs: Option<u64>,
73 #[serde(default)]
75 pub max_lifetime_secs: Option<u64>,
76}
77
78impl PoolConfig {
79 fn default_max_connections() -> u32 {
80 10
81 }
82 fn default_acquire_timeout_secs() -> u64 {
83 30
84 }
85}
86
87impl Default for PoolConfig {
88 fn default() -> Self {
89 Self {
90 max_connections: Self::default_max_connections(),
91 min_connections: 0,
92 acquire_timeout_secs: Self::default_acquire_timeout_secs(),
93 idle_timeout_secs: None,
94 max_lifetime_secs: None,
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
102pub struct DatabaseConfig {
103 #[serde(default)]
105 pub system: DbSystem,
106 #[serde(default = "default_host")]
108 pub host: String,
109 #[serde(default)]
111 pub port: Option<u16>,
112 #[serde(default)]
114 pub database: String,
115 #[serde(default)]
117 pub username: Option<String>,
118 #[serde(default)]
120 pub password: Option<String>,
121 #[serde(default)]
124 pub url: Option<String>,
125 #[serde(default)]
128 pub options: BTreeMap<String, String>,
129 #[serde(default)]
131 pub pool: PoolConfig,
132}
133
134fn default_host() -> String {
135 "localhost".to_owned()
136}
137
138impl DatabaseConfig {
139 pub fn effective_port(&self) -> Option<u16> {
141 self.port.or_else(|| self.system.default_port())
142 }
143
144 pub fn connection_url(&self) -> String {
150 if let Some(url) = &self.url {
151 return url.clone();
152 }
153
154 let scheme = self.system.scheme();
155 if self.system == DbSystem::Sqlite {
156 return format!("{scheme}://{}", self.database);
157 }
158
159 let mut url = format!("{scheme}://");
160 if let Some(user) = &self.username {
161 url.push_str(user);
162 if let Some(password) = &self.password {
163 url.push(':');
164 url.push_str(password);
165 }
166 url.push('@');
167 }
168 url.push_str(&self.host);
169 if let Some(port) = self.effective_port() {
170 url.push(':');
171 url.push_str(&port.to_string());
172 }
173 url.push('/');
174 url.push_str(&self.database);
175
176 if !self.options.is_empty() {
177 let query =
178 self.options.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
179 url.push('?');
180 url.push_str(&query);
181 }
182 url
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use serde_json::json;
190
191 #[test]
192 fn deserializes_with_defaults() {
193 let cfg: DatabaseConfig = serde_json::from_value(json!({
194 "system": "postgres",
195 "database": "app",
196 "username": "svc",
197 "password": "pw"
198 }))
199 .unwrap();
200
201 assert_eq!(cfg.host, "localhost");
202 assert_eq!(cfg.effective_port(), Some(5432));
203 assert_eq!(cfg.pool.max_connections, 10);
204 assert_eq!(cfg.connection_url(), "postgres://svc:pw@localhost:5432/app");
205 }
206
207 #[test]
208 fn url_field_overrides_components() {
209 let cfg = DatabaseConfig { url: Some("postgres://custom/db".into()), ..Default::default() };
210 assert_eq!(cfg.connection_url(), "postgres://custom/db");
211 }
212
213 #[test]
214 fn mongodb_url_with_options() {
215 let mut options = BTreeMap::new();
216 options.insert("replicaSet".to_string(), "rs0".to_string());
217 let cfg = DatabaseConfig {
218 system: DbSystem::MongoDb,
219 host: "mongo".into(),
220 database: "app".into(),
221 options,
222 ..Default::default()
223 };
224 assert_eq!(cfg.effective_port(), Some(27017));
225 assert!(!cfg.system.is_relational());
226 assert_eq!(cfg.connection_url(), "mongodb://mongo:27017/app?replicaSet=rs0");
227 }
228
229 #[test]
230 fn db_system_external_names_are_natural() {
231 let cases = [
232 ("postgres", DbSystem::Postgres),
233 ("mysql", DbSystem::MySql),
234 ("mariadb", DbSystem::MariaDb),
235 ("sqlite", DbSystem::Sqlite),
236 ("mongodb", DbSystem::MongoDb),
237 ];
238 for (name, expected) in cases {
239 let parsed: DbSystem = serde_json::from_value(json!(name)).unwrap();
240 assert_eq!(parsed, expected, "deserializing {name}");
241 assert_eq!(serde_json::to_value(expected).unwrap(), json!(name));
242 }
243 }
244
245 #[test]
246 fn sqlite_uses_path() {
247 let cfg = DatabaseConfig {
248 system: DbSystem::Sqlite,
249 database: "/var/lib/app.db".into(),
250 ..Default::default()
251 };
252 assert_eq!(cfg.effective_port(), None);
253 assert_eq!(cfg.connection_url(), "sqlite:///var/lib/app.db");
254 }
255}