Skip to main content

zeph_db/
dialect.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// SQL fragments that differ between database backends.
5///
6/// Implemented by zero-sized marker types ([`Sqlite`], [`Postgres`]).
7/// All associated constants are `&'static str` for zero-cost usage.
8pub trait Dialect: Send + Sync + 'static {
9    /// Auto-increment primary key DDL fragment.
10    ///
11    /// `SQLite`: `INTEGER PRIMARY KEY AUTOINCREMENT`
12    /// `PostgreSQL`: `BIGSERIAL PRIMARY KEY`
13    const AUTO_PK: &'static str;
14
15    /// `INSERT OR IGNORE` prefix for this backend.
16    ///
17    /// `SQLite`: `INSERT OR IGNORE`
18    /// `PostgreSQL`: `INSERT` (pair with `CONFLICT_NOTHING` suffix)
19    const INSERT_IGNORE: &'static str;
20
21    /// Suffix for conflict-do-nothing semantics.
22    ///
23    /// `SQLite`: empty string (handled by `INSERT OR IGNORE` prefix)
24    /// `PostgreSQL`: `ON CONFLICT DO NOTHING`
25    const CONFLICT_NOTHING: &'static str;
26
27    /// Case-insensitive collation suffix for `ORDER BY` / `WHERE` clauses.
28    ///
29    /// `SQLite`: `COLLATE NOCASE`
30    /// `PostgreSQL`: empty string (use `ILIKE` or `LOWER()` instead)
31    const COLLATE_NOCASE: &'static str;
32
33    /// Current epoch seconds expression.
34    ///
35    /// `SQLite`: `unixepoch('now')`
36    /// `PostgreSQL`: `EXTRACT(EPOCH FROM NOW())::BIGINT`
37    const EPOCH_NOW: &'static str;
38
39    /// Current timestamp expression, for direct assignment into a `TEXT`/`TIMESTAMPTZ`
40    /// `updated_at`-style column (as opposed to [`Self::EPOCH_NOW`], which yields an integer).
41    ///
42    /// `SQLite`: `datetime('now')`
43    /// `PostgreSQL`: `NOW()`
44    const NOW: &'static str;
45
46    /// Case-insensitive comparison expression for a column.
47    ///
48    /// `SQLite`: `{col} COLLATE NOCASE`
49    /// `PostgreSQL`: `LOWER({col})`
50    fn ilike(col: &str) -> String;
51
52    /// Epoch seconds expression for a timestamp column.
53    ///
54    /// Wraps the column in the backend-specific function that converts a stored
55    /// timestamp to a Unix epoch integer, coalescing `NULL` to `0`.
56    ///
57    /// `SQLite`: `COALESCE(CAST(strftime('%s', {col}) AS INTEGER), 0)`
58    /// `PostgreSQL`: `COALESCE(CAST(EXTRACT(EPOCH FROM {col}) AS BIGINT), 0)`
59    fn epoch_from_col(col: &str) -> String;
60
61    /// Cast suffix for a bind parameter carrying pre-serialized JSON text, destined
62    /// for a JSON-typed column.
63    ///
64    /// `sqlx` sends string bind parameters as `TEXT`/`VARCHAR`. `SQLite` stores JSON
65    /// columns as `TEXT` so no cast is needed. `PostgreSQL` stores them as `JSONB`,
66    /// which requires an explicit cast from the bound `TEXT` value — otherwise the
67    /// backend rejects the insert/update with "column is of type jsonb but expression
68    /// is of type text". Append this suffix directly after the `?` placeholder for
69    /// that bind position, e.g. `format!("VALUES (?{json_cast})", json_cast = ...)`.
70    ///
71    /// `SQLite`: empty string
72    /// `PostgreSQL`: `::jsonb`
73    const JSON_CAST: &'static str;
74
75    /// Cast suffix for a bind parameter carrying a pre-formatted timestamp string, destined
76    /// for a timestamp-typed column.
77    ///
78    /// `sqlx` sends string bind parameters as `TEXT`/`VARCHAR`. `SQLite` stores timestamp
79    /// columns as `TEXT` so no cast is needed. `PostgreSQL` stores them as `TIMESTAMPTZ`, which
80    /// has no implicit cast from `TEXT` — binding a plain string fails with
81    /// `PgDatabaseError` 42804 ("column is of type timestamptz but expression is of type
82    /// text"). Append this suffix directly after the `?` placeholder for that bind position,
83    /// e.g. `format!("VALUES (?{timestamptz_cast})", timestamptz_cast = ...)`.
84    ///
85    /// `SQLite`: empty string
86    /// `PostgreSQL`: `::timestamptz`
87    const TIMESTAMPTZ_CAST: &'static str;
88
89    /// Project a non-`TEXT` column so it decodes into a plain `String`.
90    ///
91    /// `SQLite` is dynamically typed and stores JSON/timestamp columns as `TEXT`, so
92    /// the column already decodes into `String` directly — no cast needed. `PostgreSQL`
93    /// stores the same logical data in natively-typed columns (`JSONB`, `TIMESTAMPTZ`,
94    /// ...); decoding those straight into `String` fails (`sqlx`'s `String:
95    /// Decode<Postgres>` only covers `TEXT`-family OIDs), so the column must be cast to
96    /// `::text` in the `SELECT` list for call sites that only need the raw text
97    /// representation (rather than a typed decode via `sqlx::types::Json<T>` or
98    /// `chrono::DateTime`).
99    ///
100    /// `SQLite`: `{col}`
101    /// `PostgreSQL`: `{col}::text`
102    fn select_as_text(col: &str) -> String;
103
104    /// Scalar function name returning the greatest of two (or more) numeric arguments.
105    ///
106    /// `SQLite`'s `max(a, b, ...)` is a scalar multi-argument function. `PostgreSQL`'s
107    /// `MAX()` is exclusively an aggregate (requires `GROUP BY`) — the scalar equivalent
108    /// is `GREATEST(a, b, ...)`. Use this constant when building a two-argument "largest
109    /// of" expression that must work as a plain scalar call, not an aggregate.
110    ///
111    /// `SQLite`: `MAX`
112    /// `PostgreSQL`: `GREATEST`
113    const GREATEST_FN: &'static str;
114
115    /// Scalar function name returning the least of two (or more) numeric arguments.
116    ///
117    /// `SQLite`'s `min(a, b, ...)` is a scalar multi-argument function. `PostgreSQL`'s
118    /// `MIN()` is exclusively an aggregate (requires `GROUP BY`) — the scalar equivalent
119    /// is `LEAST(a, b, ...)`. Use this constant when building a two-argument "smallest
120    /// of" expression that must work as a plain scalar call, not an aggregate.
121    ///
122    /// `SQLite`: `MIN`
123    /// `PostgreSQL`: `LEAST`
124    const LEAST_FN: &'static str;
125
126    /// Timestamp expression for binding a Unix epoch-seconds value into a
127    /// `TEXT`/`TIMESTAMPTZ` `compacted_at`-style column.
128    ///
129    /// Wraps the given bind-parameter placeholder (e.g. `?`, later rewritten to `$N`
130    /// by [`crate::rewrite_placeholders`]) in the backend-specific conversion from
131    /// Unix epoch seconds to the column's native timestamp representation.
132    ///
133    /// `SQLite` stores such columns as bare epoch-seconds `TEXT`, so the placeholder
134    /// passes through unchanged. `PostgreSQL` stores them as `TIMESTAMPTZ`; unlike an
135    /// ISO-8601 string, a bare epoch-seconds string has no valid `timestamptz` input
136    /// syntax — `'1735999999'::timestamptz` fails to parse even with
137    /// [`Self::TIMESTAMPTZ_CAST`] appended. The bound value must instead be routed
138    /// through `to_timestamp()`, which both performs the epoch conversion and yields a
139    /// `TIMESTAMPTZ` directly, so no additional cast is needed on the result.
140    ///
141    /// The placeholder itself still needs an explicit `::double precision` cast:
142    /// callers typically bind a Rust `String`/`&str` (the value is formatted with
143    /// `format!("{secs}")` before binding), which `sqlx` sends with the `TEXT` type OID.
144    /// `to_timestamp()` has only `to_timestamp(double precision)` and
145    /// `to_timestamp(text, text)` overloads — there is no implicit `text` ->
146    /// `double precision` cast, so an unqualified `to_timestamp({placeholder})` fails
147    /// function-argument resolution with `ERROR 42883: function to_timestamp(text) does
148    /// not exist`. The `::double precision` cast on the placeholder is what makes the
149    /// bound text resolve to the numeric overload.
150    ///
151    /// `SQLite`: `{placeholder}`
152    /// `PostgreSQL`: `to_timestamp({placeholder}::double precision)`
153    fn timestamptz_from_epoch(placeholder: &str) -> String;
154}
155
156/// `SQLite` dialect marker type.
157pub struct Sqlite;
158
159impl Dialect for Sqlite {
160    const AUTO_PK: &'static str = "INTEGER PRIMARY KEY AUTOINCREMENT";
161    const INSERT_IGNORE: &'static str = "INSERT OR IGNORE";
162    const CONFLICT_NOTHING: &'static str = "";
163    const COLLATE_NOCASE: &'static str = "COLLATE NOCASE";
164    const EPOCH_NOW: &'static str = "unixepoch('now')";
165    const NOW: &'static str = "datetime('now')";
166    const JSON_CAST: &'static str = "";
167    const TIMESTAMPTZ_CAST: &'static str = "";
168    const GREATEST_FN: &'static str = "MAX";
169    const LEAST_FN: &'static str = "MIN";
170
171    fn ilike(col: &str) -> String {
172        format!("{col} COLLATE NOCASE")
173    }
174
175    fn epoch_from_col(col: &str) -> String {
176        format!("COALESCE(CAST(strftime('%s', {col}) AS INTEGER), 0)")
177    }
178
179    fn select_as_text(col: &str) -> String {
180        col.to_string()
181    }
182
183    fn timestamptz_from_epoch(placeholder: &str) -> String {
184        placeholder.to_string()
185    }
186}
187
188/// `PostgreSQL` dialect marker type.
189pub struct Postgres;
190
191impl Dialect for Postgres {
192    const AUTO_PK: &'static str = "BIGSERIAL PRIMARY KEY";
193    const INSERT_IGNORE: &'static str = "INSERT";
194    const CONFLICT_NOTHING: &'static str = "ON CONFLICT DO NOTHING";
195    const COLLATE_NOCASE: &'static str = "";
196    const EPOCH_NOW: &'static str = "EXTRACT(EPOCH FROM NOW())::BIGINT";
197    const NOW: &'static str = "NOW()";
198    const JSON_CAST: &'static str = "::jsonb";
199    const TIMESTAMPTZ_CAST: &'static str = "::timestamptz";
200    const GREATEST_FN: &'static str = "GREATEST";
201    const LEAST_FN: &'static str = "LEAST";
202
203    fn ilike(col: &str) -> String {
204        format!("LOWER({col})")
205    }
206
207    fn epoch_from_col(col: &str) -> String {
208        format!("COALESCE(CAST(EXTRACT(EPOCH FROM {col}) AS BIGINT), 0)")
209    }
210
211    fn select_as_text(col: &str) -> String {
212        format!("{col}::text")
213    }
214
215    fn timestamptz_from_epoch(placeholder: &str) -> String {
216        format!("to_timestamp({placeholder}::double precision)")
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[cfg(feature = "sqlite")]
225    mod sqlite {
226        use super::*;
227
228        #[test]
229        fn auto_pk() {
230            assert_eq!(Sqlite::AUTO_PK, "INTEGER PRIMARY KEY AUTOINCREMENT");
231        }
232
233        #[test]
234        fn insert_ignore() {
235            assert_eq!(Sqlite::INSERT_IGNORE, "INSERT OR IGNORE");
236            assert_eq!(Sqlite::CONFLICT_NOTHING, "");
237        }
238
239        #[test]
240        fn epoch_now() {
241            assert_eq!(Sqlite::EPOCH_NOW, "unixepoch('now')");
242        }
243
244        #[test]
245        fn now() {
246            assert_eq!(Sqlite::NOW, "datetime('now')");
247        }
248
249        #[test]
250        fn epoch_from_col() {
251            assert_eq!(
252                Sqlite::epoch_from_col("created_at"),
253                "COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0)"
254            );
255        }
256
257        #[test]
258        fn select_as_text() {
259            assert_eq!(Sqlite::select_as_text("parts"), "parts");
260        }
261
262        #[test]
263        fn ilike() {
264            assert_eq!(Sqlite::ilike("name"), "name COLLATE NOCASE");
265        }
266
267        #[test]
268        fn json_cast() {
269            assert_eq!(Sqlite::JSON_CAST, "");
270        }
271
272        #[test]
273        fn timestamptz_cast() {
274            assert_eq!(Sqlite::TIMESTAMPTZ_CAST, "");
275        }
276
277        #[test]
278        fn greatest_fn() {
279            assert_eq!(Sqlite::GREATEST_FN, "MAX");
280        }
281
282        #[test]
283        fn least_fn() {
284            assert_eq!(Sqlite::LEAST_FN, "MIN");
285        }
286
287        #[test]
288        fn timestamptz_from_epoch() {
289            assert_eq!(Sqlite::timestamptz_from_epoch("?"), "?");
290        }
291    }
292
293    #[cfg(feature = "postgres")]
294    mod postgres {
295        use super::*;
296
297        #[test]
298        fn auto_pk() {
299            assert_eq!(Postgres::AUTO_PK, "BIGSERIAL PRIMARY KEY");
300        }
301
302        #[test]
303        fn insert_ignore() {
304            assert_eq!(Postgres::INSERT_IGNORE, "INSERT");
305            assert_eq!(Postgres::CONFLICT_NOTHING, "ON CONFLICT DO NOTHING");
306        }
307
308        #[test]
309        fn epoch_now() {
310            assert_eq!(Postgres::EPOCH_NOW, "EXTRACT(EPOCH FROM NOW())::BIGINT");
311        }
312
313        #[test]
314        fn now() {
315            assert_eq!(Postgres::NOW, "NOW()");
316        }
317
318        #[test]
319        fn epoch_from_col() {
320            assert_eq!(
321                Postgres::epoch_from_col("created_at"),
322                "COALESCE(CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT), 0)"
323            );
324        }
325
326        #[test]
327        fn select_as_text() {
328            assert_eq!(Postgres::select_as_text("parts"), "parts::text");
329        }
330
331        #[test]
332        fn ilike() {
333            assert_eq!(Postgres::ilike("name"), "LOWER(name)");
334        }
335
336        #[test]
337        fn json_cast() {
338            assert_eq!(Postgres::JSON_CAST, "::jsonb");
339        }
340
341        #[test]
342        fn timestamptz_cast() {
343            assert_eq!(Postgres::TIMESTAMPTZ_CAST, "::timestamptz");
344        }
345
346        #[test]
347        fn greatest_fn() {
348            assert_eq!(Postgres::GREATEST_FN, "GREATEST");
349        }
350
351        #[test]
352        fn least_fn() {
353            assert_eq!(Postgres::LEAST_FN, "LEAST");
354        }
355
356        #[test]
357        fn timestamptz_from_epoch() {
358            assert_eq!(
359                Postgres::timestamptz_from_epoch("?"),
360                "to_timestamp(?::double precision)"
361            );
362        }
363    }
364}