Skip to main content

modde_core/db/
backend.rs

1//! Backend-agnostic async SQL executor shared by every [`ModdeDb`](super::ModdeDb)
2//! method.
3//!
4//! modde supports two storage backends — `SQLite` (always available) and
5//! `PostgreSQL` (behind the default-on `postgres` feature). Rather than write two
6//! copies of every query, the ~60 CRUD methods are authored **once** against
7//! this thin executor: SQL is written with `?` placeholders and a small set of
8//! portable constructs (`RETURNING id`, `ON CONFLICT … DO UPDATE … EXCLUDED`,
9//! `lower(name)`, `bool` columns) that both `sqlx`'s bundled `SQLite` (3.46+) and
10//! `PostgreSQL` understand. Only the schema/migration DDL genuinely differs
11//! between backends; that lives in [`super::migrate`].
12//!
13//! The executor bridges the two real divergences:
14//!
15//! * **Placeholders** — Postgres needs `$1, $2, …`; the `SQLite` `?` form is
16//!   rewritten on the way out via [`rewrite_placeholders`].
17//! * **`now()`** — the `{NOW}` token expands to `datetime('now')` on `SQLite` and
18//!   to a matching `to_char(now(), …)` TEXT expression on Postgres, so the
19//!   timestamp columns stay `TEXT` and the Rust struct fields stay `String`.
20
21use std::borrow::Cow;
22
23use sqlx::sqlite::{SqliteArguments, SqlitePool, SqliteRow};
24use sqlx::{Arguments, Row};
25
26#[cfg(feature = "postgres")]
27use sqlx::postgres::{PgArguments, PgPool, PgRow};
28
29use crate::error::{CoreError, Result};
30use crate::resolver::{GameId, ModId};
31
32/// A backend-agnostic bound parameter.
33///
34/// Nulls are *typed* (`NullText` / `NullI64`) so `PostgreSQL` — which, unlike
35/// `SQLite`, will not infer the type of a bare `NULL` parameter in every position
36/// — always receives a parameter with a concrete type.
37#[derive(Debug, Clone)]
38pub enum Val {
39    NullText,
40    NullI64,
41    Bool(bool),
42    I64(i64),
43    Text(String),
44}
45
46impl From<&str> for Val {
47    fn from(s: &str) -> Self {
48        Val::Text(s.to_string())
49    }
50}
51impl From<String> for Val {
52    fn from(s: String) -> Self {
53        Val::Text(s)
54    }
55}
56impl From<&String> for Val {
57    fn from(s: &String) -> Self {
58        Val::Text(s.clone())
59    }
60}
61impl From<i64> for Val {
62    fn from(v: i64) -> Self {
63        Val::I64(v)
64    }
65}
66impl From<bool> for Val {
67    fn from(v: bool) -> Self {
68        Val::Bool(v)
69    }
70}
71impl From<&GameId> for Val {
72    fn from(v: &GameId) -> Self {
73        Val::Text(v.as_str().to_string())
74    }
75}
76impl From<&ModId> for Val {
77    fn from(v: &ModId) -> Self {
78        Val::Text(v.as_str().to_string())
79    }
80}
81impl From<Option<String>> for Val {
82    fn from(v: Option<String>) -> Self {
83        v.map_or(Val::NullText, Val::Text)
84    }
85}
86impl From<Option<&str>> for Val {
87    fn from(v: Option<&str>) -> Self {
88        v.map_or(Val::NullText, |s| Val::Text(s.to_string()))
89    }
90}
91impl From<Option<i64>> for Val {
92    fn from(v: Option<i64>) -> Self {
93        v.map_or(Val::NullI64, Val::I64)
94    }
95}
96
97/// Build a parameter list for an executor call.
98///
99/// Expands to a fixed-size `[Val; N]` array (not a `Vec`, so no heap
100/// allocation and no `clippy::useless_vec` at the `&vals![..]` call sites).
101/// Each argument is converted into a [`Val`] via [`Into`], so `i64`, `bool`,
102/// `&str`, `String`, `&GameId`, `&ModId`, and the `Option<…>` variants can be
103/// passed directly; callers pass `&vals![..]`, which coerces to `&[Val]`.
104macro_rules! vals {
105    () => { [] };
106    ($($x:expr),+ $(,)?) => { [ $( $crate::db::backend::Val::from($x) ),+ ] };
107}
108pub(crate) use vals;
109
110/// Column accessor presented to row-mapping closures.
111///
112/// Implemented for both `SqliteRow` and `PgRow`, this lets every mapper be
113/// written once against `&dyn DbRow` with no `sqlx` trait bounds leaking into
114/// the call site.
115pub trait DbRow {
116    fn i64(&self, idx: usize) -> Result<i64>;
117    fn opt_i64(&self, idx: usize) -> Result<Option<i64>>;
118    fn string(&self, idx: usize) -> Result<String>;
119    fn opt_string(&self, idx: usize) -> Result<Option<String>>;
120    fn bool(&self, idx: usize) -> Result<bool>;
121}
122
123impl DbRow for SqliteRow {
124    fn i64(&self, idx: usize) -> Result<i64> {
125        Ok(self.try_get(idx)?)
126    }
127    fn opt_i64(&self, idx: usize) -> Result<Option<i64>> {
128        Ok(self.try_get(idx)?)
129    }
130    fn string(&self, idx: usize) -> Result<String> {
131        Ok(self.try_get(idx)?)
132    }
133    fn opt_string(&self, idx: usize) -> Result<Option<String>> {
134        Ok(self.try_get(idx)?)
135    }
136    fn bool(&self, idx: usize) -> Result<bool> {
137        Ok(self.try_get(idx)?)
138    }
139}
140
141#[cfg(feature = "postgres")]
142impl DbRow for PgRow {
143    fn i64(&self, idx: usize) -> Result<i64> {
144        Ok(self.try_get(idx)?)
145    }
146    fn opt_i64(&self, idx: usize) -> Result<Option<i64>> {
147        Ok(self.try_get(idx)?)
148    }
149    fn string(&self, idx: usize) -> Result<String> {
150        Ok(self.try_get(idx)?)
151    }
152    fn opt_string(&self, idx: usize) -> Result<Option<String>> {
153        Ok(self.try_get(idx)?)
154    }
155    fn bool(&self, idx: usize) -> Result<bool> {
156        Ok(self.try_get(idx)?)
157    }
158}
159
160/// Which SQL dialect a connection speaks, used to shape the query text.
161#[derive(Clone, Copy)]
162enum Dialect {
163    Sqlite,
164    #[cfg(feature = "postgres")]
165    Postgres,
166}
167
168fn bind_err(e: impl std::fmt::Display) -> CoreError {
169    CoreError::Other(format!("failed to bind SQL parameter: {e}").into())
170}
171
172fn sqlite_args(vals: &[Val]) -> Result<SqliteArguments<'static>> {
173    let mut args = SqliteArguments::default();
174    for v in vals {
175        match v {
176            Val::NullText => args.add(None::<String>),
177            Val::NullI64 => args.add(None::<i64>),
178            Val::Bool(b) => args.add(*b),
179            Val::I64(i) => args.add(*i),
180            Val::Text(s) => args.add(s.clone()),
181        }
182        .map_err(bind_err)?;
183    }
184    Ok(args)
185}
186
187#[cfg(feature = "postgres")]
188fn pg_args(vals: &[Val]) -> Result<PgArguments> {
189    let mut args = PgArguments::default();
190    for v in vals {
191        match v {
192            Val::NullText => args.add(None::<String>),
193            Val::NullI64 => args.add(None::<i64>),
194            Val::Bool(b) => args.add(*b),
195            Val::I64(i) => args.add(*i),
196            Val::Text(s) => args.add(s.clone()),
197        }
198        .map_err(bind_err)?;
199    }
200    Ok(args)
201}
202
203/// Rewrite SQLite-style `?` placeholders into `PostgreSQL`'s positional
204/// `$1, $2, …` form.
205///
206/// Single-quoted string literals are skipped so a `?` inside a literal (we have
207/// none today, but be safe) is left untouched.
208#[cfg(feature = "postgres")]
209fn rewrite_placeholders(sql: &str) -> String {
210    let mut out = String::with_capacity(sql.len() + 8);
211    let mut n = 0u32;
212    let mut in_str = false;
213    for c in sql.chars() {
214        match c {
215            '\'' => {
216                in_str = !in_str;
217                out.push(c);
218            }
219            '?' if !in_str => {
220                n += 1;
221                out.push('$');
222                out.push_str(&n.to_string());
223            }
224            _ => out.push(c),
225        }
226    }
227    out
228}
229
230fn prepare_sql(dialect: Dialect, sql: &str) -> Cow<'_, str> {
231    match dialect {
232        Dialect::Sqlite => {
233            if sql.contains("{NOW}") {
234                Cow::Owned(sql.replace("{NOW}", "datetime('now')"))
235            } else {
236                Cow::Borrowed(sql)
237            }
238        }
239        #[cfg(feature = "postgres")]
240        Dialect::Postgres => {
241            let sql = sql.replace("{NOW}", "to_char(now(), 'YYYY-MM-DD HH24:MI:SS')");
242            Cow::Owned(rewrite_placeholders(&sql))
243        }
244    }
245}
246
247/// An open connection pool to one of the supported backends.
248///
249/// Cloning is cheap because the inner `sqlx` pool is reference-counted.
250#[derive(Debug, Clone)]
251pub enum Db {
252    Sqlite(SqlitePool),
253    #[cfg(feature = "postgres")]
254    Postgres(PgPool),
255}
256
257impl Db {
258    fn dialect(&self) -> Dialect {
259        match self {
260            Db::Sqlite(_) => Dialect::Sqlite,
261            #[cfg(feature = "postgres")]
262            Db::Postgres(_) => Dialect::Postgres,
263        }
264    }
265
266    /// Run a statement, returning the number of affected rows.
267    pub async fn execute(&self, sql: &str, vals: &[Val]) -> Result<u64> {
268        let sql = prepare_sql(self.dialect(), sql);
269        match self {
270            Db::Sqlite(pool) => {
271                let args = sqlite_args(vals)?;
272                Ok(sqlx::query_with(&sql, args)
273                    .execute(pool)
274                    .await?
275                    .rows_affected())
276            }
277            #[cfg(feature = "postgres")]
278            Db::Postgres(pool) => {
279                let args = pg_args(vals)?;
280                Ok(sqlx::query_with(&sql, args)
281                    .execute(pool)
282                    .await?
283                    .rows_affected())
284            }
285        }
286    }
287
288    /// Run a query and map every returned row.
289    pub async fn fetch_all<T>(
290        &self,
291        sql: &str,
292        vals: &[Val],
293        map: impl Fn(&dyn DbRow) -> Result<T>,
294    ) -> Result<Vec<T>> {
295        let sql = prepare_sql(self.dialect(), sql);
296        match self {
297            Db::Sqlite(pool) => {
298                let args = sqlite_args(vals)?;
299                let rows = sqlx::query_with(&sql, args).fetch_all(pool).await?;
300                rows.iter().map(|r| map(r as &dyn DbRow)).collect()
301            }
302            #[cfg(feature = "postgres")]
303            Db::Postgres(pool) => {
304                let args = pg_args(vals)?;
305                let rows = sqlx::query_with(&sql, args).fetch_all(pool).await?;
306                rows.iter().map(|r| map(r as &dyn DbRow)).collect()
307            }
308        }
309    }
310
311    /// Run a query expected to return at most one row.
312    pub async fn fetch_optional<T>(
313        &self,
314        sql: &str,
315        vals: &[Val],
316        map: impl Fn(&dyn DbRow) -> Result<T>,
317    ) -> Result<Option<T>> {
318        let sql = prepare_sql(self.dialect(), sql);
319        match self {
320            Db::Sqlite(pool) => {
321                let args = sqlite_args(vals)?;
322                match sqlx::query_with(&sql, args).fetch_optional(pool).await? {
323                    Some(r) => Ok(Some(map(&r as &dyn DbRow)?)),
324                    None => Ok(None),
325                }
326            }
327            #[cfg(feature = "postgres")]
328            Db::Postgres(pool) => {
329                let args = pg_args(vals)?;
330                match sqlx::query_with(&sql, args).fetch_optional(pool).await? {
331                    Some(r) => Ok(Some(map(&r as &dyn DbRow)?)),
332                    None => Ok(None),
333                }
334            }
335        }
336    }
337
338    /// Run a query expected to return exactly one row.
339    pub async fn fetch_one<T>(
340        &self,
341        sql: &str,
342        vals: &[Val],
343        map: impl Fn(&dyn DbRow) -> Result<T>,
344    ) -> Result<T> {
345        let sql = prepare_sql(self.dialect(), sql);
346        match self {
347            Db::Sqlite(pool) => {
348                let args = sqlite_args(vals)?;
349                let r = sqlx::query_with(&sql, args).fetch_one(pool).await?;
350                map(&r as &dyn DbRow)
351            }
352            #[cfg(feature = "postgres")]
353            Db::Postgres(pool) => {
354                let args = pg_args(vals)?;
355                let r = sqlx::query_with(&sql, args).fetch_one(pool).await?;
356                map(&r as &dyn DbRow)
357            }
358        }
359    }
360
361    /// Begin a transaction for the few multi-statement writers that need
362    /// all-or-nothing semantics (`record_install`, `remove_installed_mod`).
363    pub async fn begin(&self) -> Result<DbTx<'_>> {
364        Ok(match self {
365            Db::Sqlite(pool) => DbTx::Sqlite(pool.begin().await?),
366            #[cfg(feature = "postgres")]
367            Db::Postgres(pool) => DbTx::Postgres(pool.begin().await?),
368        })
369    }
370}
371
372/// An in-progress transaction mirroring [`Db`]'s query surface.
373pub enum DbTx<'c> {
374    Sqlite(sqlx::Transaction<'c, sqlx::Sqlite>),
375    #[cfg(feature = "postgres")]
376    Postgres(sqlx::Transaction<'c, sqlx::Postgres>),
377}
378
379impl DbTx<'_> {
380    fn dialect(&self) -> Dialect {
381        match self {
382            DbTx::Sqlite(_) => Dialect::Sqlite,
383            #[cfg(feature = "postgres")]
384            DbTx::Postgres(_) => Dialect::Postgres,
385        }
386    }
387
388    pub async fn execute(&mut self, sql: &str, vals: &[Val]) -> Result<u64> {
389        let sql = prepare_sql(self.dialect(), sql);
390        match self {
391            DbTx::Sqlite(tx) => {
392                let args = sqlite_args(vals)?;
393                Ok(sqlx::query_with(&sql, args)
394                    .execute(&mut **tx)
395                    .await?
396                    .rows_affected())
397            }
398            #[cfg(feature = "postgres")]
399            DbTx::Postgres(tx) => {
400                let args = pg_args(vals)?;
401                Ok(sqlx::query_with(&sql, args)
402                    .execute(&mut **tx)
403                    .await?
404                    .rows_affected())
405            }
406        }
407    }
408
409    pub async fn commit(self) -> Result<()> {
410        match self {
411            DbTx::Sqlite(tx) => tx.commit().await?,
412            #[cfg(feature = "postgres")]
413            DbTx::Postgres(tx) => tx.commit().await?,
414        }
415        Ok(())
416    }
417}