Skip to main content

prax_sqlite/
engine.rs

1//! SQLite query engine implementing `prax_query::QueryEngine`.
2
3use std::sync::Arc;
4
5use prax_query::error::{QueryError, QueryResult};
6use prax_query::filter::FilterValue;
7use prax_query::row::FromRow;
8use prax_query::traits::{BoxFuture, Model, QueryEngine};
9use rusqlite::types::Value as SqlValue;
10use tokio_rusqlite::Connection as RusqliteConnection;
11use tracing::{trace, warn};
12
13use crate::connection::SqliteConnection;
14use crate::pool::SqlitePool;
15use crate::row_ref::SqliteRowRef;
16use crate::types::filter_value_to_sqlite;
17
18/// SQLite query engine backed by `tokio_rusqlite`.
19///
20/// # Breaking changes (0.7)
21///
22/// `SqliteEngine` no longer has inherent `query` / `query_one` / `query_opt`
23/// methods that returned untyped `RowData` / `serde_json::Value`. It now
24/// implements [`prax_query::traits::QueryEngine`], whose row-returning
25/// methods are generic over `T: Model + FromRow` and return typed models.
26///
27/// Migration:
28/// - Replace `engine.query(sql, params)` with
29///   `engine.query_many::<YourType>(sql, params).await?`, where `YourType`
30///   carries `#[derive(prax_orm::Model)]` (which emits both `Model` and
31///   `FromRow`) or hand-written `impl Model + impl FromRow`.
32/// - For ad-hoc typed queries without a full `Model`, bridge through
33///   [`crate::row_ref::SqliteRowRef::from_rusqlite`] inside a custom
34///   `FromRow` impl.
35/// - For the legacy JSON-blob API, use [`crate::raw::SqliteRawEngine`] +
36///   [`crate::raw::SqliteJsonRow`].
37/// - To run side-effecting SQL that returns no rows, call
38///   [`prax_query::traits::QueryEngine::execute_raw`].
39///
40/// See `CHANGELOG.md` for the full migration guide.
41///
42/// # Transaction mode
43///
44/// `SqliteEngine` has two modes, controlled by `tx_conn`:
45///
46/// - **Pool mode** (`tx_conn == None`, default): each query acquires
47///   a fresh [`SqliteConnection`] from the pool and drops it after
48///   the call.
49/// - **Transaction mode** (`tx_conn == Some(..)`): each query routes
50///   through the same [`tokio_rusqlite::Connection`] handle so the
51///   whole BEGIN…COMMIT block serialises onto the connection's
52///   background thread in order. The `Arc<SqliteConnection>` keeps
53///   the pool permit alive for the transaction's lifetime; dropping
54///   the last clone returns the connection to the idle pool.
55#[derive(Clone)]
56pub struct SqliteEngine {
57    pool: SqlitePool,
58    /// Present when this engine is bound to an in-flight transaction.
59    /// `None` in the normal pool-backed case.
60    tx_conn: Option<Arc<SqliteConnection>>,
61}
62
63impl SqliteEngine {
64    /// Create a new engine with the given pool.
65    pub fn new(pool: SqlitePool) -> Self {
66        Self {
67            pool,
68            tx_conn: None,
69        }
70    }
71
72    /// Get a reference to the connection pool.
73    pub fn pool(&self) -> &SqlitePool {
74        &self.pool
75    }
76
77    /// Resolve which raw [`tokio_rusqlite::Connection`] to run the
78    /// query against, along with an optional owned
79    /// [`SqliteConnection`] guard whose `Drop` returns the
80    /// connection to the idle pool once we're done with it.
81    ///
82    /// In tx mode the guard is `None` because the pinned connection
83    /// is already kept alive by the `Arc<SqliteConnection>` on
84    /// `self.tx_conn`. In pool mode we hand back a fresh guard so
85    /// the caller can clone the inner handle, drive one `call(..)`
86    /// through it, and drop the guard on the way out.
87    async fn resolve_conn(&self) -> QueryResult<(RusqliteConnection, Option<SqliteConnection>)> {
88        if let Some(tx) = &self.tx_conn {
89            Ok((tx.inner().clone(), None))
90        } else {
91            let conn = self
92                .pool
93                .get()
94                .await
95                .map_err(|e| QueryError::connection(e.to_string()).with_source(e))?;
96            let handle = conn.inner().clone();
97            Ok((handle, Some(conn)))
98        }
99    }
100
101    fn bind(params: &[FilterValue]) -> Vec<SqlValue> {
102        params.iter().map(filter_value_to_sqlite).collect()
103    }
104
105    /// Decode multiple rows into typed models.
106    ///
107    /// # Short-circuit on decode error
108    ///
109    /// Uses `Result<Vec<T>, _>::collect`, which returns the first decode
110    /// error and discards every successfully-decoded row before it. A
111    /// row-level type mismatch therefore aborts the whole batch rather
112    /// than returning partial results. Callers that want per-row
113    /// recovery should manually iterate rows and handle each result.
114    async fn query_rows<T: Model + FromRow>(
115        &self,
116        sql: String,
117        params: Vec<FilterValue>,
118    ) -> QueryResult<Vec<T>> {
119        trace!(sql = %sql, "sqlite query_rows");
120        let (handle, _guard) = self.resolve_conn().await?;
121        let bound = Self::bind(&params);
122        let snapshots: Vec<SqliteRowRef> = handle
123            .call(move |c| {
124                let mut stmt = c.prepare(&sql)?;
125                let refs: Vec<&dyn rusqlite::ToSql> =
126                    bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
127                let mut rows = stmt.query(refs.as_slice())?;
128                let mut out = Vec::new();
129                while let Some(row) = rows.next()? {
130                    out.push(SqliteRowRef::from_rusqlite(row).map_err(|e| {
131                        rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::other(
132                            e.to_string(),
133                        )))
134                    })?);
135                }
136                Ok(out)
137            })
138            .await
139            .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
140
141        snapshots
142            .into_iter()
143            .map(|r| {
144                T::from_row(&r).map_err(|e| {
145                    let msg = e.to_string();
146                    QueryError::deserialization(msg).with_source(e)
147                })
148            })
149            .collect()
150    }
151
152    /// Stop after the first row so callers that want a single row do not pay
153    /// for materializing the tail. Naively routing `query_one`/`query_optional`
154    /// through `query_rows` + `.pop()` would decode every matching row and
155    /// throw away all but one; a caller who accidentally asked for a single
156    /// row from a million-row table would allocate a million typed models.
157    async fn query_first_row<T: Model + FromRow>(
158        &self,
159        sql: String,
160        params: Vec<FilterValue>,
161    ) -> QueryResult<Option<T>> {
162        trace!(sql = %sql, "sqlite query_first_row");
163        let (handle, _guard) = self.resolve_conn().await?;
164        let bound = Self::bind(&params);
165        let snapshot: Option<SqliteRowRef> = handle
166            .call(move |c| {
167                let mut stmt = c.prepare(&sql)?;
168                let refs: Vec<&dyn rusqlite::ToSql> =
169                    bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
170                let mut rows = stmt.query(refs.as_slice())?;
171                match rows.next()? {
172                    Some(row) => Ok(Some(SqliteRowRef::from_rusqlite(row).map_err(|e| {
173                        rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::other(
174                            e.to_string(),
175                        )))
176                    })?)),
177                    None => Ok(None),
178                }
179            })
180            .await
181            .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
182
183        snapshot
184            .map(|r| {
185                T::from_row(&r).map_err(|e| {
186                    let msg = e.to_string();
187                    QueryError::deserialization(msg).with_source(e)
188                })
189            })
190            .transpose()
191    }
192
193    async fn exec_raw(&self, sql: String, params: Vec<FilterValue>) -> QueryResult<u64> {
194        let (handle, _guard) = self.resolve_conn().await?;
195        let bound = Self::bind(&params);
196        let n = handle
197            .call(move |c| {
198                let refs: Vec<&dyn rusqlite::ToSql> =
199                    bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
200                Ok(c.execute(&sql, refs.as_slice())?)
201            })
202            .await
203            .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
204        Ok(n as u64)
205    }
206
207    async fn count_rows(&self, sql: String, params: Vec<FilterValue>) -> QueryResult<u64> {
208        let (handle, _guard) = self.resolve_conn().await?;
209        let bound = Self::bind(&params);
210        let n = handle
211            .call(move |c| {
212                let refs: Vec<&dyn rusqlite::ToSql> =
213                    bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
214                let mut stmt = c.prepare(&sql)?;
215                let n: i64 = stmt.query_row(refs.as_slice(), |r| r.get(0))?;
216                Ok(n)
217            })
218            .await
219            .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
220        Ok(n as u64)
221    }
222}
223
224/// Drop-based guard for the open `BEGIN` in
225/// [`QueryEngine::transaction`]. If the transaction closure *panics*,
226/// unwinding skips the finalisation `match` entirely — without this
227/// guard the pooled connection would return to the idle pool still
228/// inside the transaction, and the next checkout would inherit it.
229///
230/// `Drop` cannot `.await`, so the best-effort ROLLBACK is spawned
231/// onto the runtime instead. That is still race-free: the guard is
232/// declared after `tx_conn`, so on unwind it drops *before* the
233/// `Arc<SqliteConnection>` returns the connection to the pool, and
234/// `tokio_rusqlite` executes queued `call(..)`s in FIFO order on the
235/// connection's single background thread — the spawned ROLLBACK is
236/// therefore enqueued ahead of any later checkout's statements.
237struct RollbackOnPanic {
238    handle: RusqliteConnection,
239    armed: bool,
240}
241
242impl RollbackOnPanic {
243    fn new(handle: RusqliteConnection) -> Self {
244        Self {
245            handle,
246            armed: true,
247        }
248    }
249
250    /// Stand down after COMMIT/ROLLBACK has been issued explicitly.
251    fn disarm(mut self) {
252        self.armed = false;
253    }
254}
255
256impl Drop for RollbackOnPanic {
257    fn drop(&mut self) {
258        if !self.armed {
259            return;
260        }
261        match tokio::runtime::Handle::try_current() {
262            Ok(rt) => {
263                let handle = self.handle.clone();
264                rt.spawn(async move {
265                    if let Err(e) = handle
266                        .call(|c| {
267                            c.execute_batch("ROLLBACK")?;
268                            Ok(())
269                        })
270                        .await
271                    {
272                        warn!(
273                            error = %e,
274                            "spawned transaction rollback failed; pooled connection may carry an open transaction"
275                        );
276                    }
277                });
278            }
279            Err(_) => {
280                warn!(
281                    "no tokio runtime handle available to spawn transaction rollback; \
282                     pooled connection may carry an open transaction"
283                );
284            }
285        }
286    }
287}
288
289impl QueryEngine for SqliteEngine {
290    fn dialect(&self) -> &dyn prax_query::dialect::SqlDialect {
291        &prax_query::dialect::Sqlite
292    }
293
294    fn query_many<T: Model + FromRow + Send + 'static>(
295        &self,
296        sql: &str,
297        params: Vec<FilterValue>,
298    ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
299        let sql = sql.to_string();
300        Box::pin(self.query_rows::<T>(sql, params))
301    }
302
303    fn query_one<T: Model + FromRow + Send + 'static>(
304        &self,
305        sql: &str,
306        params: Vec<FilterValue>,
307    ) -> BoxFuture<'_, QueryResult<T>> {
308        let sql = sql.to_string();
309        Box::pin(async move {
310            self.query_first_row::<T>(sql, params)
311                .await?
312                .ok_or_else(|| QueryError::not_found(T::MODEL_NAME))
313        })
314    }
315
316    fn query_optional<T: Model + FromRow + Send + 'static>(
317        &self,
318        sql: &str,
319        params: Vec<FilterValue>,
320    ) -> BoxFuture<'_, QueryResult<Option<T>>> {
321        let sql = sql.to_string();
322        Box::pin(self.query_first_row::<T>(sql, params))
323    }
324
325    fn execute_insert<T: Model + FromRow + Send + 'static>(
326        &self,
327        sql: &str,
328        params: Vec<FilterValue>,
329    ) -> BoxFuture<'_, QueryResult<T>> {
330        // SQLite 3.35+ supports INSERT ... RETURNING. INSERT RETURNING yields
331        // at most one row per inserted tuple; query_first_row avoids ever
332        // materializing a tail if the caller's SQL yields many (which would
333        // be a misuse, but the engine shouldn't punish it with unbounded
334        // allocation).
335        let sql = sql.to_string();
336        Box::pin(async move {
337            self.query_first_row::<T>(sql, params)
338                .await?
339                .ok_or_else(|| QueryError::not_found(T::MODEL_NAME))
340        })
341    }
342
343    fn execute_update<T: Model + FromRow + Send + 'static>(
344        &self,
345        sql: &str,
346        params: Vec<FilterValue>,
347    ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
348        let sql = sql.to_string();
349        Box::pin(self.query_rows::<T>(sql, params))
350    }
351
352    fn execute_delete(
353        &self,
354        sql: &str,
355        params: Vec<FilterValue>,
356    ) -> BoxFuture<'_, QueryResult<u64>> {
357        let sql = sql.to_string();
358        Box::pin(self.exec_raw(sql, params))
359    }
360
361    fn execute_raw(&self, sql: &str, params: Vec<FilterValue>) -> BoxFuture<'_, QueryResult<u64>> {
362        let sql = sql.to_string();
363        Box::pin(self.exec_raw(sql, params))
364    }
365
366    fn count(&self, sql: &str, params: Vec<FilterValue>) -> BoxFuture<'_, QueryResult<u64>> {
367        let sql = sql.to_string();
368        Box::pin(self.count_rows(sql, params))
369    }
370
371    fn aggregate_query(
372        &self,
373        sql: &str,
374        params: Vec<FilterValue>,
375    ) -> BoxFuture<'_, QueryResult<Vec<std::collections::HashMap<String, FilterValue>>>> {
376        let sql = sql.to_string();
377        Box::pin(async move {
378            trace!(sql = %sql, "sqlite aggregate_query");
379            let (handle, _guard) = self.resolve_conn().await?;
380            let bound = Self::bind(&params);
381            let rows: Vec<std::collections::HashMap<String, FilterValue>> = handle
382                .call(move |c| {
383                    let mut stmt = c.prepare(&sql)?;
384                    let column_names: Vec<String> =
385                        stmt.column_names().iter().map(|s| s.to_string()).collect();
386                    let refs: Vec<&dyn rusqlite::ToSql> =
387                        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
388                    let mut rows = stmt.query(refs.as_slice())?;
389                    let mut out = Vec::new();
390                    while let Some(row) = rows.next()? {
391                        let mut map = std::collections::HashMap::new();
392                        for (i, name) in column_names.iter().enumerate() {
393                            // SQLite storage classes are dynamic per-row:
394                            // INTEGER, REAL, TEXT, BLOB, NULL. Pull each
395                            // cell as an untyped `Value` and project into
396                            // the closest `FilterValue` variant. BLOB
397                            // becomes `Null` — aggregate results never
398                            // return BLOB in practice, and surfacing raw
399                            // bytes through FilterValue doesn't buy
400                            // anything for the caller.
401                            let v: SqlValue = row.get(i).unwrap_or(SqlValue::Null);
402                            let fv = match v {
403                                SqlValue::Null => FilterValue::Null,
404                                SqlValue::Integer(n) => FilterValue::Int(n),
405                                SqlValue::Real(f) => FilterValue::Float(f),
406                                SqlValue::Text(s) => FilterValue::String(s),
407                                SqlValue::Blob(_) => FilterValue::Null,
408                            };
409                            map.insert(name.clone(), fv);
410                        }
411                        out.push(map);
412                    }
413                    Ok(out)
414                })
415                .await
416                .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
417            Ok(rows)
418        })
419    }
420
421    fn transaction<'a, R, Fut, F>(&'a self, f: F) -> BoxFuture<'a, QueryResult<R>>
422    where
423        F: FnOnce(Self) -> Fut + Send + 'a,
424        Fut: std::future::Future<Output = QueryResult<R>> + Send + 'a,
425        R: Send + 'a,
426        Self: Clone,
427    {
428        Box::pin(async move {
429            // Refuse nested transactions until savepoint support
430            // lands. Callers can still drive SAVEPOINT / RELEASE
431            // manually via `execute_raw` if they need it.
432            if self.tx_conn.is_some() {
433                return Err(QueryError::internal(
434                    "nested transactions not yet implemented \
435                     (call .transaction() on the outer engine only, or \
436                     issue SAVEPOINT via execute_raw)",
437                ));
438            }
439
440            // Pin a single pooled connection. Wrapping it in `Arc`
441            // keeps the pool permit alive for the whole transaction
442            // and makes every engine clone share the same
443            // `tokio_rusqlite::Connection` handle — every query
444            // dispatched through the closure's engine therefore
445            // serialises onto the same background thread as our
446            // initial `BEGIN`.
447            let conn = self
448                .pool
449                .get()
450                .await
451                .map_err(|e| QueryError::connection(e.to_string()).with_source(e))?;
452            let handle = conn.inner().clone();
453            handle
454                .call(|c| {
455                    c.execute_batch("BEGIN")?;
456                    Ok(())
457                })
458                .await
459                .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
460
461            let tx_conn = Arc::new(conn);
462            let tx_engine = SqliteEngine {
463                pool: self.pool.clone(),
464                tx_conn: Some(tx_conn.clone()),
465            };
466
467            // Guard the open transaction: if the closure panics,
468            // unwinding skips the match below and would otherwise leak
469            // the connection back to the idle pool still inside BEGIN.
470            let rollback_guard = RollbackOnPanic::new(handle.clone());
471
472            let result = f(tx_engine).await;
473
474            // Finalise: COMMIT on success, best-effort ROLLBACK on
475            // failure. Preserve the caller's error if ROLLBACK fails.
476            // The guard is disarmed on both paths — it only fires when
477            // the closure panics and unwinding skips this match, or
478            // when COMMIT itself fails and the early `?` return drops
479            // it while still armed.
480            match result {
481                Ok(v) => {
482                    handle
483                        .call(|c| {
484                            c.execute_batch("COMMIT")?;
485                            Ok(())
486                        })
487                        .await
488                        .map_err(|e| QueryError::database(e.to_string()).with_source(e))?;
489                    rollback_guard.disarm();
490                    Ok(v)
491                }
492                Err(e) => {
493                    if let Err(rollback_err) = handle
494                        .call(|c| {
495                            c.execute_batch("ROLLBACK")?;
496                            Ok(())
497                        })
498                        .await
499                    {
500                        warn!(
501                            error = %rollback_err,
502                            "transaction rollback failed; pooled connection may carry an open transaction"
503                        );
504                    }
505                    rollback_guard.disarm();
506                    Err(e)
507                }
508            }
509        })
510    }
511}