doido_model/pool.rs
1//! Database connections backed by the model [`config`](crate::config).
2//!
3//! Besides the one-shot [`connect`]/[`connect_with_url`] helpers, this module
4//! holds a process-global connection installed once at application boot
5//! ([`init`]) and read from request handlers via [`pool`]. Controllers reach it
6//! through `Context::db()`.
7
8use crate::config;
9use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbErr};
10use std::sync::OnceLock;
11
12/// Process-global connection, installed by [`init`]/[`set_pool`].
13static POOL: OnceLock<DatabaseConnection> = OnceLock::new();
14
15/// Builds [`ConnectOptions`] toggling sea-orm's SQL statement logging, so
16/// queries surface through the global tracing subscriber (target `sqlx::query`,
17/// level `INFO`) when `sql_logging` is on. The flag comes from the `logger.sql`
18/// config key; tune visibility further with `RUST_LOG` (see `doido_core::logger`).
19fn options(url: &str, sql_logging: bool) -> ConnectOptions {
20 pool_options(url, sql_logging, None, None)
21}
22
23/// Builds [`ConnectOptions`] with SQL logging plus optional pool tuning:
24/// `max_connections` (config `database.pool`) and `connect_timeout` in seconds
25/// (config `database.connect_timeout`).
26pub fn pool_options(
27 url: &str,
28 sql_logging: bool,
29 max_connections: Option<u32>,
30 connect_timeout_secs: Option<u64>,
31) -> ConnectOptions {
32 let mut opts = ConnectOptions::new(url.to_owned());
33 // Statements log at the default `INFO` level under target `sqlx::query`.
34 opts.sqlx_logging(sql_logging);
35 if let Some(max) = max_connections {
36 opts.max_connections(max);
37 }
38 if let Some(secs) = connect_timeout_secs {
39 opts.connect_timeout(std::time::Duration::from_secs(secs));
40 }
41 opts
42}
43
44/// Connects to the database named by the current environment's
45/// `config/<env>.yml` `database.url`, honouring its `logger.sql` toggle and any
46/// `database.pool` / `database.connect_timeout` knobs.
47pub async fn connect() -> Result<DatabaseConnection, DbErr> {
48 let config = config::load();
49 let db = config.database();
50 Database::connect(pool_options(
51 db.url.as_str(),
52 config.logger().sql,
53 db.pool,
54 db.connect_timeout,
55 ))
56 .await
57}
58
59/// Connects using an explicit database URL, bypassing the config file. SQL
60/// statement logging stays enabled (the default).
61pub async fn connect_with_url(url: &str) -> Result<DatabaseConnection, DbErr> {
62 Database::connect(options(url, true)).await
63}
64
65/// Connects (via [`connect`]) and installs the process-global pool, returning a
66/// reference to it. Idempotent: if already initialised, the existing connection
67/// is returned and the new one discarded. Call once at server boot.
68pub async fn init() -> Result<&'static DatabaseConnection, DbErr> {
69 if let Some(existing) = POOL.get() {
70 return Ok(existing);
71 }
72 let conn = connect().await?;
73 let _ = POOL.set(conn);
74 Ok(POOL.get().expect("pool was just set"))
75}
76
77/// Installs an already-open connection as the global pool (e.g. in tests).
78/// Returns `Err` with the connection back if one was already installed.
79pub fn set_pool(conn: DatabaseConnection) -> Result<(), DatabaseConnection> {
80 POOL.set(conn)
81}
82
83/// Returns the global connection, panicking if [`init`]/[`set_pool`] was never
84/// called. Use from request handlers where boot is guaranteed to have run.
85pub fn pool() -> &'static DatabaseConnection {
86 POOL.get().expect(
87 "database pool not initialised; call doido_model::pool::init() at boot before handling requests",
88 )
89}
90
91/// Returns the global connection if installed, else `None`.
92pub fn try_pool() -> Option<&'static DatabaseConnection> {
93 POOL.get()
94}
95
96/// A process-global lock for serializing tests that share the global pool.
97///
98/// The pool is process-global, so tests that install it and run requests must
99/// not execute concurrently. Generated scaffold controller tests hold this lock
100/// for their duration; hold it in any hand-written test that touches the global
101/// pool. Poisoning is ignored so one failing test doesn't cascade.
102pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
103 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
104 LOCK.lock().unwrap_or_else(|e| e.into_inner())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[tokio::test]
112 async fn set_pool_then_pool_returns_it() {
113 // No pool before install.
114 assert!(try_pool().is_none());
115
116 let conn = connect_with_url("sqlite::memory:").await.unwrap();
117 set_pool(conn).expect("first install succeeds");
118
119 // Now both accessors see it; a second install is rejected.
120 assert!(try_pool().is_some());
121 let _ = pool();
122 let second = connect_with_url("sqlite::memory:").await.unwrap();
123 assert!(set_pool(second).is_err());
124 }
125}