Skip to main content

rws_server/model/
pool.rs

1//! Async connection pool — `DbPool` and `DbTransaction`.
2//!
3//! Backed by `sqlx`. `model-sqlite`, `model-postgres`, and `model-mysql` are
4//! not mutually exclusive: each compiled-in feature adds a variant to the
5//! `DbPool`/`DbTransaction` enums below, so a single binary can hold pools to
6//! more than one backend at once. Which variant a given `DbPool` is depends
7//! on the [`Backend`] passed via [`DbConfig::backend`].
8
9use std::future::Future;
10
11use super::backend::Backend;
12use super::connection::{DbConfig, DbTls};
13use super::migration;
14use super::{DbError, Model, ModelRow, MigrationStatus, Value};
15
16// ── Connect options ───────────────────────────────────────────────────────────
17
18#[cfg(feature = "model-postgres")]
19#[doc(hidden)]
20pub fn pg_options(config: &DbConfig, tls: &DbTls) -> sqlx::postgres::PgConnectOptions {
21    use super::connection::DbSslMode;
22    use sqlx::postgres::{PgConnectOptions, PgSslMode};
23    let mut options = PgConnectOptions::new_without_pgpass()
24        .host(&config.host)
25        .port(config.port)
26        .username(&config.user)
27        .password(&config.password)
28        .database(&config.database)
29        .ssl_mode(match tls.mode {
30            DbSslMode::Disable => PgSslMode::Disable,
31            DbSslMode::Prefer => PgSslMode::Prefer,
32            DbSslMode::Require => PgSslMode::Require,
33            DbSslMode::VerifyCa => PgSslMode::VerifyCa,
34            DbSslMode::VerifyFull => PgSslMode::VerifyFull,
35        });
36    if let Some(ca) = &tls.root_cert {
37        options = options.ssl_root_cert(ca);
38    }
39    if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
40        options = options.ssl_client_cert(cert).ssl_client_key(key);
41    }
42    options
43}
44
45#[cfg(feature = "model-mysql")]
46#[doc(hidden)]
47pub fn mysql_options(config: &DbConfig, tls: &DbTls) -> sqlx::mysql::MySqlConnectOptions {
48    use super::connection::DbSslMode;
49    use sqlx::mysql::{MySqlConnectOptions, MySqlSslMode};
50    let mut options = MySqlConnectOptions::new()
51        .host(&config.host)
52        .port(config.port)
53        .username(&config.user)
54        .password(&config.password)
55        .database(&config.database)
56        .ssl_mode(match tls.mode {
57            DbSslMode::Disable => MySqlSslMode::Disabled,
58            DbSslMode::Prefer => MySqlSslMode::Preferred,
59            DbSslMode::Require => MySqlSslMode::Required,
60            DbSslMode::VerifyCa => MySqlSslMode::VerifyCa,
61            DbSslMode::VerifyFull => MySqlSslMode::VerifyIdentity,
62        });
63    if let Some(ca) = &tls.root_cert {
64        options = options.ssl_ca(ca);
65    }
66    if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
67        options = options.ssl_client_cert(cert).ssl_client_key(key);
68    }
69    options
70}
71
72// ── DbPool ────────────────────────────────────────────────────────────────────
73
74/// An async connection pool backed by sqlx.
75///
76/// Cheap to clone — the inner pool is reference-counted. Which variant a
77/// `DbPool` is depends on the [`Backend`] it was created with; see the module
78/// docs for running more than one backend in the same binary.
79#[derive(Clone, Debug)]
80pub enum DbPool {
81    #[cfg(feature = "model-sqlite")]
82    Sqlite(sqlx::Pool<sqlx::Sqlite>),
83    #[cfg(feature = "model-postgres")]
84    Postgres(sqlx::Pool<sqlx::Postgres>),
85    #[cfg(feature = "model-mysql")]
86    MySql(sqlx::Pool<sqlx::MySql>),
87}
88
89impl DbPool {
90    /// Create a new pool for `config.backend`, with the given configuration
91    /// and the default TLS ([`DbTls::default`]: TLS when the server offers
92    /// it, certificate not checked).
93    pub async fn new(config: DbConfig) -> Result<Self, DbError> {
94        DbPool::new_with_tls(config, DbTls::default()).await
95    }
96
97    /// As [`DbPool::new`], with `tls` for PostgreSQL and MySQL (SQLite
98    /// ignores it). User and password are passed as they are, not through a
99    /// URL, so any characters work.
100    pub async fn new_with_tls(config: DbConfig, tls: DbTls) -> Result<Self, DbError> {
101        let _ = &tls;
102        // sqlx builds its rustls config with `ClientConfig::builder()`, which
103        // panics when no process-wide crypto provider is installed — so far
104        // only rws's TLS listener installed one, and an app serving plain
105        // HTTP behind a load balancer panicked at its first TLS connection to
106        // the database. Install rws's (aws-lc-rs) unless one is set already.
107        #[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
108        let _ = crate::crypto_provider().install_default();
109        match config.backend {
110            #[cfg(feature = "model-sqlite")]
111            Backend::Sqlite => {
112                let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
113                    .max_connections(config.pool_size)
114                    .connect(&config.to_url())
115                    .await
116                    .map_err(|e| DbError::new(e.to_string()))?;
117                Ok(DbPool::Sqlite(pool))
118            }
119            #[cfg(feature = "model-postgres")]
120            Backend::Postgres => {
121                let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
122                    .max_connections(config.pool_size)
123                    .connect_with(pg_options(&config, &tls))
124                    .await
125                    .map_err(|e| DbError::new(e.to_string()))?;
126                Ok(DbPool::Postgres(pool))
127            }
128            #[cfg(feature = "model-mysql")]
129            Backend::MySql => {
130                let pool = sqlx::pool::PoolOptions::<sqlx::MySql>::new()
131                    .max_connections(config.pool_size)
132                    .connect_with(mysql_options(&config, &tls))
133                    .await
134                    .map_err(|e| DbError::new(e.to_string()))?;
135                Ok(DbPool::MySql(pool))
136            }
137        }
138    }
139
140    /// Create a pool using [`DbConfig::from_env`] and [`DbTls::from_env`].
141    pub async fn from_env() -> Result<Self, DbError> {
142        DbPool::new_with_tls(DbConfig::from_env()?, DbTls::from_env()?).await
143    }
144
145    /// Create a pool backed by a SQLite in-memory database.
146    ///
147    /// All connections in the pool share the same in-memory database
148    /// (max_connections = 1).  Each call returns an independent, isolated
149    /// database — ideal for tests.
150    ///
151    /// ```rust,no_run
152    /// # extern crate rws_server as rust_web_server;
153    /// # #[cfg(feature = "model-sqlite")]
154    /// # async fn example() -> Result<(), rust_web_server::model::DbError> {
155    /// use rust_web_server::model::{DbPool, Value};
156    ///
157    /// let pool = DbPool::memory().await?;
158    /// pool.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", &[]).await?;
159    /// pool.execute("INSERT INTO t (v) VALUES (?)", &[Value::Text("hello".into())]).await?;
160    /// let rows = pool.query_rows("SELECT * FROM t", &[]).await?;
161    /// assert_eq!(1, rows.len());
162    /// # Ok(())
163    /// # }
164    /// ```
165    #[cfg(feature = "model-sqlite")]
166    pub async fn memory() -> Result<Self, DbError> {
167        let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
168            .max_connections(1)
169            .connect("sqlite::memory:")
170            .await
171            .map_err(|e| DbError::new(e.to_string()))?;
172        Ok(DbPool::Sqlite(pool))
173    }
174
175    /// Which backend this pool talks to.
176    pub fn backend(&self) -> Backend {
177        match self {
178            #[cfg(feature = "model-sqlite")]
179            DbPool::Sqlite(_) => Backend::Sqlite,
180            #[cfg(feature = "model-postgres")]
181            DbPool::Postgres(_) => Backend::Postgres,
182            #[cfg(feature = "model-mysql")]
183            DbPool::MySql(_) => Backend::MySql,
184        }
185    }
186
187    // ── Core async SQL operations ─────────────────────────────────────────────
188
189    /// Execute a SQL statement (INSERT / UPDATE / DELETE / DDL).
190    ///
191    /// Returns the number of rows affected.
192    pub async fn execute(&self, sql: &str, params: &[Value]) -> Result<u64, DbError> {
193        let span = db_span(self.backend(), sql);
194        let result = match self {
195            #[cfg(feature = "model-sqlite")]
196            DbPool::Sqlite(pool) => sqlite_pool_execute(pool, sql, params).await,
197            #[cfg(feature = "model-postgres")]
198            DbPool::Postgres(pool) => pg_pool_execute(pool, sql, params).await,
199            #[cfg(feature = "model-mysql")]
200            DbPool::MySql(pool) => mysql_pool_execute(pool, sql, params).await,
201        };
202        finish_db_span(span, &result);
203        result
204    }
205
206    /// Execute a SQL query and return untyped rows.
207    pub async fn query_rows(&self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
208        let span = db_span(self.backend(), sql);
209        let result = match self {
210            #[cfg(feature = "model-sqlite")]
211            DbPool::Sqlite(pool) => sqlite_pool_query_rows(pool, sql, params).await,
212            #[cfg(feature = "model-postgres")]
213            DbPool::Postgres(pool) => pg_pool_query_rows(pool, sql, params).await,
214            #[cfg(feature = "model-mysql")]
215            DbPool::MySql(pool) => mysql_pool_query_rows(pool, sql, params).await,
216        };
217        finish_db_span(span, &result);
218        result
219    }
220
221    /// Execute a SQL query and deserialise results into `T: Model`.
222    pub async fn query<T: Model>(&self, sql: &str, params: &[Value]) -> Result<Vec<T>, DbError> {
223        let rows = self.query_rows(sql, params).await?;
224        rows.iter().map(|r| T::from_row(r)).collect()
225    }
226
227    /// Execute a SQL query returning untyped rows (alias for `query_rows`).
228    pub async fn query_raw(&self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
229        self.query_rows(sql, params).await
230    }
231
232    /// Insert-then-fetch-generated-id, dispatched per backend: SQLite uses
233    /// `last_insert_rowid()`, PostgreSQL appends `RETURNING`, MySQL uses
234    /// `last_insert_id()`. Used by the repository's INSERT logic.
235    #[doc(hidden)]
236    pub async fn insert_returning_id(
237        &self,
238        insert_sql: &str,
239        params: &[Value],
240        _pk_col: &str,
241    ) -> Result<i64, DbError> {
242        let span = db_span(self.backend(), insert_sql);
243        let result = match self {
244            #[cfg(feature = "model-sqlite")]
245            DbPool::Sqlite(pool) => sqlite_last_insert_id(pool, insert_sql, params).await,
246            #[cfg(feature = "model-postgres")]
247            DbPool::Postgres(pool) => pg_insert_returning(pool, insert_sql, params, _pk_col).await,
248            #[cfg(feature = "model-mysql")]
249            DbPool::MySql(pool) => mysql_last_insert_id(pool, insert_sql, params).await,
250        };
251        finish_db_span(span, &result);
252        result
253    }
254
255    // ── Transactions ──────────────────────────────────────────────────────────
256
257    /// Begin a transaction. Call [`DbTransaction::commit`] to commit or let it
258    /// drop to automatically roll back.
259    pub async fn begin(&self) -> Result<DbTransaction, DbError> {
260        match self {
261            #[cfg(feature = "model-sqlite")]
262            DbPool::Sqlite(pool) => {
263                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
264                Ok(DbTransaction::Sqlite(tx))
265            }
266            #[cfg(feature = "model-postgres")]
267            DbPool::Postgres(pool) => {
268                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
269                Ok(DbTransaction::Postgres(tx))
270            }
271            #[cfg(feature = "model-mysql")]
272            DbPool::MySql(pool) => {
273                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
274                Ok(DbTransaction::MySql(tx))
275            }
276        }
277    }
278
279    /// Run a closure in a transaction.
280    ///
281    /// The closure owns the transaction: call `tx.commit()` before returning
282    /// `Ok`. A transaction that isn't committed — the closure returns `Err`,
283    /// or `Ok` without committing — is dropped, which rolls it back.
284    ///
285    /// ```rust,no_run
286    /// # extern crate rws_server as rust_web_server;
287    /// # #[cfg(feature = "model-sqlite")]
288    /// # async fn example() -> Result<(), rust_web_server::model::DbError> {
289    /// use rust_web_server::model::{DbPool, Value};
290    ///
291    /// let pool = DbPool::memory().await?;
292    /// let result = pool.transaction(|mut tx| async move {
293    ///     tx.execute("INSERT INTO t (v) VALUES (?)", &[Value::Text("hi".into())]).await?;
294    ///     tx.commit().await?;
295    ///     Ok(42i32)
296    /// }).await?;
297    /// assert_eq!(42, result);
298    /// # Ok(())
299    /// # }
300    /// ```
301    pub async fn transaction<F, T, Fut>(&self, f: F) -> Result<T, DbError>
302    where
303        F: FnOnce(DbTransaction) -> Fut + Send,
304        Fut: Future<Output = Result<T, DbError>> + Send,
305        T: Send,
306    {
307        let tx = self.begin().await?;
308        match f(tx).await {
309            Ok(v) => Ok(v),
310            Err(e) => Err(e),
311        }
312    }
313
314    // ── Migration ─────────────────────────────────────────────────────────────
315
316    /// Run pending migrations from SQL files in `dir`.
317    ///
318    /// Files are applied in lexicographic order. Already-applied versions are
319    /// tracked in a `_schema_migrations` table (created if absent). Idempotent.
320    pub async fn migrate(&self, dir: &str) -> Result<(), DbError> {
321        migration::run_migrations(self, dir).await
322    }
323
324    /// Return the status (applied / pending) for each SQL file in `dir`.
325    pub async fn migration_status(&self, dir: &str) -> Result<Vec<MigrationStatus>, DbError> {
326        migration::migration_status(self, dir).await
327    }
328
329    /// Roll back the most recently applied migration in `dir`.
330    ///
331    /// Requires a companion `<version-stem>.down.sql` file next to the up
332    /// migration it undoes (e.g. `0001_x.sql` → `0001_x.down.sql`); returns
333    /// `Err` if that file is missing. Returns `Ok(None)` if nothing is
334    /// currently applied.
335    pub async fn rollback_last(&self, dir: &str) -> Result<Option<String>, DbError> {
336        migration::rollback_last(self, dir).await
337    }
338
339    /// Roll back the last `n` applied migrations, most recently applied
340    /// first. Returns the versions actually rolled back — fewer than `n` if
341    /// fewer than `n` migrations were applied. Stops at the first missing
342    /// down file or SQL error; migrations already rolled back in earlier
343    /// iterations stay rolled back.
344    pub async fn rollback(&self, dir: &str, n: usize) -> Result<Vec<String>, DbError> {
345        migration::rollback(self, dir, n).await
346    }
347
348    /// Run the seed files in `dir` (18.30.0): every `*.sql` file, in
349    /// lexicographic order, each in its own transaction (statements split
350    /// at `;`, as migrations). Unlike migrations nothing records that a
351    /// seed ran — it runs again every time, so write seeds that can (`INSERT
352    /// … ON CONFLICT DO NOTHING` on PostgreSQL and SQLite, `INSERT IGNORE`
353    /// on MySQL). Answers the files run; stops at the first that fails,
354    /// whose statements are rolled back.
355    pub async fn seed(&self, dir: &str) -> Result<Vec<String>, DbError> {
356        migration::run_seeds(self, dir).await
357    }
358
359    /// A pool for a database URL (18.30.0): `sqlite:<path>` (or
360    /// `sqlite::memory:`), `postgres://user:password@host:port/db` (also
361    /// `postgresql://`), `mysql://user:password@host:port/db` (also
362    /// `mariadb://`) — sqlx's syntax, query parameters included (`sslmode`,
363    /// `ssl-mode`, …). The backend's `model-*` feature must be compiled in.
364    pub async fn connect(url: &str) -> Result<Self, DbError> {
365        let url = url.trim();
366        let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
367        let backend = match scheme.as_str() {
368            "sqlite" => "sqlite",
369            "postgres" | "postgresql" => "postgres",
370            "mysql" | "mariadb" => "mysql",
371            _ => {
372                return Err(DbError::new(format!(
373                    "database URL {:?}: expected sqlite:<path>, postgres://… or mysql://…",
374                    redact_url(url)
375                )))
376            }
377        };
378        let backend = Backend::parse(backend)
379            .map_err(|e| DbError::new(format!("database URL {:?}: {}", redact_url(url), e.0)))?;
380        #[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
381        let _ = crate::crypto_provider().install_default();
382        let bad_url = |e: sqlx::Error| DbError::new(format!("database URL {:?}: {e}", redact_url(url)));
383        match backend {
384            #[cfg(feature = "model-sqlite")]
385            Backend::Sqlite => {
386                let options: sqlx::sqlite::SqliteConnectOptions = url.parse().map_err(bad_url)?;
387                let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
388                    .max_connections(1)
389                    .connect_with(options)
390                    .await
391                    .map_err(|e| DbError::new(e.to_string()))?;
392                Ok(DbPool::Sqlite(pool))
393            }
394            #[cfg(feature = "model-postgres")]
395            Backend::Postgres => {
396                let options: sqlx::postgres::PgConnectOptions = url.parse().map_err(bad_url)?;
397                let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
398                    .max_connections(4)
399                    .connect_with(options)
400                    .await
401                    .map_err(|e| DbError::new(e.to_string()))?;
402                Ok(DbPool::Postgres(pool))
403            }
404            #[cfg(feature = "model-mysql")]
405            Backend::MySql => {
406                let options: sqlx::mysql::MySqlConnectOptions = url.parse().map_err(bad_url)?;
407                let pool = sqlx::pool::PoolOptions::<sqlx::MySql>::new()
408                    .max_connections(4)
409                    .connect_with(options)
410                    .await
411                    .map_err(|e| DbError::new(e.to_string()))?;
412                Ok(DbPool::MySql(pool))
413            }
414        }
415    }
416}
417
418/// `url` with the password replaced by `***`, for error messages.
419#[doc(hidden)]
420pub fn redact_url(url: &str) -> String {
421    let Some((scheme, rest)) = url.split_once("://") else { return url.to_string() };
422    let authority_end = rest.find('/').unwrap_or(rest.len());
423    let (authority, tail) = rest.split_at(authority_end);
424    match authority.rsplit_once('@') {
425        Some((userinfo, host)) => match userinfo.split_once(':') {
426            Some((user, _)) => format!("{scheme}://{user}:***@{host}{tail}"),
427            None => url.to_string(),
428        },
429        None => url.to_string(),
430    }
431}
432
433// ── DbTransaction ─────────────────────────────────────────────────────────────
434
435/// An in-progress database transaction.
436///
437/// Created with [`DbPool::begin`].  Calling [`commit`][DbTransaction::commit]
438/// commits the transaction; dropping without committing rolls it back.
439pub enum DbTransaction {
440    #[cfg(feature = "model-sqlite")]
441    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
442    #[cfg(feature = "model-postgres")]
443    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
444    #[cfg(feature = "model-mysql")]
445    MySql(sqlx::Transaction<'static, sqlx::MySql>),
446}
447
448impl DbTransaction {
449    fn backend(&self) -> Backend {
450        match self {
451            #[cfg(feature = "model-sqlite")]
452            DbTransaction::Sqlite(_) => Backend::Sqlite,
453            #[cfg(feature = "model-postgres")]
454            DbTransaction::Postgres(_) => Backend::Postgres,
455            #[cfg(feature = "model-mysql")]
456            DbTransaction::MySql(_) => Backend::MySql,
457        }
458    }
459
460    /// Execute a SQL statement inside this transaction.
461    pub async fn execute(&mut self, sql: &str, params: &[Value]) -> Result<u64, DbError> {
462        let span = db_span(self.backend(), sql);
463        let result = match self {
464            #[cfg(feature = "model-sqlite")]
465            DbTransaction::Sqlite(tx) => sqlite_tx_execute(tx, sql, params).await,
466            #[cfg(feature = "model-postgres")]
467            DbTransaction::Postgres(tx) => pg_tx_execute(tx, sql, params).await,
468            #[cfg(feature = "model-mysql")]
469            DbTransaction::MySql(tx) => mysql_tx_execute(tx, sql, params).await,
470        };
471        finish_db_span(span, &result);
472        result
473    }
474
475    /// Execute a SQL query inside this transaction, returning untyped rows.
476    pub async fn query_rows(&mut self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
477        let span = db_span(self.backend(), sql);
478        let result = match self {
479            #[cfg(feature = "model-sqlite")]
480            DbTransaction::Sqlite(tx) => sqlite_tx_query_rows(tx, sql, params).await,
481            #[cfg(feature = "model-postgres")]
482            DbTransaction::Postgres(tx) => pg_tx_query_rows(tx, sql, params).await,
483            #[cfg(feature = "model-mysql")]
484            DbTransaction::MySql(tx) => mysql_tx_query_rows(tx, sql, params).await,
485        };
486        finish_db_span(span, &result);
487        result
488    }
489
490    /// Execute a SQL query inside this transaction and deserialise into `T: Model`.
491    pub async fn query<T: Model>(&mut self, sql: &str, params: &[Value]) -> Result<Vec<T>, DbError> {
492        let rows = self.query_rows(sql, params).await?;
493        rows.iter().map(|r| T::from_row(r)).collect()
494    }
495
496    /// Commit this transaction.
497    pub async fn commit(self) -> Result<(), DbError> {
498        match self {
499            #[cfg(feature = "model-sqlite")]
500            DbTransaction::Sqlite(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
501            #[cfg(feature = "model-postgres")]
502            DbTransaction::Postgres(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
503            #[cfg(feature = "model-mysql")]
504            DbTransaction::MySql(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
505        }
506    }
507
508    /// Roll back this transaction explicitly (also happens on drop).
509    pub async fn rollback(self) -> Result<(), DbError> {
510        match self {
511            #[cfg(feature = "model-sqlite")]
512            DbTransaction::Sqlite(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
513            #[cfg(feature = "model-postgres")]
514            DbTransaction::Postgres(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
515            #[cfg(feature = "model-mysql")]
516            DbTransaction::MySql(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
517        }
518    }
519}
520
521// ── Internal helpers (SQLite) ─────────────────────────────────────────────────
522
523#[cfg(feature = "model-sqlite")]
524async fn sqlite_pool_execute(
525    pool: &sqlx::Pool<sqlx::Sqlite>,
526    sql: &str,
527    params: &[Value],
528) -> Result<u64, DbError> {
529    let mut args = sqlx::sqlite::SqliteArguments::default();
530    bind_sqlite_args(&mut args, params)?;
531    let r = sqlx::query_with(sql, args)
532        .execute(pool)
533        .await
534        .map_err(|e| DbError::new(e.to_string()))?;
535    Ok(r.rows_affected())
536}
537
538#[cfg(feature = "model-sqlite")]
539async fn sqlite_pool_query_rows(
540    pool: &sqlx::Pool<sqlx::Sqlite>,
541    sql: &str,
542    params: &[Value],
543) -> Result<Vec<ModelRow>, DbError> {
544    let mut args = sqlx::sqlite::SqliteArguments::default();
545    bind_sqlite_args(&mut args, params)?;
546    let rows = sqlx::query_with(sql, args)
547        .fetch_all(pool)
548        .await
549        .map_err(|e| DbError::new(e.to_string()))?;
550    rows.into_iter().map(sqlite_row_to_model_row).collect()
551}
552
553#[cfg(feature = "model-sqlite")]
554async fn sqlite_tx_execute(
555    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
556    sql: &str,
557    params: &[Value],
558) -> Result<u64, DbError> {
559    let mut args = sqlx::sqlite::SqliteArguments::default();
560    bind_sqlite_args(&mut args, params)?;
561    let r = sqlx::query_with(sql, args)
562        .execute(&mut **tx)
563        .await
564        .map_err(|e| DbError::new(e.to_string()))?;
565    Ok(r.rows_affected())
566}
567
568#[cfg(feature = "model-sqlite")]
569async fn sqlite_tx_query_rows(
570    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
571    sql: &str,
572    params: &[Value],
573) -> Result<Vec<ModelRow>, DbError> {
574    let mut args = sqlx::sqlite::SqliteArguments::default();
575    bind_sqlite_args(&mut args, params)?;
576    let rows = sqlx::query_with(sql, args)
577        .fetch_all(&mut **tx)
578        .await
579        .map_err(|e| DbError::new(e.to_string()))?;
580    rows.into_iter().map(sqlite_row_to_model_row).collect()
581}
582
583#[cfg(feature = "model-sqlite")]
584fn bind_sqlite_args(
585    args: &mut sqlx::sqlite::SqliteArguments<'_>,
586    params: &[Value],
587) -> Result<(), DbError> {
588    use sqlx::Arguments;
589    for v in params {
590        match v {
591            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
592            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
593            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
594            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
595            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
596            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
597        }
598    }
599    Ok(())
600}
601
602#[cfg(feature = "model-sqlite")]
603fn sqlite_row_to_model_row(
604    row: sqlx::sqlite::SqliteRow,
605) -> Result<ModelRow, DbError> {
606    use sqlx::{Column, Row, TypeInfo};
607    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
608    for col in row.columns() {
609        let name = col.name().to_string();
610        let type_name = col.type_info().name();
611        let value = if type_name.contains("INT") {
612            row.try_get::<Option<i64>, _>(col.ordinal())
613                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
614                .unwrap_or(Value::Null)
615        } else if type_name.contains("REAL")
616            || type_name.contains("FLOAT")
617            || type_name.contains("DOUBLE")
618            || type_name.contains("NUMERIC")
619            || type_name.contains("DECIMAL")
620        {
621            row.try_get::<Option<f64>, _>(col.ordinal())
622                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
623                .unwrap_or(Value::Null)
624        } else if type_name.contains("BOOL") {
625            row.try_get::<Option<bool>, _>(col.ordinal())
626                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
627                .unwrap_or(Value::Null)
628        } else if type_name.contains("BLOB") {
629            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
630                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
631                .unwrap_or(Value::Null)
632        } else if type_name.is_empty() || type_name == "NULL" {
633            // No declared type (expression, aggregate, function result) — probe by value type.
634            if let Ok(Some(v)) = row.try_get::<Option<i64>, _>(col.ordinal()) {
635                Value::Int(v)
636            } else if let Ok(Some(v)) = row.try_get::<Option<f64>, _>(col.ordinal()) {
637                Value::Float(v)
638            } else if let Ok(Some(v)) = row.try_get::<Option<String>, _>(col.ordinal()) {
639                Value::Text(v)
640            } else if let Ok(Some(v)) = row.try_get::<Option<Vec<u8>>, _>(col.ordinal()) {
641                Value::Bytes(v)
642            } else {
643                Value::Null
644            }
645        } else {
646            // TEXT, VARCHAR, CHAR → string; a declared type sqlx won't read
647            // as one (DATETIME, DATE, …) → whatever the value is stored as.
648            match row.try_get::<Option<String>, _>(col.ordinal()) {
649                Ok(v) => v.map(Value::Text).unwrap_or(Value::Null),
650                Err(_) => sqlite_stored_value(&row, col.ordinal()),
651            }
652        };
653        cols.push((name, value));
654    }
655    Ok(ModelRow::new(cols))
656}
657
658/// A SQLite value by its storage class, whatever the column's declared type.
659#[cfg(feature = "model-sqlite")]
660fn sqlite_stored_value(row: &sqlx::sqlite::SqliteRow, ordinal: usize) -> Value {
661    use sqlx::{Row, TypeInfo, ValueRef};
662    let Ok(raw) = row.try_get_raw(ordinal) else { return Value::Null };
663    if raw.is_null() {
664        return Value::Null;
665    }
666    let class = raw.type_info().name().to_string();
667    match class.as_str() {
668        "INTEGER" => row.try_get_unchecked::<i64, _>(ordinal).map(Value::Int).unwrap_or(Value::Null),
669        "REAL" => row.try_get_unchecked::<f64, _>(ordinal).map(Value::Float).unwrap_or(Value::Null),
670        "BLOB" => row.try_get_unchecked::<Vec<u8>, _>(ordinal).map(Value::Bytes).unwrap_or(Value::Null),
671        _ => row.try_get_unchecked::<String, _>(ordinal).map(Value::Text).unwrap_or(Value::Null),
672    }
673}
674
675/// Insert then return the last inserted row ID from SQLite's
676/// `last_insert_rowid()`. Used by the repository's INSERT logic.
677#[cfg(feature = "model-sqlite")]
678async fn sqlite_last_insert_id(
679    pool: &sqlx::Pool<sqlx::Sqlite>,
680    sql: &str,
681    params: &[Value],
682) -> Result<i64, DbError> {
683    let mut args = sqlx::sqlite::SqliteArguments::default();
684    bind_sqlite_args(&mut args, params)?;
685    let r = sqlx::query_with(sql, args)
686        .execute(pool)
687        .await
688        .map_err(|e| DbError::new(e.to_string()))?;
689    Ok(r.last_insert_rowid())
690}
691
692// ── Internal helpers (PostgreSQL) ─────────────────────────────────────────────
693
694#[cfg(feature = "model-postgres")]
695async fn pg_pool_execute(
696    pool: &sqlx::Pool<sqlx::Postgres>,
697    sql: &str,
698    params: &[Value],
699) -> Result<u64, DbError> {
700    let mut args = sqlx::postgres::PgArguments::default();
701    bind_pg_args(&mut args, params)?;
702    let r = sqlx::query_with(sql, args)
703        .execute(pool)
704        .await
705        .map_err(|e| DbError::new(e.to_string()))?;
706    Ok(r.rows_affected())
707}
708
709#[cfg(feature = "model-postgres")]
710async fn pg_pool_query_rows(
711    pool: &sqlx::Pool<sqlx::Postgres>,
712    sql: &str,
713    params: &[Value],
714) -> Result<Vec<ModelRow>, DbError> {
715    let mut args = sqlx::postgres::PgArguments::default();
716    bind_pg_args(&mut args, params)?;
717    let rows = sqlx::query_with(sql, args)
718        .fetch_all(pool)
719        .await
720        .map_err(|e| DbError::new(e.to_string()))?;
721    rows.into_iter().map(pg_row_to_model_row).collect()
722}
723
724#[cfg(feature = "model-postgres")]
725async fn pg_tx_execute(
726    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
727    sql: &str,
728    params: &[Value],
729) -> Result<u64, DbError> {
730    let mut args = sqlx::postgres::PgArguments::default();
731    bind_pg_args(&mut args, params)?;
732    let r = sqlx::query_with(sql, args)
733        .execute(&mut **tx)
734        .await
735        .map_err(|e| DbError::new(e.to_string()))?;
736    Ok(r.rows_affected())
737}
738
739#[cfg(feature = "model-postgres")]
740async fn pg_tx_query_rows(
741    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
742    sql: &str,
743    params: &[Value],
744) -> Result<Vec<ModelRow>, DbError> {
745    let mut args = sqlx::postgres::PgArguments::default();
746    bind_pg_args(&mut args, params)?;
747    let rows = sqlx::query_with(sql, args)
748        .fetch_all(&mut **tx)
749        .await
750        .map_err(|e| DbError::new(e.to_string()))?;
751    rows.into_iter().map(pg_row_to_model_row).collect()
752}
753
754#[cfg(feature = "model-postgres")]
755fn bind_pg_args(
756    args: &mut sqlx::postgres::PgArguments,
757    params: &[Value],
758) -> Result<(), DbError> {
759    use sqlx::Arguments;
760    for v in params {
761        match v {
762            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
763            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
764            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
765            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
766            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
767            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
768        }
769    }
770    Ok(())
771}
772
773#[cfg(feature = "model-postgres")]
774fn pg_row_to_model_row(row: sqlx::postgres::PgRow) -> Result<ModelRow, DbError> {
775    use sqlx::{Column, Row, TypeInfo};
776    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
777    for col in row.columns() {
778        let name = col.name().to_string();
779        let type_name = col.type_info().name();
780        // sqlx decodes a Rust type only from the PostgreSQL types it maps
781        // to (an `i64` from INT8 alone): each width is read as itself.
782        let value = match type_name {
783            "BOOL" => row.try_get::<Option<bool>, _>(col.ordinal())
784                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
785                .unwrap_or(Value::Null),
786            "INT2" => row.try_get::<Option<i16>, _>(col.ordinal())
787                .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
788                .unwrap_or(Value::Null),
789            "INT4" => row.try_get::<Option<i32>, _>(col.ordinal())
790                .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
791                .unwrap_or(Value::Null),
792            "INT8" => row.try_get::<Option<i64>, _>(col.ordinal())
793                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
794                .unwrap_or(Value::Null),
795            "FLOAT4" => row.try_get::<Option<f32>, _>(col.ordinal())
796                .map(|v| v.map(|f| Value::Float(f as f64)).unwrap_or(Value::Null))
797                .unwrap_or(Value::Null),
798            "FLOAT8" | "NUMERIC" => row.try_get::<Option<f64>, _>(col.ordinal())
799                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
800                .unwrap_or(Value::Null),
801            "TIMESTAMPTZ" | "TIMESTAMP" | "DATE" | "UUID" => {
802                use sqlx::ValueRef;
803                match row.try_get_raw(col.ordinal()) {
804                    Ok(raw) if !raw.is_null() => {
805                        let binary = matches!(raw.format(), sqlx::postgres::PgValueFormat::Binary);
806                        raw.as_bytes().ok().and_then(|bytes| super::decode::pg_value(type_name, binary, bytes)).unwrap_or(Value::Null)
807                    }
808                    _ => Value::Null,
809                }
810            }
811            "BYTEA" => row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
812                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
813                .unwrap_or(Value::Null),
814            _ => row.try_get::<Option<String>, _>(col.ordinal())
815                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
816                .unwrap_or(Value::Null),
817        };
818        cols.push((name, value));
819    }
820    Ok(ModelRow::new(cols))
821}
822
823/// Execute an INSERT and return the RETURNING id (PostgreSQL).
824#[cfg(feature = "model-postgres")]
825async fn pg_insert_returning(
826    pool: &sqlx::Pool<sqlx::Postgres>,
827    sql: &str,
828    params: &[Value],
829    pk_col: &str,
830) -> Result<i64, DbError> {
831    let mut args = sqlx::postgres::PgArguments::default();
832    bind_pg_args(&mut args, params)?;
833    let returning_sql = format!("{} RETURNING {}", sql, pk_col);
834    let row = sqlx::query_with(&returning_sql, args)
835        .fetch_one(pool)
836        .await
837        .map_err(|e| DbError::new(e.to_string()))?;
838    // Through the row conversion, so an INT4 (SERIAL) key reads as well as
839    // an INT8 (BIGSERIAL) one.
840    match pg_row_to_model_row(row)?.columns.into_iter().next() {
841        Some((_, Value::Int(id))) => Ok(id),
842        other => Err(DbError::new(format!("INSERT … RETURNING {pk_col}: expected an integer key, got {other:?}"))),
843    }
844}
845
846// ── Internal helpers (MySQL) ──────────────────────────────────────────────────
847
848#[cfg(feature = "model-mysql")]
849async fn mysql_pool_execute(
850    pool: &sqlx::Pool<sqlx::MySql>,
851    sql: &str,
852    params: &[Value],
853) -> Result<u64, DbError> {
854    let mut args = sqlx::mysql::MySqlArguments::default();
855    bind_mysql_args(&mut args, params)?;
856    let r = sqlx::query_with(sql, args)
857        .execute(pool)
858        .await
859        .map_err(|e| DbError::new(e.to_string()))?;
860    Ok(r.rows_affected())
861}
862
863#[cfg(feature = "model-mysql")]
864async fn mysql_pool_query_rows(
865    pool: &sqlx::Pool<sqlx::MySql>,
866    sql: &str,
867    params: &[Value],
868) -> Result<Vec<ModelRow>, DbError> {
869    let mut args = sqlx::mysql::MySqlArguments::default();
870    bind_mysql_args(&mut args, params)?;
871    let rows = sqlx::query_with(sql, args)
872        .fetch_all(pool)
873        .await
874        .map_err(|e| DbError::new(e.to_string()))?;
875    rows.into_iter().map(mysql_row_to_model_row).collect()
876}
877
878#[cfg(feature = "model-mysql")]
879async fn mysql_tx_execute(
880    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
881    sql: &str,
882    params: &[Value],
883) -> Result<u64, DbError> {
884    let mut args = sqlx::mysql::MySqlArguments::default();
885    bind_mysql_args(&mut args, params)?;
886    let r = sqlx::query_with(sql, args)
887        .execute(&mut **tx)
888        .await
889        .map_err(|e| DbError::new(e.to_string()))?;
890    Ok(r.rows_affected())
891}
892
893#[cfg(feature = "model-mysql")]
894async fn mysql_tx_query_rows(
895    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
896    sql: &str,
897    params: &[Value],
898) -> Result<Vec<ModelRow>, DbError> {
899    let mut args = sqlx::mysql::MySqlArguments::default();
900    bind_mysql_args(&mut args, params)?;
901    let rows = sqlx::query_with(sql, args)
902        .fetch_all(&mut **tx)
903        .await
904        .map_err(|e| DbError::new(e.to_string()))?;
905    rows.into_iter().map(mysql_row_to_model_row).collect()
906}
907
908#[cfg(feature = "model-mysql")]
909fn bind_mysql_args(
910    args: &mut sqlx::mysql::MySqlArguments,
911    params: &[Value],
912) -> Result<(), DbError> {
913    use sqlx::Arguments;
914    for v in params {
915        match v {
916            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
917            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
918            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
919            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
920            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
921            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
922        }
923    }
924    Ok(())
925}
926
927#[cfg(feature = "model-mysql")]
928fn mysql_row_to_model_row(row: sqlx::mysql::MySqlRow) -> Result<ModelRow, DbError> {
929    use sqlx::{Column, Row, TypeInfo};
930    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
931    for col in row.columns() {
932        let name = col.name().to_string();
933        let type_name = col.type_info().name().to_uppercase();
934        let value = if type_name.contains("INT") || type_name.contains("SERIAL") {
935            match row.try_get::<Option<i64>, _>(col.ordinal()) {
936                Ok(v) => v.map(Value::Int).unwrap_or(Value::Null),
937                // UNSIGNED columns decode as u64 only.
938                Err(_) => row
939                    .try_get::<Option<u64>, _>(col.ordinal())
940                    .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
941                    .unwrap_or(Value::Null),
942            }
943        } else if matches!(type_name.as_str(), "DATETIME" | "TIMESTAMP" | "DATE") {
944            // sqlx decodes these only into chrono/time types; read the wire
945            // bytes instead (decoded without its type check).
946            row.try_get_unchecked::<Option<Vec<u8>>, _>(col.ordinal())
947                .ok()
948                .flatten()
949                .and_then(|bytes| super::decode::mysql_temporal(&type_name, &bytes))
950                .unwrap_or(Value::Null)
951        } else if type_name.contains("FLOAT")
952            || type_name.contains("DOUBLE")
953            || type_name.contains("DECIMAL")
954            || type_name.contains("NUMERIC")
955        {
956            row.try_get::<Option<f64>, _>(col.ordinal())
957                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
958                .unwrap_or(Value::Null)
959        } else if type_name.contains("BOOL") {
960            row.try_get::<Option<bool>, _>(col.ordinal())
961                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
962                .unwrap_or(Value::Null)
963        } else if type_name.contains("BLOB") || type_name.contains("BINARY") {
964            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
965                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
966                .unwrap_or(Value::Null)
967        } else {
968            row.try_get::<Option<String>, _>(col.ordinal())
969                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
970                .unwrap_or(Value::Null)
971        };
972        cols.push((name, value));
973    }
974    Ok(ModelRow::new(cols))
975}
976
977/// Insert then return the last inserted row ID from MySQL's `last_insert_id()`.
978/// Used by the repository's INSERT logic.
979#[cfg(feature = "model-mysql")]
980async fn mysql_last_insert_id(
981    pool: &sqlx::Pool<sqlx::MySql>,
982    sql: &str,
983    params: &[Value],
984) -> Result<i64, DbError> {
985    let mut args = sqlx::mysql::MySqlArguments::default();
986    bind_mysql_args(&mut args, params)?;
987    let r = sqlx::query_with(sql, args)
988        .execute(pool)
989        .await
990        .map_err(|e| DbError::new(e.to_string()))?;
991    Ok(r.last_insert_id() as i64)
992}
993
994// ── tracing ───────────────────────────────────────────────────────────────────
995
996/// A client span for one statement, inside a traced request: named after the
997/// operation (`SELECT`, `INSERT`, …) with `db.system.name` and
998/// `db.operation.name` — not the query text, which can carry literals.
999fn db_span(backend: Backend, sql: &str) -> Option<crate::otel::DetachedSpan> {
1000    let operation = sql_operation(sql);
1001    let mut span = crate::otel::detached_span(operation, crate::otel::SpanKind::Client)?;
1002    let system = match backend {
1003        #[cfg(feature = "model-sqlite")]
1004        Backend::Sqlite => "sqlite",
1005        #[cfg(feature = "model-postgres")]
1006        Backend::Postgres => "postgresql",
1007        #[cfg(feature = "model-mysql")]
1008        Backend::MySql => "mysql",
1009    };
1010    span.set_attribute("db.system.name", system);
1011    span.set_attribute("db.operation.name", operation);
1012    Some(span)
1013}
1014
1015#[cfg(all(test, feature = "model-sqlite"))]
1016#[doc(hidden)]
1017pub fn db_span_for_test(sql: &str) -> Option<crate::otel::DetachedSpan> {
1018    db_span(Backend::Sqlite, sql)
1019}
1020
1021fn finish_db_span<T>(span: Option<crate::otel::DetachedSpan>, result: &Result<T, DbError>) {
1022    if let (Some(mut span), Err(e)) = (span, result) {
1023        span.record_error(&e.to_string());
1024    }
1025}
1026
1027/// The statement's first keyword, upper-cased (`"query"` when there's none).
1028#[doc(hidden)]
1029pub fn sql_operation(sql: &str) -> &'static str {
1030    const KNOWN: [&str; 14] = [
1031        "SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE", "ALTER", "DROP", "BEGIN", "COMMIT", "ROLLBACK", "PRAGMA", "REPLACE", "TRUNCATE",
1032    ];
1033    let word = sql.trim_start().split(|c: char| !c.is_ascii_alphabetic()).next().unwrap_or("");
1034    KNOWN.iter().find(|k| k.eq_ignore_ascii_case(word)).copied().unwrap_or("query")
1035}