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
349// ── DbTransaction ─────────────────────────────────────────────────────────────
350
351/// An in-progress database transaction.
352///
353/// Created with [`DbPool::begin`].  Calling [`commit`][DbTransaction::commit]
354/// commits the transaction; dropping without committing rolls it back.
355pub enum DbTransaction {
356    #[cfg(feature = "model-sqlite")]
357    Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>),
358    #[cfg(feature = "model-postgres")]
359    Postgres(sqlx::Transaction<'static, sqlx::Postgres>),
360    #[cfg(feature = "model-mysql")]
361    MySql(sqlx::Transaction<'static, sqlx::MySql>),
362}
363
364impl DbTransaction {
365    fn backend(&self) -> Backend {
366        match self {
367            #[cfg(feature = "model-sqlite")]
368            DbTransaction::Sqlite(_) => Backend::Sqlite,
369            #[cfg(feature = "model-postgres")]
370            DbTransaction::Postgres(_) => Backend::Postgres,
371            #[cfg(feature = "model-mysql")]
372            DbTransaction::MySql(_) => Backend::MySql,
373        }
374    }
375
376    /// Execute a SQL statement inside this transaction.
377    pub async fn execute(&mut self, sql: &str, params: &[Value]) -> Result<u64, DbError> {
378        let span = db_span(self.backend(), sql);
379        let result = match self {
380            #[cfg(feature = "model-sqlite")]
381            DbTransaction::Sqlite(tx) => sqlite_tx_execute(tx, sql, params).await,
382            #[cfg(feature = "model-postgres")]
383            DbTransaction::Postgres(tx) => pg_tx_execute(tx, sql, params).await,
384            #[cfg(feature = "model-mysql")]
385            DbTransaction::MySql(tx) => mysql_tx_execute(tx, sql, params).await,
386        };
387        finish_db_span(span, &result);
388        result
389    }
390
391    /// Execute a SQL query inside this transaction, returning untyped rows.
392    pub async fn query_rows(&mut self, sql: &str, params: &[Value]) -> Result<Vec<ModelRow>, DbError> {
393        let span = db_span(self.backend(), sql);
394        let result = match self {
395            #[cfg(feature = "model-sqlite")]
396            DbTransaction::Sqlite(tx) => sqlite_tx_query_rows(tx, sql, params).await,
397            #[cfg(feature = "model-postgres")]
398            DbTransaction::Postgres(tx) => pg_tx_query_rows(tx, sql, params).await,
399            #[cfg(feature = "model-mysql")]
400            DbTransaction::MySql(tx) => mysql_tx_query_rows(tx, sql, params).await,
401        };
402        finish_db_span(span, &result);
403        result
404    }
405
406    /// Execute a SQL query inside this transaction and deserialise into `T: Model`.
407    pub async fn query<T: Model>(&mut self, sql: &str, params: &[Value]) -> Result<Vec<T>, DbError> {
408        let rows = self.query_rows(sql, params).await?;
409        rows.iter().map(|r| T::from_row(r)).collect()
410    }
411
412    /// Commit this transaction.
413    pub async fn commit(self) -> Result<(), DbError> {
414        match self {
415            #[cfg(feature = "model-sqlite")]
416            DbTransaction::Sqlite(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
417            #[cfg(feature = "model-postgres")]
418            DbTransaction::Postgres(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
419            #[cfg(feature = "model-mysql")]
420            DbTransaction::MySql(tx) => tx.commit().await.map_err(|e| DbError::new(e.to_string())),
421        }
422    }
423
424    /// Roll back this transaction explicitly (also happens on drop).
425    pub async fn rollback(self) -> Result<(), DbError> {
426        match self {
427            #[cfg(feature = "model-sqlite")]
428            DbTransaction::Sqlite(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
429            #[cfg(feature = "model-postgres")]
430            DbTransaction::Postgres(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
431            #[cfg(feature = "model-mysql")]
432            DbTransaction::MySql(tx) => tx.rollback().await.map_err(|e| DbError::new(e.to_string())),
433        }
434    }
435}
436
437// ── Internal helpers (SQLite) ─────────────────────────────────────────────────
438
439#[cfg(feature = "model-sqlite")]
440async fn sqlite_pool_execute(
441    pool: &sqlx::Pool<sqlx::Sqlite>,
442    sql: &str,
443    params: &[Value],
444) -> Result<u64, DbError> {
445    let mut args = sqlx::sqlite::SqliteArguments::default();
446    bind_sqlite_args(&mut args, params)?;
447    let r = sqlx::query_with(sql, args)
448        .execute(pool)
449        .await
450        .map_err(|e| DbError::new(e.to_string()))?;
451    Ok(r.rows_affected())
452}
453
454#[cfg(feature = "model-sqlite")]
455async fn sqlite_pool_query_rows(
456    pool: &sqlx::Pool<sqlx::Sqlite>,
457    sql: &str,
458    params: &[Value],
459) -> Result<Vec<ModelRow>, DbError> {
460    let mut args = sqlx::sqlite::SqliteArguments::default();
461    bind_sqlite_args(&mut args, params)?;
462    let rows = sqlx::query_with(sql, args)
463        .fetch_all(pool)
464        .await
465        .map_err(|e| DbError::new(e.to_string()))?;
466    rows.into_iter().map(sqlite_row_to_model_row).collect()
467}
468
469#[cfg(feature = "model-sqlite")]
470async fn sqlite_tx_execute(
471    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
472    sql: &str,
473    params: &[Value],
474) -> Result<u64, DbError> {
475    let mut args = sqlx::sqlite::SqliteArguments::default();
476    bind_sqlite_args(&mut args, params)?;
477    let r = sqlx::query_with(sql, args)
478        .execute(&mut **tx)
479        .await
480        .map_err(|e| DbError::new(e.to_string()))?;
481    Ok(r.rows_affected())
482}
483
484#[cfg(feature = "model-sqlite")]
485async fn sqlite_tx_query_rows(
486    tx: &mut sqlx::Transaction<'static, sqlx::Sqlite>,
487    sql: &str,
488    params: &[Value],
489) -> Result<Vec<ModelRow>, DbError> {
490    let mut args = sqlx::sqlite::SqliteArguments::default();
491    bind_sqlite_args(&mut args, params)?;
492    let rows = sqlx::query_with(sql, args)
493        .fetch_all(&mut **tx)
494        .await
495        .map_err(|e| DbError::new(e.to_string()))?;
496    rows.into_iter().map(sqlite_row_to_model_row).collect()
497}
498
499#[cfg(feature = "model-sqlite")]
500fn bind_sqlite_args(
501    args: &mut sqlx::sqlite::SqliteArguments<'_>,
502    params: &[Value],
503) -> Result<(), DbError> {
504    use sqlx::Arguments;
505    for v in params {
506        match v {
507            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
508            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
509            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
510            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
511            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
512            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
513        }
514    }
515    Ok(())
516}
517
518#[cfg(feature = "model-sqlite")]
519fn sqlite_row_to_model_row(
520    row: sqlx::sqlite::SqliteRow,
521) -> Result<ModelRow, DbError> {
522    use sqlx::{Column, Row, TypeInfo};
523    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
524    for col in row.columns() {
525        let name = col.name().to_string();
526        let type_name = col.type_info().name();
527        let value = if type_name.contains("INT") {
528            row.try_get::<Option<i64>, _>(col.ordinal())
529                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
530                .unwrap_or(Value::Null)
531        } else if type_name.contains("REAL")
532            || type_name.contains("FLOAT")
533            || type_name.contains("DOUBLE")
534            || type_name.contains("NUMERIC")
535            || type_name.contains("DECIMAL")
536        {
537            row.try_get::<Option<f64>, _>(col.ordinal())
538                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
539                .unwrap_or(Value::Null)
540        } else if type_name.contains("BOOL") {
541            row.try_get::<Option<bool>, _>(col.ordinal())
542                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
543                .unwrap_or(Value::Null)
544        } else if type_name.contains("BLOB") {
545            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
546                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
547                .unwrap_or(Value::Null)
548        } else if type_name.is_empty() || type_name == "NULL" {
549            // No declared type (expression, aggregate, function result) — probe by value type.
550            if let Ok(Some(v)) = row.try_get::<Option<i64>, _>(col.ordinal()) {
551                Value::Int(v)
552            } else if let Ok(Some(v)) = row.try_get::<Option<f64>, _>(col.ordinal()) {
553                Value::Float(v)
554            } else if let Ok(Some(v)) = row.try_get::<Option<String>, _>(col.ordinal()) {
555                Value::Text(v)
556            } else if let Ok(Some(v)) = row.try_get::<Option<Vec<u8>>, _>(col.ordinal()) {
557                Value::Bytes(v)
558            } else {
559                Value::Null
560            }
561        } else {
562            // TEXT, VARCHAR, CHAR, and anything else → try string
563            row.try_get::<Option<String>, _>(col.ordinal())
564                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
565                .unwrap_or(Value::Null)
566        };
567        cols.push((name, value));
568    }
569    Ok(ModelRow::new(cols))
570}
571
572/// Insert then return the last inserted row ID from SQLite's
573/// `last_insert_rowid()`. Used by the repository's INSERT logic.
574#[cfg(feature = "model-sqlite")]
575async fn sqlite_last_insert_id(
576    pool: &sqlx::Pool<sqlx::Sqlite>,
577    sql: &str,
578    params: &[Value],
579) -> Result<i64, DbError> {
580    let mut args = sqlx::sqlite::SqliteArguments::default();
581    bind_sqlite_args(&mut args, params)?;
582    let r = sqlx::query_with(sql, args)
583        .execute(pool)
584        .await
585        .map_err(|e| DbError::new(e.to_string()))?;
586    Ok(r.last_insert_rowid())
587}
588
589// ── Internal helpers (PostgreSQL) ─────────────────────────────────────────────
590
591#[cfg(feature = "model-postgres")]
592async fn pg_pool_execute(
593    pool: &sqlx::Pool<sqlx::Postgres>,
594    sql: &str,
595    params: &[Value],
596) -> Result<u64, DbError> {
597    let mut args = sqlx::postgres::PgArguments::default();
598    bind_pg_args(&mut args, params)?;
599    let r = sqlx::query_with(sql, args)
600        .execute(pool)
601        .await
602        .map_err(|e| DbError::new(e.to_string()))?;
603    Ok(r.rows_affected())
604}
605
606#[cfg(feature = "model-postgres")]
607async fn pg_pool_query_rows(
608    pool: &sqlx::Pool<sqlx::Postgres>,
609    sql: &str,
610    params: &[Value],
611) -> Result<Vec<ModelRow>, DbError> {
612    let mut args = sqlx::postgres::PgArguments::default();
613    bind_pg_args(&mut args, params)?;
614    let rows = sqlx::query_with(sql, args)
615        .fetch_all(pool)
616        .await
617        .map_err(|e| DbError::new(e.to_string()))?;
618    rows.into_iter().map(pg_row_to_model_row).collect()
619}
620
621#[cfg(feature = "model-postgres")]
622async fn pg_tx_execute(
623    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
624    sql: &str,
625    params: &[Value],
626) -> Result<u64, DbError> {
627    let mut args = sqlx::postgres::PgArguments::default();
628    bind_pg_args(&mut args, params)?;
629    let r = sqlx::query_with(sql, args)
630        .execute(&mut **tx)
631        .await
632        .map_err(|e| DbError::new(e.to_string()))?;
633    Ok(r.rows_affected())
634}
635
636#[cfg(feature = "model-postgres")]
637async fn pg_tx_query_rows(
638    tx: &mut sqlx::Transaction<'static, sqlx::Postgres>,
639    sql: &str,
640    params: &[Value],
641) -> Result<Vec<ModelRow>, DbError> {
642    let mut args = sqlx::postgres::PgArguments::default();
643    bind_pg_args(&mut args, params)?;
644    let rows = sqlx::query_with(sql, args)
645        .fetch_all(&mut **tx)
646        .await
647        .map_err(|e| DbError::new(e.to_string()))?;
648    rows.into_iter().map(pg_row_to_model_row).collect()
649}
650
651#[cfg(feature = "model-postgres")]
652fn bind_pg_args(
653    args: &mut sqlx::postgres::PgArguments,
654    params: &[Value],
655) -> Result<(), DbError> {
656    use sqlx::Arguments;
657    for v in params {
658        match v {
659            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
660            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
661            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
662            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
663            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
664            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
665        }
666    }
667    Ok(())
668}
669
670#[cfg(feature = "model-postgres")]
671fn pg_row_to_model_row(row: sqlx::postgres::PgRow) -> Result<ModelRow, DbError> {
672    use sqlx::{Column, Row, TypeInfo};
673    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
674    for col in row.columns() {
675        let name = col.name().to_string();
676        let type_name = col.type_info().name();
677        let value = match type_name {
678            "BOOL" => row.try_get::<Option<bool>, _>(col.ordinal())
679                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
680                .unwrap_or(Value::Null),
681            "INT2" | "INT4" | "INT8" => row.try_get::<Option<i64>, _>(col.ordinal())
682                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
683                .unwrap_or(Value::Null),
684            "FLOAT4" | "FLOAT8" | "NUMERIC" => row.try_get::<Option<f64>, _>(col.ordinal())
685                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
686                .unwrap_or(Value::Null),
687            "BYTEA" => row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
688                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
689                .unwrap_or(Value::Null),
690            _ => row.try_get::<Option<String>, _>(col.ordinal())
691                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
692                .unwrap_or(Value::Null),
693        };
694        cols.push((name, value));
695    }
696    Ok(ModelRow::new(cols))
697}
698
699/// Execute an INSERT and return the RETURNING id (PostgreSQL).
700#[cfg(feature = "model-postgres")]
701async fn pg_insert_returning(
702    pool: &sqlx::Pool<sqlx::Postgres>,
703    sql: &str,
704    params: &[Value],
705    pk_col: &str,
706) -> Result<i64, DbError> {
707    let mut args = sqlx::postgres::PgArguments::default();
708    bind_pg_args(&mut args, params)?;
709    let returning_sql = format!("{} RETURNING {}", sql, pk_col);
710    let row = sqlx::query_with(&returning_sql, args)
711        .fetch_one(pool)
712        .await
713        .map_err(|e| DbError::new(e.to_string()))?;
714    use sqlx::Row;
715    let id: i64 = row.try_get(pk_col).map_err(|e| DbError::new(e.to_string()))?;
716    Ok(id)
717}
718
719// ── Internal helpers (MySQL) ──────────────────────────────────────────────────
720
721#[cfg(feature = "model-mysql")]
722async fn mysql_pool_execute(
723    pool: &sqlx::Pool<sqlx::MySql>,
724    sql: &str,
725    params: &[Value],
726) -> Result<u64, DbError> {
727    let mut args = sqlx::mysql::MySqlArguments::default();
728    bind_mysql_args(&mut args, params)?;
729    let r = sqlx::query_with(sql, args)
730        .execute(pool)
731        .await
732        .map_err(|e| DbError::new(e.to_string()))?;
733    Ok(r.rows_affected())
734}
735
736#[cfg(feature = "model-mysql")]
737async fn mysql_pool_query_rows(
738    pool: &sqlx::Pool<sqlx::MySql>,
739    sql: &str,
740    params: &[Value],
741) -> Result<Vec<ModelRow>, DbError> {
742    let mut args = sqlx::mysql::MySqlArguments::default();
743    bind_mysql_args(&mut args, params)?;
744    let rows = sqlx::query_with(sql, args)
745        .fetch_all(pool)
746        .await
747        .map_err(|e| DbError::new(e.to_string()))?;
748    rows.into_iter().map(mysql_row_to_model_row).collect()
749}
750
751#[cfg(feature = "model-mysql")]
752async fn mysql_tx_execute(
753    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
754    sql: &str,
755    params: &[Value],
756) -> Result<u64, DbError> {
757    let mut args = sqlx::mysql::MySqlArguments::default();
758    bind_mysql_args(&mut args, params)?;
759    let r = sqlx::query_with(sql, args)
760        .execute(&mut **tx)
761        .await
762        .map_err(|e| DbError::new(e.to_string()))?;
763    Ok(r.rows_affected())
764}
765
766#[cfg(feature = "model-mysql")]
767async fn mysql_tx_query_rows(
768    tx: &mut sqlx::Transaction<'static, sqlx::MySql>,
769    sql: &str,
770    params: &[Value],
771) -> Result<Vec<ModelRow>, DbError> {
772    let mut args = sqlx::mysql::MySqlArguments::default();
773    bind_mysql_args(&mut args, params)?;
774    let rows = sqlx::query_with(sql, args)
775        .fetch_all(&mut **tx)
776        .await
777        .map_err(|e| DbError::new(e.to_string()))?;
778    rows.into_iter().map(mysql_row_to_model_row).collect()
779}
780
781#[cfg(feature = "model-mysql")]
782fn bind_mysql_args(
783    args: &mut sqlx::mysql::MySqlArguments,
784    params: &[Value],
785) -> Result<(), DbError> {
786    use sqlx::Arguments;
787    for v in params {
788        match v {
789            Value::Null  => args.add(None::<String>).map_err(|e| DbError::new(e.to_string()))?,
790            Value::Bool(b)  => args.add(*b).map_err(|e| DbError::new(e.to_string()))?,
791            Value::Int(i)   => args.add(*i).map_err(|e| DbError::new(e.to_string()))?,
792            Value::Float(f) => args.add(*f).map_err(|e| DbError::new(e.to_string()))?,
793            Value::Text(s)  => args.add(s.clone()).map_err(|e| DbError::new(e.to_string()))?,
794            Value::Bytes(b) => args.add(b.clone()).map_err(|e| DbError::new(e.to_string()))?,
795        }
796    }
797    Ok(())
798}
799
800#[cfg(feature = "model-mysql")]
801fn mysql_row_to_model_row(row: sqlx::mysql::MySqlRow) -> Result<ModelRow, DbError> {
802    use sqlx::{Column, Row, TypeInfo};
803    let mut cols: Vec<(String, Value)> = Vec::with_capacity(row.columns().len());
804    for col in row.columns() {
805        let name = col.name().to_string();
806        let type_name = col.type_info().name().to_uppercase();
807        let value = if type_name.contains("INT") || type_name.contains("SERIAL") {
808            row.try_get::<Option<i64>, _>(col.ordinal())
809                .map(|v| v.map(Value::Int).unwrap_or(Value::Null))
810                .unwrap_or(Value::Null)
811        } else if type_name.contains("FLOAT")
812            || type_name.contains("DOUBLE")
813            || type_name.contains("DECIMAL")
814            || type_name.contains("NUMERIC")
815        {
816            row.try_get::<Option<f64>, _>(col.ordinal())
817                .map(|v| v.map(Value::Float).unwrap_or(Value::Null))
818                .unwrap_or(Value::Null)
819        } else if type_name.contains("BOOL") {
820            row.try_get::<Option<bool>, _>(col.ordinal())
821                .map(|v| v.map(Value::Bool).unwrap_or(Value::Null))
822                .unwrap_or(Value::Null)
823        } else if type_name.contains("BLOB") || type_name.contains("BINARY") {
824            row.try_get::<Option<Vec<u8>>, _>(col.ordinal())
825                .map(|v| v.map(Value::Bytes).unwrap_or(Value::Null))
826                .unwrap_or(Value::Null)
827        } else {
828            row.try_get::<Option<String>, _>(col.ordinal())
829                .map(|v| v.map(Value::Text).unwrap_or(Value::Null))
830                .unwrap_or(Value::Null)
831        };
832        cols.push((name, value));
833    }
834    Ok(ModelRow::new(cols))
835}
836
837/// Insert then return the last inserted row ID from MySQL's `last_insert_id()`.
838/// Used by the repository's INSERT logic.
839#[cfg(feature = "model-mysql")]
840async fn mysql_last_insert_id(
841    pool: &sqlx::Pool<sqlx::MySql>,
842    sql: &str,
843    params: &[Value],
844) -> Result<i64, DbError> {
845    let mut args = sqlx::mysql::MySqlArguments::default();
846    bind_mysql_args(&mut args, params)?;
847    let r = sqlx::query_with(sql, args)
848        .execute(pool)
849        .await
850        .map_err(|e| DbError::new(e.to_string()))?;
851    Ok(r.last_insert_id() as i64)
852}
853
854// ── tracing ───────────────────────────────────────────────────────────────────
855
856/// A client span for one statement, inside a traced request: named after the
857/// operation (`SELECT`, `INSERT`, …) with `db.system.name` and
858/// `db.operation.name` — not the query text, which can carry literals.
859fn db_span(backend: Backend, sql: &str) -> Option<crate::otel::DetachedSpan> {
860    let operation = sql_operation(sql);
861    let mut span = crate::otel::detached_span(operation, crate::otel::SpanKind::Client)?;
862    let system = match backend {
863        #[cfg(feature = "model-sqlite")]
864        Backend::Sqlite => "sqlite",
865        #[cfg(feature = "model-postgres")]
866        Backend::Postgres => "postgresql",
867        #[cfg(feature = "model-mysql")]
868        Backend::MySql => "mysql",
869    };
870    span.set_attribute("db.system.name", system);
871    span.set_attribute("db.operation.name", operation);
872    Some(span)
873}
874
875#[cfg(all(test, feature = "model-sqlite"))]
876#[doc(hidden)]
877pub fn db_span_for_test(sql: &str) -> Option<crate::otel::DetachedSpan> {
878    db_span(Backend::Sqlite, sql)
879}
880
881fn finish_db_span<T>(span: Option<crate::otel::DetachedSpan>, result: &Result<T, DbError>) {
882    if let (Some(mut span), Err(e)) = (span, result) {
883        span.record_error(&e.to_string());
884    }
885}
886
887/// The statement's first keyword, upper-cased (`"query"` when there's none).
888#[doc(hidden)]
889pub fn sql_operation(sql: &str) -> &'static str {
890    const KNOWN: [&str; 14] = [
891        "SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "CREATE", "ALTER", "DROP", "BEGIN", "COMMIT", "ROLLBACK", "PRAGMA", "REPLACE", "TRUNCATE",
892    ];
893    let word = sql.trim_start().split(|c: char| !c.is_ascii_alphabetic()).next().unwrap_or("");
894    KNOWN.iter().find(|k| k.eq_ignore_ascii_case(word)).copied().unwrap_or("query")
895}