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    /// A primary and its read replicas (18.30.0): made by
88    /// [`DbPool::with_replicas`] or [`DbPool::from_env`] with
89    /// `RWS_DB_REPLICA_URLS`. Reads ([`query_rows`](DbPool::query_rows),
90    /// [`query`](DbPool::query), the query builder's `fetch_*` / `count`,
91    /// the repository's `find_*` / `count`) go to a replica, round robin;
92    /// writes, transactions, migrations and seeds go to the primary. Code
93    /// that matches on `DbPool`'s variants needs an arm for this one —
94    /// [`DbPool::close`] closes any.
95    Replicated(std::sync::Arc<Replicas>),
96}
97
98/// The pools of a [`DbPool::Replicated`]: one primary, the replicas reads
99/// go to, and the round-robin position (18.30.0).
100pub struct Replicas {
101    primary: DbPool,
102    replicas: Vec<DbPool>,
103    next: std::sync::atomic::AtomicUsize,
104}
105
106impl Replicas {
107    /// The pool writes go to.
108    pub fn primary(&self) -> &DbPool {
109        &self.primary
110    }
111
112    /// The pools reads go to (in the order given).
113    pub fn replicas(&self) -> &[DbPool] {
114        &self.replicas
115    }
116}
117
118impl std::fmt::Debug for Replicas {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.debug_struct("Replicas").field("primary", &self.primary).field("replicas", &self.replicas).finish()
121    }
122}
123
124/// The error for a [`DbPool::Replicated`] found where a backend's pool was
125/// expected — [`DbPool::with_replicas`] never nests them.
126fn nested() -> DbError {
127    DbError::new("a replicated DbPool can't hold another replicated pool")
128}
129
130impl DbPool {
131    /// Create a new pool for `config.backend`, with the given configuration
132    /// and the default TLS ([`DbTls::default`]: TLS when the server offers
133    /// it, certificate not checked).
134    pub async fn new(config: DbConfig) -> Result<Self, DbError> {
135        DbPool::new_with_tls(config, DbTls::default()).await
136    }
137
138    /// As [`DbPool::new`], with `tls` for PostgreSQL and MySQL (SQLite
139    /// ignores it). User and password are passed as they are, not through a
140    /// URL, so any characters work.
141    pub async fn new_with_tls(config: DbConfig, tls: DbTls) -> Result<Self, DbError> {
142        let _ = &tls;
143        // sqlx builds its rustls config with `ClientConfig::builder()`, which
144        // panics when no process-wide crypto provider is installed — so far
145        // only rws's TLS listener installed one, and an app serving plain
146        // HTTP behind a load balancer panicked at its first TLS connection to
147        // the database. Install rws's (aws-lc-rs) unless one is set already.
148        #[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
149        let _ = crate::crypto_provider().install_default();
150        match config.backend {
151            #[cfg(feature = "model-sqlite")]
152            Backend::Sqlite => {
153                let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
154                    .max_connections(config.pool_size)
155                    .connect(&config.to_url())
156                    .await
157                    .map_err(|e| DbError::new(e.to_string()))?;
158                Ok(DbPool::Sqlite(pool))
159            }
160            #[cfg(feature = "model-postgres")]
161            Backend::Postgres => {
162                let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
163                    .max_connections(config.pool_size)
164                    .connect_with(pg_options(&config, &tls))
165                    .await
166                    .map_err(|e| DbError::new(e.to_string()))?;
167                Ok(DbPool::Postgres(pool))
168            }
169            #[cfg(feature = "model-mysql")]
170            Backend::MySql => {
171                let pool = sqlx::pool::PoolOptions::<sqlx::MySql>::new()
172                    .max_connections(config.pool_size)
173                    .connect_with(mysql_options(&config, &tls))
174                    .await
175                    .map_err(|e| DbError::new(e.to_string()))?;
176                Ok(DbPool::MySql(pool))
177            }
178        }
179    }
180
181    /// Create a pool using [`DbConfig::from_env`] and [`DbTls::from_env`].
182    ///
183    /// With `RWS_DB_REPLICA_URLS` set (18.30.0) — comma-separated database
184    /// URLs as [`DbPool::connect`] takes them, each with
185    /// `RWS_DB_POOL_SIZE` connections at most — the pool is
186    /// [replicated](DbPool::with_replicas): reads go to those replicas,
187    /// writes to the database the other `RWS_DB_*` variables name.
188    pub async fn from_env() -> Result<Self, DbError> {
189        let config = DbConfig::from_env()?;
190        let size = config.pool_size;
191        let primary = DbPool::new_with_tls(config, DbTls::from_env()?).await?;
192        let urls = std::env::var("RWS_DB_REPLICA_URLS").unwrap_or_default();
193        let urls: Vec<&str> = urls.split(',').map(str::trim).filter(|u| !u.is_empty()).collect();
194        if urls.is_empty() {
195            return Ok(primary);
196        }
197        let mut replicas = Vec::with_capacity(urls.len());
198        for url in urls {
199            let replica = DbPool::connect_sized(url, size)
200                .await
201                .map_err(|e| DbError::new(format!("RWS_DB_REPLICA_URLS: {}", e.0)))?;
202            replicas.push(replica);
203        }
204        DbPool::with_replicas(primary, replicas)
205    }
206
207    /// A pool that sends reads to `replicas` (round robin) and everything
208    /// else to `primary` (18.30.0). Reads are [`query_rows`](Self::query_rows),
209    /// [`query`](Self::query), [`query_raw`](Self::query_raw) — so
210    /// `QueryBuilder::fetch_all` / `fetch_one` / `count` / `paginate`, the
211    /// repository's `find_by_id` / `find_all` / `count` / `exists_by_id`
212    /// and relation loads. [`execute`](Self::execute),
213    /// [`begin`](Self::begin) / [`transaction`](Self::transaction) (reads
214    /// inside a transaction too), migrations, seeds, [`schema`](Self::schema)
215    /// and `save` (which reads the saved row back) go to the primary.
216    ///
217    /// A replica lags behind its primary: a read right after a write may not
218    /// see it. Read from [`primary`](Self::primary) when it must
219    /// (`User::query(pool.primary())`). With no replicas every statement
220    /// goes to the primary. An error when a replica's backend differs from
221    /// the primary's; a replicated `primary` contributes its primary and
222    /// replicas.
223    ///
224    /// ```rust,no_run
225    /// # extern crate rws_server as rust_web_server;
226    /// # #[cfg(feature = "model-postgres")]
227    /// # async fn example() -> Result<(), rust_web_server::model::DbError> {
228    /// use rust_web_server::model::{DbPool, Value};
229    ///
230    /// let pool = DbPool::with_replicas(
231    ///     DbPool::connect("postgres://app:secret@db-primary/app").await?,
232    ///     vec![DbPool::connect("postgres://app:secret@db-replica-1/app").await?],
233    /// )?;
234    /// pool.execute("UPDATE users SET active = false WHERE id = $1", &[Value::Int(7)]).await?; // the primary
235    /// let rows = pool.query_rows("SELECT * FROM users", &[]).await?; // a replica
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub fn with_replicas(primary: DbPool, replicas: Vec<DbPool>) -> Result<DbPool, DbError> {
240        let (primary, mut all) = match primary {
241            DbPool::Replicated(r) => (r.primary.clone(), r.replicas.clone()),
242            other => (other, Vec::new()),
243        };
244        for replica in replicas {
245            match replica {
246                DbPool::Replicated(r) => all.extend(r.replicas.iter().cloned()),
247                other => all.push(other),
248            }
249        }
250        if let Some(other) = all.iter().find(|r| r.backend() != primary.backend()) {
251            return Err(DbError::new(format!(
252                "a replica is {} but the primary is {}: replicas use the primary's backend",
253                other.backend().name(),
254                primary.backend().name()
255            )));
256        }
257        Ok(DbPool::Replicated(std::sync::Arc::new(Replicas { primary, replicas: all, next: Default::default() })))
258    }
259
260    /// The pool writes go to: the primary of a replicated pool, else this
261    /// pool (18.30.0).
262    pub fn primary(&self) -> &DbPool {
263        match self {
264            DbPool::Replicated(r) => &r.primary,
265            other => other,
266        }
267    }
268
269    /// The pool the next read goes to: a replicated pool's replicas in
270    /// turn (its primary when it has none), else this pool (18.30.0).
271    pub fn replica(&self) -> &DbPool {
272        match self {
273            DbPool::Replicated(r) if !r.replicas.is_empty() => {
274                let i = r.next.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % r.replicas.len();
275                &r.replicas[i]
276            }
277            DbPool::Replicated(r) => &r.primary,
278            other => other,
279        }
280    }
281
282    /// A replicated pool's replicas; empty for any other (18.30.0).
283    pub fn replicas(&self) -> &[DbPool] {
284        match self {
285            DbPool::Replicated(r) => &r.replicas,
286            _ => &[],
287        }
288    }
289
290    /// Close the pool — every pool of a replicated one — waiting for the
291    /// connections in use to be returned (18.30.0).
292    pub async fn close(&self) {
293        let mut pools: Vec<&DbPool> = vec![self.primary()];
294        pools.extend(self.replicas());
295        for pool in pools {
296            match pool {
297                #[cfg(feature = "model-sqlite")]
298                DbPool::Sqlite(p) => p.close().await,
299                #[cfg(feature = "model-postgres")]
300                DbPool::Postgres(p) => p.close().await,
301                #[cfg(feature = "model-mysql")]
302                DbPool::MySql(p) => p.close().await,
303                DbPool::Replicated(_) => {}
304            }
305        }
306    }
307
308    /// Create a pool backed by a SQLite in-memory database.
309    ///
310    /// All connections in the pool share the same in-memory database
311    /// (max_connections = 1).  Each call returns an independent, isolated
312    /// database — ideal for tests.
313    ///
314    /// ```rust,no_run
315    /// # extern crate rws_server as rust_web_server;
316    /// # #[cfg(feature = "model-sqlite")]
317    /// # async fn example() -> Result<(), rust_web_server::model::DbError> {
318    /// use rust_web_server::model::{DbPool, Value};
319    ///
320    /// let pool = DbPool::memory().await?;
321    /// pool.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", &[]).await?;
322    /// pool.execute("INSERT INTO t (v) VALUES (?)", &[Value::Text("hello".into())]).await?;
323    /// let rows = pool.query_rows("SELECT * FROM t", &[]).await?;
324    /// assert_eq!(1, rows.len());
325    /// # Ok(())
326    /// # }
327    /// ```
328    #[cfg(feature = "model-sqlite")]
329    pub async fn memory() -> Result<Self, DbError> {
330        let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
331            .max_connections(1)
332            .connect("sqlite::memory:")
333            .await
334            .map_err(|e| DbError::new(e.to_string()))?;
335        Ok(DbPool::Sqlite(pool))
336    }
337
338    /// Which backend this pool talks to.
339    pub fn backend(&self) -> Backend {
340        match self {
341            #[cfg(feature = "model-sqlite")]
342            DbPool::Sqlite(_) => Backend::Sqlite,
343            #[cfg(feature = "model-postgres")]
344            DbPool::Postgres(_) => Backend::Postgres,
345            #[cfg(feature = "model-mysql")]
346            DbPool::MySql(_) => Backend::MySql,
347            DbPool::Replicated(r) => r.primary.backend(),
348        }
349    }
350
351    // ── Core async SQL operations ─────────────────────────────────────────────
352
353    /// Execute a SQL statement (INSERT / UPDATE / DELETE / DDL).
354    ///
355    /// Returns the number of rows affected.
356    pub async fn execute(&self, sql: &str, params: &[Value]) -> Result<u64, DbError> {
357        let span = db_span(self.backend(), sql);
358        let result = match self.primary() {
359            #[cfg(feature = "model-sqlite")]
360            DbPool::Sqlite(pool) => sqlite_pool_execute(pool, sql, params).await,
361            #[cfg(feature = "model-postgres")]
362            DbPool::Postgres(pool) => pg_pool_execute(pool, sql, params).await,
363            #[cfg(feature = "model-mysql")]
364            DbPool::MySql(pool) => mysql_pool_execute(pool, sql, params).await,
365            DbPool::Replicated(_) => Err(nested()),
366        };
367        finish_db_span(span, &result);
368        result
369    }
370
371    /// Execute a SQL query and return untyped rows — on a
372    /// [replica](Self::with_replicas) when the pool has them.
373    pub async fn query_rows(&self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
374        let span = db_span(self.backend(), sql);
375        let result = match self.replica() {
376            #[cfg(feature = "model-sqlite")]
377            DbPool::Sqlite(pool) => sqlite_pool_query_rows(pool, sql, params).await,
378            #[cfg(feature = "model-postgres")]
379            DbPool::Postgres(pool) => pg_pool_query_rows(pool, sql, params).await,
380            #[cfg(feature = "model-mysql")]
381            DbPool::MySql(pool) => mysql_pool_query_rows(pool, sql, params).await,
382            DbPool::Replicated(_) => Err(nested()),
383        };
384        finish_db_span(span, &result);
385        result
386    }
387
388    /// Execute a SQL query and deserialise results into `T: Model`.
389    pub async fn query<T: Model>(&self, sql: &str, params: &[Value]) -> Result<Vec<T>, DbError> {
390        let rows = self.query_rows(sql, params).await?;
391        rows.iter().map(|r| T::from_row(r)).collect()
392    }
393
394    /// Execute a SQL query returning untyped rows (alias for `query_rows`).
395    pub async fn query_raw(&self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
396        self.query_rows(sql, params).await
397    }
398
399    /// Insert-then-fetch-generated-id, dispatched per backend: SQLite uses
400    /// `last_insert_rowid()`, PostgreSQL appends `RETURNING`, MySQL uses
401    /// `last_insert_id()`. Used by the repository's INSERT logic.
402    #[doc(hidden)]
403    pub async fn insert_returning_id(
404        &self,
405        insert_sql: &str,
406        params: &[Value],
407        _pk_col: &str,
408    ) -> Result<i64, DbError> {
409        let span = db_span(self.backend(), insert_sql);
410        let result = match self.primary() {
411            #[cfg(feature = "model-sqlite")]
412            DbPool::Sqlite(pool) => sqlite_last_insert_id(pool, insert_sql, params).await,
413            #[cfg(feature = "model-postgres")]
414            DbPool::Postgres(pool) => pg_insert_returning(pool, insert_sql, params, _pk_col).await,
415            #[cfg(feature = "model-mysql")]
416            DbPool::MySql(pool) => mysql_last_insert_id(pool, insert_sql, params).await,
417            DbPool::Replicated(_) => Err(nested()),
418        };
419        finish_db_span(span, &result);
420        result
421    }
422
423    // ── Transactions ──────────────────────────────────────────────────────────
424
425    /// Begin a transaction. Call [`DbTransaction::commit`] to commit or let it
426    /// drop to automatically roll back.
427    pub async fn begin(&self) -> Result<DbTransaction, DbError> {
428        match self.primary() {
429            #[cfg(feature = "model-sqlite")]
430            DbPool::Sqlite(pool) => {
431                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
432                Ok(DbTransaction::Sqlite(tx))
433            }
434            #[cfg(feature = "model-postgres")]
435            DbPool::Postgres(pool) => {
436                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
437                Ok(DbTransaction::Postgres(tx))
438            }
439            #[cfg(feature = "model-mysql")]
440            DbPool::MySql(pool) => {
441                let tx = pool.begin().await.map_err(|e| DbError::new(e.to_string()))?;
442                Ok(DbTransaction::MySql(tx))
443            }
444            DbPool::Replicated(_) => Err(nested()),
445        }
446    }
447
448    /// Run a closure in a transaction.
449    ///
450    /// The closure owns the transaction: call `tx.commit()` before returning
451    /// `Ok`. A transaction that isn't committed — the closure returns `Err`,
452    /// or `Ok` without committing — is dropped, which rolls it back.
453    ///
454    /// ```rust,no_run
455    /// # extern crate rws_server as rust_web_server;
456    /// # #[cfg(feature = "model-sqlite")]
457    /// # async fn example() -> Result<(), rust_web_server::model::DbError> {
458    /// use rust_web_server::model::{DbPool, Value};
459    ///
460    /// let pool = DbPool::memory().await?;
461    /// let result = pool.transaction(|mut tx| async move {
462    ///     tx.execute("INSERT INTO t (v) VALUES (?)", &[Value::Text("hi".into())]).await?;
463    ///     tx.commit().await?;
464    ///     Ok(42i32)
465    /// }).await?;
466    /// assert_eq!(42, result);
467    /// # Ok(())
468    /// # }
469    /// ```
470    pub async fn transaction<F, T, Fut>(&self, f: F) -> Result<T, DbError>
471    where
472        F: FnOnce(DbTransaction) -> Fut + Send,
473        Fut: Future<Output = Result<T, DbError>> + Send,
474        T: Send,
475    {
476        let tx = self.begin().await?;
477        match f(tx).await {
478            Ok(v) => Ok(v),
479            Err(e) => Err(e),
480        }
481    }
482
483    // ── Migration ─────────────────────────────────────────────────────────────
484
485    /// Run pending migrations from SQL files in `dir`.
486    ///
487    /// Files are applied in lexicographic order. Already-applied versions are
488    /// tracked in a `_schema_migrations` table (created if absent). Idempotent.
489    pub async fn migrate(&self, dir: &str) -> Result<(), DbError> {
490        migration::run_migrations(self.primary(), dir).await
491    }
492
493    /// Return the status (applied / pending) for each SQL file in `dir`.
494    pub async fn migration_status(&self, dir: &str) -> Result<Vec<MigrationStatus>, DbError> {
495        migration::migration_status(self.primary(), dir).await
496    }
497
498    /// Roll back the most recently applied migration in `dir`.
499    ///
500    /// Requires a companion `<version-stem>.down.sql` file next to the up
501    /// migration it undoes (e.g. `0001_x.sql` → `0001_x.down.sql`); returns
502    /// `Err` if that file is missing. Returns `Ok(None)` if nothing is
503    /// currently applied.
504    pub async fn rollback_last(&self, dir: &str) -> Result<Option<String>, DbError> {
505        migration::rollback_last(self.primary(), dir).await
506    }
507
508    /// Roll back the last `n` applied migrations, most recently applied
509    /// first. Returns the versions actually rolled back — fewer than `n` if
510    /// fewer than `n` migrations were applied. Stops at the first missing
511    /// down file or SQL error; migrations already rolled back in earlier
512    /// iterations stay rolled back.
513    pub async fn rollback(&self, dir: &str, n: usize) -> Result<Vec<String>, DbError> {
514        migration::rollback(self.primary(), dir, n).await
515    }
516
517    /// Run the seed files in `dir` (18.30.0): every `*.sql` file, in
518    /// lexicographic order, each in its own transaction (statements split
519    /// at `;`, as migrations). Unlike migrations nothing records that a
520    /// seed ran — it runs again every time, so write seeds that can (`INSERT
521    /// … ON CONFLICT DO NOTHING` on PostgreSQL and SQLite, `INSERT IGNORE`
522    /// on MySQL). Answers the files run; stops at the first that fails,
523    /// whose statements are rolled back.
524    pub async fn seed(&self, dir: &str) -> Result<Vec<String>, DbError> {
525        migration::run_seeds(self.primary(), dir).await
526    }
527
528    /// A pool for a database URL (18.30.0): `sqlite:<path>` (or
529    /// `sqlite::memory:`), `postgres://user:password@host:port/db` (also
530    /// `postgresql://`), `mysql://user:password@host:port/db` (also
531    /// `mariadb://`) — sqlx's syntax, query parameters included (`sslmode`,
532    /// `ssl-mode`, …). The backend's `model-*` feature must be compiled in.
533    pub async fn connect(url: &str) -> Result<Self, DbError> {
534        DbPool::connect_sized(url, 4).await
535    }
536
537    /// [`DbPool::connect`] with at most `size` connections (SQLite: one).
538    async fn connect_sized(url: &str, size: u32) -> Result<Self, DbError> {
539        let _ = size;
540        let url = url.trim();
541        let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
542        let backend = match scheme.as_str() {
543            "sqlite" => "sqlite",
544            "postgres" | "postgresql" => "postgres",
545            "mysql" | "mariadb" => "mysql",
546            _ => {
547                return Err(DbError::new(format!(
548                    "database URL {:?}: expected sqlite:<path>, postgres://… or mysql://…",
549                    redact_url(url)
550                )))
551            }
552        };
553        let backend = Backend::parse(backend)
554            .map_err(|e| DbError::new(format!("database URL {:?}: {}", redact_url(url), e.0)))?;
555        #[cfg(any(feature = "model-postgres", feature = "model-mysql"))]
556        let _ = crate::crypto_provider().install_default();
557        let bad_url = |e: sqlx::Error| DbError::new(format!("database URL {:?}: {e}", redact_url(url)));
558        match backend {
559            #[cfg(feature = "model-sqlite")]
560            Backend::Sqlite => {
561                let options: sqlx::sqlite::SqliteConnectOptions = url.parse().map_err(bad_url)?;
562                let pool = sqlx::pool::PoolOptions::<sqlx::Sqlite>::new()
563                    .max_connections(1)
564                    .connect_with(options)
565                    .await
566                    .map_err(|e| DbError::new(e.to_string()))?;
567                Ok(DbPool::Sqlite(pool))
568            }
569            #[cfg(feature = "model-postgres")]
570            Backend::Postgres => {
571                let options: sqlx::postgres::PgConnectOptions = url.parse().map_err(bad_url)?;
572                let pool = sqlx::pool::PoolOptions::<sqlx::Postgres>::new()
573                    .max_connections(size.max(1))
574                    .connect_with(options)
575                    .await
576                    .map_err(|e| DbError::new(e.to_string()))?;
577                Ok(DbPool::Postgres(pool))
578            }
579            #[cfg(feature = "model-mysql")]
580            Backend::MySql => {
581                let options: sqlx::mysql::MySqlConnectOptions = url.parse().map_err(bad_url)?;
582                let pool = sqlx::pool::PoolOptions::<sqlx::MySql>::new()
583                    .max_connections(size.max(1))
584                    .connect_with(options)
585                    .await
586                    .map_err(|e| DbError::new(e.to_string()))?;
587                Ok(DbPool::MySql(pool))
588            }
589        }
590    }
591}
592
593/// `url` with the password replaced by `***`, for error messages.
594#[doc(hidden)]
595pub fn redact_url(url: &str) -> String {
596    let Some((scheme, rest)) = url.split_once("://") else { return url.to_string() };
597    let authority_end = rest.find('/').unwrap_or(rest.len());
598    let (authority, tail) = rest.split_at(authority_end);
599    match authority.rsplit_once('@') {
600        Some((userinfo, host)) => match userinfo.split_once(':') {
601            Some((user, _)) => format!("{scheme}://{user}:***@{host}{tail}"),
602            None => url.to_string(),
603        },
604        None => url.to_string(),
605    }
606}
607
608// ── DbTransaction ─────────────────────────────────────────────────────────────
609
610/// An in-progress database transaction.
611///
612/// Created with [`DbPool::begin`].  Calling [`commit`][DbTransaction::commit]
613/// commits the transaction; dropping without committing rolls it back.
614pub enum DbTransaction {
615    #[cfg(feature = "model-sqlite")]
616    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
617    #[cfg(feature = "model-postgres")]
618    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
619    #[cfg(feature = "model-mysql")]
620    MySql(sqlx::Transaction<'static, sqlx::MySql>),
621}
622
623impl DbTransaction {
624    fn backend(&self) -> Backend {
625        match self {
626            #[cfg(feature = "model-sqlite")]
627            DbTransaction::Sqlite(_) => Backend::Sqlite,
628            #[cfg(feature = "model-postgres")]
629            DbTransaction::Postgres(_) => Backend::Postgres,
630            #[cfg(feature = "model-mysql")]
631            DbTransaction::MySql(_) => Backend::MySql,
632        }
633    }
634
635    /// Execute a SQL statement inside this transaction.
636    pub async fn execute(&mut self, sql: &str, params: &[Value]) -> Result<u64, DbError> {
637        let span = db_span(self.backend(), sql);
638        let result = match self {
639            #[cfg(feature = "model-sqlite")]
640            DbTransaction::Sqlite(tx) => sqlite_tx_execute(tx, sql, params).await,
641            #[cfg(feature = "model-postgres")]
642            DbTransaction::Postgres(tx) => pg_tx_execute(tx, sql, params).await,
643            #[cfg(feature = "model-mysql")]
644            DbTransaction::MySql(tx) => mysql_tx_execute(tx, sql, params).await,
645        };
646        finish_db_span(span, &result);
647        result
648    }
649
650    /// Execute a SQL query inside this transaction, returning untyped rows.
651    pub async fn query_rows(&mut self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
652        let span = db_span(self.backend(), sql);
653        let result = match self {
654            #[cfg(feature = "model-sqlite")]
655            DbTransaction::Sqlite(tx) => sqlite_tx_query_rows(tx, sql, params).await,
656            #[cfg(feature = "model-postgres")]
657            DbTransaction::Postgres(tx) => pg_tx_query_rows(tx, sql, params).await,
658            #[cfg(feature = "model-mysql")]
659            DbTransaction::MySql(tx) => mysql_tx_query_rows(tx, sql, params).await,
660        };
661        finish_db_span(span, &result);
662        result
663    }
664
665    /// Execute a SQL query inside this transaction and deserialise into `T: Model`.
666    pub async fn query<T: Model>(&mut self, sql: &str, params: &[Value]) -> Result<Vec<T>, DbError> {
667        let rows = self.query_rows(sql, params).await?;
668        rows.iter().map(|r| T::from_row(r)).collect()
669    }
670
671    /// Commit this transaction.
672    pub async fn commit(self) -> Result<(), DbError> {
673        match self {
674            #[cfg(feature = "model-sqlite")]
675            DbTransaction::Sqlite(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
676            #[cfg(feature = "model-postgres")]
677            DbTransaction::Postgres(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
678            #[cfg(feature = "model-mysql")]
679            DbTransaction::MySql(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
680        }
681    }
682
683    /// Roll back this transaction explicitly (also happens on drop).
684    pub async fn rollback(self) -> Result<(), DbError> {
685        match self {
686            #[cfg(feature = "model-sqlite")]
687            DbTransaction::Sqlite(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
688            #[cfg(feature = "model-postgres")]
689            DbTransaction::Postgres(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
690            #[cfg(feature = "model-mysql")]
691            DbTransaction::MySql(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
692        }
693    }
694}
695
696// ── Internal helpers (SQLite) ─────────────────────────────────────────────────
697
698#[cfg(feature = "model-sqlite")]
699async fn sqlite_pool_execute(
700    pool: &sqlx::Pool<sqlx::Sqlite>,
701    sql: &str,
702    params: &[Value],
703) -> Result<u64, DbError> {
704    let mut args = sqlx::sqlite::SqliteArguments::default();
705    bind_sqlite_args(&mut args, params)?;
706    let r = sqlx::query_with(sql, args)
707        .execute(pool)
708        .await
709        .map_err(|e| DbError::new(e.to_string()))?;
710    Ok(r.rows_affected())
711}
712
713#[cfg(feature = "model-sqlite")]
714async fn sqlite_pool_query_rows(
715    pool: &sqlx::Pool<sqlx::Sqlite>,
716    sql: &str,
717    params: &[Value],
718) -> Result<Vec<ModelRow>, DbError> {
719    let mut args = sqlx::sqlite::SqliteArguments::default();
720    bind_sqlite_args(&mut args, params)?;
721    let rows = sqlx::query_with(sql, args)
722        .fetch_all(pool)
723        .await
724        .map_err(|e| DbError::new(e.to_string()))?;
725    rows.into_iter().map(sqlite_row_to_model_row).collect()
726}
727
728#[cfg(feature = "model-sqlite")]
729async fn sqlite_tx_execute(
730    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
731    sql: &str,
732    params: &[Value],
733) -> Result<u64, DbError> {
734    let mut args = sqlx::sqlite::SqliteArguments::default();
735    bind_sqlite_args(&mut args, params)?;
736    let r = sqlx::query_with(sql, args)
737        .execute(&mut **tx)
738        .await
739        .map_err(|e| DbError::new(e.to_string()))?;
740    Ok(r.rows_affected())
741}
742
743#[cfg(feature = "model-sqlite")]
744async fn sqlite_tx_query_rows(
745    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
746    sql: &str,
747    params: &[Value],
748) -> Result<Vec<ModelRow>, DbError> {
749    let mut args = sqlx::sqlite::SqliteArguments::default();
750    bind_sqlite_args(&mut args, params)?;
751    let rows = sqlx::query_with(sql, args)
752        .fetch_all(&mut **tx)
753        .await
754        .map_err(|e| DbError::new(e.to_string()))?;
755    rows.into_iter().map(sqlite_row_to_model_row).collect()
756}
757
758#[cfg(feature = "model-sqlite")]
759fn bind_sqlite_args(
760    args: &mut sqlx::sqlite::SqliteArguments<'_>,
761    params: &[Value],
762) -> Result<(), DbError> {
763    use sqlx::Arguments;
764    for v in params {
765        match v {
766            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
767            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
768            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
769            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
770            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
771            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
772        }
773    }
774    Ok(())
775}
776
777#[cfg(feature = "model-sqlite")]
778fn sqlite_row_to_model_row(
779    row: sqlx::sqlite::SqliteRow,
780) -> Result<ModelRow, DbError> {
781    use sqlx::{Column, Row, TypeInfo};
782    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
783    for col in row.columns() {
784        let name = col.name().to_string();
785        let type_name = col.type_info().name();
786        let value = if type_name.contains("INT") {
787            row.try_get::<Option<i64>, _>(col.ordinal())
788                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
789                .unwrap_or(Value::Null)
790        } else if type_name.contains("REAL")
791            || type_name.contains("FLOAT")
792            || type_name.contains("DOUBLE")
793            || type_name.contains("NUMERIC")
794            || type_name.contains("DECIMAL")
795        {
796            row.try_get::<Option<f64>, _>(col.ordinal())
797                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
798                .unwrap_or(Value::Null)
799        } else if type_name.contains("BOOL") {
800            row.try_get::<Option<bool>, _>(col.ordinal())
801                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
802                .unwrap_or(Value::Null)
803        } else if type_name.contains("BLOB") {
804            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
805                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
806                .unwrap_or(Value::Null)
807        } else if type_name.is_empty() || type_name == "NULL" {
808            // No declared type (expression, aggregate, function result) — probe by value type.
809            if let Ok(Some(v)) = row.try_get::<Option<i64>, _>(col.ordinal()) {
810                Value::Int(v)
811            } else if let Ok(Some(v)) = row.try_get::<Option<f64>, _>(col.ordinal()) {
812                Value::Float(v)
813            } else if let Ok(Some(v)) = row.try_get::<Option<String>, _>(col.ordinal()) {
814                Value::Text(v)
815            } else if let Ok(Some(v)) = row.try_get::<Option<Vec<u8>>, _>(col.ordinal()) {
816                Value::Bytes(v)
817            } else {
818                Value::Null
819            }
820        } else {
821            // TEXT, VARCHAR, CHAR → string; a declared type sqlx won't read
822            // as one (DATETIME, DATE, …) → whatever the value is stored as.
823            match row.try_get::<Option<String>, _>(col.ordinal()) {
824                Ok(v) => v.map(Value::Text).unwrap_or(Value::Null),
825                Err(_) => sqlite_stored_value(&row, col.ordinal()),
826            }
827        };
828        cols.push((name, value));
829    }
830    Ok(ModelRow::new(cols))
831}
832
833/// A SQLite value by its storage class, whatever the column's declared type.
834#[cfg(feature = "model-sqlite")]
835fn sqlite_stored_value(row: &sqlx::sqlite::SqliteRow, ordinal: usize) -> Value {
836    use sqlx::{Row, TypeInfo, ValueRef};
837    let Ok(raw) = row.try_get_raw(ordinal) else { return Value::Null };
838    if raw.is_null() {
839        return Value::Null;
840    }
841    let class = raw.type_info().name().to_string();
842    match class.as_str() {
843        "INTEGER" => row.try_get_unchecked::<i64, _>(ordinal).map(Value::Int).unwrap_or(Value::Null),
844        "REAL" => row.try_get_unchecked::<f64, _>(ordinal).map(Value::Float).unwrap_or(Value::Null),
845        "BLOB" => row.try_get_unchecked::<Vec<u8>, _>(ordinal).map(Value::Bytes).unwrap_or(Value::Null),
846        _ => row.try_get_unchecked::<String, _>(ordinal).map(Value::Text).unwrap_or(Value::Null),
847    }
848}
849
850/// Insert then return the last inserted row ID from SQLite's
851/// `last_insert_rowid()`. Used by the repository's INSERT logic.
852#[cfg(feature = "model-sqlite")]
853async fn sqlite_last_insert_id(
854    pool: &sqlx::Pool<sqlx::Sqlite>,
855    sql: &str,
856    params: &[Value],
857) -> Result<i64, DbError> {
858    let mut args = sqlx::sqlite::SqliteArguments::default();
859    bind_sqlite_args(&mut args, params)?;
860    let r = sqlx::query_with(sql, args)
861        .execute(pool)
862        .await
863        .map_err(|e| DbError::new(e.to_string()))?;
864    Ok(r.last_insert_rowid())
865}
866
867// ── Internal helpers (PostgreSQL) ─────────────────────────────────────────────
868
869#[cfg(feature = "model-postgres")]
870async fn pg_pool_execute(
871    pool: &sqlx::Pool<sqlx::Postgres>,
872    sql: &str,
873    params: &[Value],
874) -> Result<u64, DbError> {
875    let mut args = sqlx::postgres::PgArguments::default();
876    bind_pg_args(&mut args, params)?;
877    let r = sqlx::query_with(sql, args)
878        .execute(pool)
879        .await
880        .map_err(|e| DbError::new(e.to_string()))?;
881    Ok(r.rows_affected())
882}
883
884#[cfg(feature = "model-postgres")]
885async fn pg_pool_query_rows(
886    pool: &sqlx::Pool<sqlx::Postgres>,
887    sql: &str,
888    params: &[Value],
889) -> Result<Vec<ModelRow>, DbError> {
890    let mut args = sqlx::postgres::PgArguments::default();
891    bind_pg_args(&mut args, params)?;
892    let rows = sqlx::query_with(sql, args)
893        .fetch_all(pool)
894        .await
895        .map_err(|e| DbError::new(e.to_string()))?;
896    rows.into_iter().map(pg_row_to_model_row).collect()
897}
898
899#[cfg(feature = "model-postgres")]
900async fn pg_tx_execute(
901    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
902    sql: &str,
903    params: &[Value],
904) -> Result<u64, DbError> {
905    let mut args = sqlx::postgres::PgArguments::default();
906    bind_pg_args(&mut args, params)?;
907    let r = sqlx::query_with(sql, args)
908        .execute(&mut **tx)
909        .await
910        .map_err(|e| DbError::new(e.to_string()))?;
911    Ok(r.rows_affected())
912}
913
914#[cfg(feature = "model-postgres")]
915async fn pg_tx_query_rows(
916    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
917    sql: &str,
918    params: &[Value],
919) -> Result<Vec<ModelRow>, DbError> {
920    let mut args = sqlx::postgres::PgArguments::default();
921    bind_pg_args(&mut args, params)?;
922    let rows = sqlx::query_with(sql, args)
923        .fetch_all(&mut **tx)
924        .await
925        .map_err(|e| DbError::new(e.to_string()))?;
926    rows.into_iter().map(pg_row_to_model_row).collect()
927}
928
929#[cfg(feature = "model-postgres")]
930fn bind_pg_args(
931    args: &mut sqlx::postgres::PgArguments,
932    params: &[Value],
933) -> Result<(), DbError> {
934    use sqlx::Arguments;
935    for v in params {
936        match v {
937            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
938            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
939            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
940            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
941            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
942            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
943        }
944    }
945    Ok(())
946}
947
948#[cfg(feature = "model-postgres")]
949fn pg_row_to_model_row(row: sqlx::postgres::PgRow) -> Result<ModelRow, DbError> {
950    use sqlx::{Column, Row, TypeInfo};
951    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
952    for col in row.columns() {
953        let name = col.name().to_string();
954        let type_name = col.type_info().name();
955        // sqlx decodes a Rust type only from the PostgreSQL types it maps
956        // to (an `i64` from INT8 alone): each width is read as itself.
957        let value = match type_name {
958            "BOOL" => row.try_get::<Option<bool>, _>(col.ordinal())
959                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
960                .unwrap_or(Value::Null),
961            "INT2" => row.try_get::<Option<i16>, _>(col.ordinal())
962                .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
963                .unwrap_or(Value::Null),
964            "INT4" => row.try_get::<Option<i32>, _>(col.ordinal())
965                .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
966                .unwrap_or(Value::Null),
967            "INT8" => row.try_get::<Option<i64>, _>(col.ordinal())
968                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
969                .unwrap_or(Value::Null),
970            "FLOAT4" => row.try_get::<Option<f32>, _>(col.ordinal())
971                .map(|v| v.map(|f| Value::Float(f as f64)).unwrap_or(Value::Null))
972                .unwrap_or(Value::Null),
973            "FLOAT8" | "NUMERIC" => row.try_get::<Option<f64>, _>(col.ordinal())
974                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
975                .unwrap_or(Value::Null),
976            "TIMESTAMPTZ" | "TIMESTAMP" | "DATE" | "UUID" => {
977                use sqlx::ValueRef;
978                match row.try_get_raw(col.ordinal()) {
979                    Ok(raw) if !raw.is_null() => {
980                        let binary = matches!(raw.format(), sqlx::postgres::PgValueFormat::Binary);
981                        raw.as_bytes().ok().and_then(|bytes| super::decode::pg_value(type_name, binary, bytes)).unwrap_or(Value::Null)
982                    }
983                    _ => Value::Null,
984                }
985            }
986            "BYTEA" => row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
987                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
988                .unwrap_or(Value::Null),
989            _ => row.try_get::<Option<String>, _>(col.ordinal())
990                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
991                .unwrap_or(Value::Null),
992        };
993        cols.push((name, value));
994    }
995    Ok(ModelRow::new(cols))
996}
997
998/// Execute an INSERT and return the RETURNING id (PostgreSQL).
999#[cfg(feature = "model-postgres")]
1000async fn pg_insert_returning(
1001    pool: &sqlx::Pool<sqlx::Postgres>,
1002    sql: &str,
1003    params: &[Value],
1004    pk_col: &str,
1005) -> Result<i64, DbError> {
1006    let mut args = sqlx::postgres::PgArguments::default();
1007    bind_pg_args(&mut args, params)?;
1008    let returning_sql = format!("{} RETURNING {}", sql, pk_col);
1009    let row = sqlx::query_with(&returning_sql, args)
1010        .fetch_one(pool)
1011        .await
1012        .map_err(|e| DbError::new(e.to_string()))?;
1013    // Through the row conversion, so an INT4 (SERIAL) key reads as well as
1014    // an INT8 (BIGSERIAL) one.
1015    match pg_row_to_model_row(row)?.columns.into_iter().next() {
1016        Some((_, Value::Int(id))) => Ok(id),
1017        other => Err(DbError::new(format!("INSERT … RETURNING {pk_col}: expected an integer key, got {other:?}"))),
1018    }
1019}
1020
1021// ── Internal helpers (MySQL) ──────────────────────────────────────────────────
1022
1023#[cfg(feature = "model-mysql")]
1024async fn mysql_pool_execute(
1025    pool: &sqlx::Pool<sqlx::MySql>,
1026    sql: &str,
1027    params: &[Value],
1028) -> Result<u64, DbError> {
1029    let mut args = sqlx::mysql::MySqlArguments::default();
1030    bind_mysql_args(&mut args, params)?;
1031    let r = sqlx::query_with(sql, args)
1032        .execute(pool)
1033        .await
1034        .map_err(|e| DbError::new(e.to_string()))?;
1035    Ok(r.rows_affected())
1036}
1037
1038#[cfg(feature = "model-mysql")]
1039async fn mysql_pool_query_rows(
1040    pool: &sqlx::Pool<sqlx::MySql>,
1041    sql: &str,
1042    params: &[Value],
1043) -> Result<Vec<ModelRow>, DbError> {
1044    let mut args = sqlx::mysql::MySqlArguments::default();
1045    bind_mysql_args(&mut args, params)?;
1046    let rows = sqlx::query_with(sql, args)
1047        .fetch_all(pool)
1048        .await
1049        .map_err(|e| DbError::new(e.to_string()))?;
1050    rows.into_iter().map(mysql_row_to_model_row).collect()
1051}
1052
1053#[cfg(feature = "model-mysql")]
1054async fn mysql_tx_execute(
1055    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
1056    sql: &str,
1057    params: &[Value],
1058) -> Result<u64, DbError> {
1059    let mut args = sqlx::mysql::MySqlArguments::default();
1060    bind_mysql_args(&mut args, params)?;
1061    let r = sqlx::query_with(sql, args)
1062        .execute(&mut **tx)
1063        .await
1064        .map_err(|e| DbError::new(e.to_string()))?;
1065    Ok(r.rows_affected())
1066}
1067
1068#[cfg(feature = "model-mysql")]
1069async fn mysql_tx_query_rows(
1070    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
1071    sql: &str,
1072    params: &[Value],
1073) -> Result<Vec<ModelRow>, DbError> {
1074    let mut args = sqlx::mysql::MySqlArguments::default();
1075    bind_mysql_args(&mut args, params)?;
1076    let rows = sqlx::query_with(sql, args)
1077        .fetch_all(&mut **tx)
1078        .await
1079        .map_err(|e| DbError::new(e.to_string()))?;
1080    rows.into_iter().map(mysql_row_to_model_row).collect()
1081}
1082
1083#[cfg(feature = "model-mysql")]
1084fn bind_mysql_args(
1085    args: &mut sqlx::mysql::MySqlArguments,
1086    params: &[Value],
1087) -> Result<(), DbError> {
1088    use sqlx::Arguments;
1089    for v in params {
1090        match v {
1091            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
1092            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
1093            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
1094            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
1095            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
1096            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
1097        }
1098    }
1099    Ok(())
1100}
1101
1102#[cfg(feature = "model-mysql")]
1103fn mysql_row_to_model_row(row: sqlx::mysql::MySqlRow) -> Result<ModelRow, DbError> {
1104    use sqlx::{Column, Row, TypeInfo};
1105    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
1106    for col in row.columns() {
1107        let name = col.name().to_string();
1108        let type_name = col.type_info().name().to_uppercase();
1109        let value = if type_name.contains("INT") || type_name.contains("SERIAL") {
1110            match row.try_get::<Option<i64>, _>(col.ordinal()) {
1111                Ok(v) => v.map(Value::Int).unwrap_or(Value::Null),
1112                // UNSIGNED columns decode as u64 only.
1113                Err(_) => row
1114                    .try_get::<Option<u64>, _>(col.ordinal())
1115                    .map(|v| v.map(|n| Value::Int(n as i64)).unwrap_or(Value::Null))
1116                    .unwrap_or(Value::Null),
1117            }
1118        } else if matches!(type_name.as_str(), "DATETIME" | "TIMESTAMP" | "DATE") {
1119            // sqlx decodes these only into chrono/time types; read the wire
1120            // bytes instead (decoded without its type check).
1121            row.try_get_unchecked::<Option<Vec<u8>>, _>(col.ordinal())
1122                .ok()
1123                .flatten()
1124                .and_then(|bytes| super::decode::mysql_temporal(&type_name, &bytes))
1125                .unwrap_or(Value::Null)
1126        } else if type_name.contains("FLOAT")
1127            || type_name.contains("DOUBLE")
1128            || type_name.contains("DECIMAL")
1129            || type_name.contains("NUMERIC")
1130        {
1131            row.try_get::<Option<f64>, _>(col.ordinal())
1132                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
1133                .unwrap_or(Value::Null)
1134        } else if type_name.contains("BOOL") {
1135            row.try_get::<Option<bool>, _>(col.ordinal())
1136                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
1137                .unwrap_or(Value::Null)
1138        } else if type_name.contains("BLOB") || type_name.contains("BINARY") {
1139            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
1140                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
1141                .unwrap_or(Value::Null)
1142        } else {
1143            row.try_get::<Option<String>, _>(col.ordinal())
1144                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
1145                .unwrap_or(Value::Null)
1146        };
1147        cols.push((name, value));
1148    }
1149    Ok(ModelRow::new(cols))
1150}
1151
1152/// Insert then return the last inserted row ID from MySQL's `last_insert_id()`.
1153/// Used by the repository's INSERT logic.
1154#[cfg(feature = "model-mysql")]
1155async fn mysql_last_insert_id(
1156    pool: &sqlx::Pool<sqlx::MySql>,
1157    sql: &str,
1158    params: &[Value],
1159) -> Result<i64, DbError> {
1160    let mut args = sqlx::mysql::MySqlArguments::default();
1161    bind_mysql_args(&mut args, params)?;
1162    let r = sqlx::query_with(sql, args)
1163        .execute(pool)
1164        .await
1165        .map_err(|e| DbError::new(e.to_string()))?;
1166    Ok(r.last_insert_id() as i64)
1167}
1168
1169// ── tracing ───────────────────────────────────────────────────────────────────
1170
1171/// A client span for one statement, inside a traced request: named after the
1172/// operation (`SELECT`, `INSERT`, …) with `db.system.name` and
1173/// `db.operation.name` — not the query text, which can carry literals.
1174fn db_span(backend: Backend, sql: &str) -> Option<crate::otel::DetachedSpan> {
1175    let operation = sql_operation(sql);
1176    let mut span = crate::otel::detached_span(operation, crate::otel::SpanKind::Client)?;
1177    let system = match backend {
1178        #[cfg(feature = "model-sqlite")]
1179        Backend::Sqlite => "sqlite",
1180        #[cfg(feature = "model-postgres")]
1181        Backend::Postgres => "postgresql",
1182        #[cfg(feature = "model-mysql")]
1183        Backend::MySql => "mysql",
1184    };
1185    span.set_attribute("db.system.name", system);
1186    span.set_attribute("db.operation.name", operation);
1187    Some(span)
1188}
1189
1190#[cfg(all(test, feature = "model-sqlite"))]
1191#[doc(hidden)]
1192pub fn db_span_for_test(sql: &str) -> Option<crate::otel::DetachedSpan> {
1193    db_span(Backend::Sqlite, sql)
1194}
1195
1196fn finish_db_span<T>(span: Option<crate::otel::DetachedSpan>, result: &Result<T, DbError>) {
1197    if let (Some(mut span), Err(e)) = (span, result) {
1198        span.record_error(&e.to_string());
1199    }
1200}
1201
1202/// The statement's first keyword, upper-cased (`"query"` when there's none).
1203#[doc(hidden)]
1204pub fn sql_operation(sql: &str) -> &'static str {
1205    const KNOWN: [&str; 14] = [
1206        "SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE", "ALTER", "DROP", "BEGIN", "COMMIT", "ROLLBACK", "PRAGMA", "REPLACE", "TRUNCATE",
1207    ];
1208    let word = sql.trim_start().split(|c: char| !c.is_ascii_alphabetic()).next().unwrap_or("");
1209    KNOWN.iter().find(|k| k.eq_ignore_ascii_case(word)).copied().unwrap_or("query")
1210}