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}