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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
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}