Skip to main content

rango_orm_core/
config.rs

1/// Database connection configuration.
2/// Backend-agnostic — works for PostgreSQL, MySQL, MariaDB, SQLite.
3#[derive(Clone)]
4pub struct DatabaseConfig {
5    pub url: String,
6    pub max_connections: u32,
7    pub min_connections: u32,
8    pub connect_timeout: u64,
9    pub idle_timeout: u64,
10    /// Close connections older than this many seconds (lets the DB rebalance, frees server memory).
11    pub max_lifetime: u64,
12    /// Ping each connection before handing it out. Off by default: saves one round-trip per
13    /// acquire; enable behind proxies or failover setups that kill idle connections silently.
14    pub test_before_acquire: bool,
15    /// Postgres `statement_timeout` in milliseconds, set at connection startup. `None` = server default.
16    pub statement_timeout_ms: Option<u64>,
17    /// Postgres `lock_timeout` in milliseconds, set at connection startup. `None` = server default.
18    pub lock_timeout_ms: Option<u64>,
19    /// Queries slower than this (milliseconds) are logged at WARN (SQL text only, never bound values).
20    pub slow_query_threshold_ms: u64,
21    /// SQL statements executed on every new connection.
22    /// Useful for strict mode, timezone, charset, search_path, etc.
23    pub after_connect: Vec<String>,
24}
25
26impl std::fmt::Debug for DatabaseConfig {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("DatabaseConfig")
29            .field("url", &Self::redact_url(&self.url))
30            .field("max_connections", &self.max_connections)
31            .field("min_connections", &self.min_connections)
32            .field("connect_timeout", &self.connect_timeout)
33            .field("idle_timeout", &self.idle_timeout)
34            .field("max_lifetime", &self.max_lifetime)
35            .field("test_before_acquire", &self.test_before_acquire)
36            .field("statement_timeout_ms", &self.statement_timeout_ms)
37            .field("lock_timeout_ms", &self.lock_timeout_ms)
38            .field("slow_query_threshold_ms", &self.slow_query_threshold_ms)
39            .field("after_connect", &self.after_connect)
40            .finish()
41    }
42}
43
44impl DatabaseConfig {
45    pub fn redact_url(url: &str) -> String {
46        let scheme_end = match url.find("://") {
47            Some(idx) => idx,
48            None => return url.to_string(),
49        };
50        let after_scheme = &url[scheme_end + 3..];
51        // Userinfo lives in the authority (before the path/query); the last '@' ends it.
52        let authority_end = after_scheme
53            .find(['/', '?', '#'])
54            .unwrap_or(after_scheme.len());
55        let at_idx = match after_scheme[..authority_end].rfind('@') {
56            Some(idx) => idx,
57            None => return url.to_string(),
58        };
59        let authority = &after_scheme[..at_idx];
60        let colon_idx = match authority.find(':') {
61            Some(idx) => idx,
62            None => return url.to_string(),
63        };
64        let user = &authority[..colon_idx];
65        format!(
66            "{}://{}:***@{}",
67            &url[..scheme_end],
68            user,
69            &after_scheme[at_idx + 1..]
70        )
71    }
72
73    /// Build from a full connection URL.
74    /// Examples:
75    ///   postgres://user:pass@localhost:5432/mydb
76    ///   mysql://user:pass@localhost:3306/mydb
77    ///   sqlite:///path/to/db.sqlite3
78    pub fn from_url(url: impl Into<String>) -> Self {
79        Self {
80            url: url.into(),
81            max_connections: 20,
82            min_connections: 2,
83            connect_timeout: 10,
84            idle_timeout: 600,
85            max_lifetime: 1800,
86            test_before_acquire: false,
87            statement_timeout_ms: None,
88            lock_timeout_ms: None,
89            slow_query_threshold_ms: 500,
90            after_connect: Vec::new(),
91        }
92    }
93
94    /// Build from environment variable (defaults to DATABASE_URL).
95    pub fn from_env() -> Result<Self, std::env::VarError> {
96        Self::from_env_var("DATABASE_URL")
97    }
98
99    pub fn from_env_var(var: &str) -> Result<Self, std::env::VarError> {
100        let url = std::env::var(var)?;
101        Ok(Self::from_url(url))
102    }
103
104    /// Detect the backend kind from the URL scheme.
105    pub fn backend(&self) -> BackendKind {
106        if self.url.starts_with("postgres") {
107            BackendKind::Postgres
108        } else if self.url.starts_with("mysql") || self.url.starts_with("mariadb") {
109            BackendKind::Mysql
110        } else if self.url.starts_with("sqlite") {
111            BackendKind::Sqlite
112        } else {
113            BackendKind::Unknown
114        }
115    }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum BackendKind {
120    Postgres,
121    Mysql,
122    Sqlite,
123    Unknown,
124}
125
126impl BackendKind {
127    /// Quote an identifier for this backend: backticks on MySQL, `"` elsewhere.
128    pub fn quote_ident(self, ident: &str) -> String {
129        match self {
130            BackendKind::Mysql => crate::query::quote_ident_mysql(ident),
131            _ => crate::query::quote_ident(ident),
132        }
133    }
134}
135
136/// Loopback addresses and Unix sockets never leave the machine, so backends do not force TLS there.
137#[doc(hidden)]
138pub fn is_local_host(host: &str) -> bool {
139    host.starts_with('/') || matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn local_hosts_are_not_forced_to_tls() {
148        for h in ["localhost", "127.0.0.1", "::1", "/var/run/postgresql"] {
149            assert!(is_local_host(h), "{h}");
150        }
151        for h in ["db.example.com", "10.0.0.5", "localhost.evil.com"] {
152            assert!(!is_local_host(h), "{h}");
153        }
154    }
155
156    #[test]
157    fn redact_url_replaces_password() {
158        assert_eq!(
159            DatabaseConfig::redact_url("postgres://user:pass@host:5432/db"),
160            "postgres://user:***@host:5432/db"
161        );
162    }
163
164    #[test]
165    fn redact_url_handles_special_chars_and_query() {
166        assert_eq!(
167            DatabaseConfig::redact_url("postgres://u:p@ss:w@host/db"),
168            "postgres://u:***@host/db"
169        );
170        assert_eq!(
171            DatabaseConfig::redact_url("postgres://host:5432/db?x=a@b"),
172            "postgres://host:5432/db?x=a@b"
173        );
174    }
175
176    #[test]
177    fn redact_url_without_password_is_unchanged() {
178        assert_eq!(
179            DatabaseConfig::redact_url("postgres://user@host:5432/db"),
180            "postgres://user@host:5432/db"
181        );
182    }
183
184    #[test]
185    fn redact_url_sqlite_memory_is_unchanged() {
186        assert_eq!(
187            DatabaseConfig::redact_url("sqlite::memory:"),
188            "sqlite::memory:"
189        );
190    }
191
192    #[test]
193    fn database_config_debug_redacts_url() {
194        let config = DatabaseConfig::from_url("postgres://user:secret@host:5432/db");
195        let debug = format!("{:?}", config);
196        assert!(!debug.contains("secret"));
197        assert!(debug.contains("user:***@host"));
198    }
199
200    #[test]
201    fn from_url_defaults() {
202        let config = DatabaseConfig::from_url("postgres://user:pass@host:5432/db");
203        assert_eq!(config.max_lifetime, 1800);
204        assert!(!config.test_before_acquire);
205        assert_eq!(config.statement_timeout_ms, None);
206        assert_eq!(config.lock_timeout_ms, None);
207        assert_eq!(config.slow_query_threshold_ms, 500);
208    }
209}