Skip to main content

prax_query/
dialect.rs

1//! Abstraction over SQL dialect differences.
2//!
3//! Different databases vary in placeholder syntax (`$N`, `?`, `?N`, `@PN`),
4//! result-returning clauses (`RETURNING`, `OUTPUT INSERTED`), identifier
5//! quoting, upsert syntax, and transaction control keywords. Operations in
6//! `prax-query` compose SQL through a `&dyn SqlDialect`, obtained from their
7//! bound `QueryEngine` via `engine.dialect()`, so a single `build_sql`
8//! emission path serves every backend.
9
10/// Sealed supertrait so only this crate can implement `SqlDialect`.
11/// Prevents downstream crates from adding their own `SqlDialect`
12/// impls; we reserve the right to add new required methods to the
13/// trait without a SemVer break.
14mod sealed {
15    pub trait Sealed {}
16    impl Sealed for super::Postgres {}
17    impl Sealed for super::Sqlite {}
18    impl Sealed for super::Mysql {}
19    impl Sealed for super::Mssql {}
20    impl Sealed for super::Cql {}
21    impl Sealed for super::NotSql {}
22}
23
24/// Cross-dialect SQL emission helpers.
25///
26/// Implementations describe a single database backend's syntax choices.
27/// Engines return `&dyn SqlDialect` from `QueryEngine::dialect()`.
28pub trait SqlDialect: Send + Sync + sealed::Sealed {
29    /// Emit the 1-indexed parameter placeholder for position `i`.
30    fn placeholder(&self, i: usize) -> String;
31
32    /// Emit the clause (leading space included) that requests the given
33    /// columns be returned after an INSERT/UPDATE/DELETE. Postgres/SQLite/MySQL
34    /// emit `RETURNING cols`; MSSQL emits `OUTPUT INSERTED.cols`.
35    fn returning_clause(&self, cols: &str) -> String;
36
37    /// Quote a table/column identifier for safe interpolation.
38    fn quote_ident(&self, ident: &str) -> String;
39
40    /// Whether the dialect supports `SELECT DISTINCT ON (cols)` (Postgres-only
41    /// among our backends today).
42    fn supports_distinct_on(&self) -> bool {
43        false
44    }
45
46    /// Whether an INSERT statement can use the dialect's returning clause to
47    /// retrieve inserted rows in-place.
48    fn insert_has_returning(&self) -> bool {
49        true
50    }
51
52    /// Emit the ON CONFLICT / ON DUPLICATE KEY clause (leading space
53    /// included) that converts an INSERT into an upsert.
54    ///
55    /// **Identifier convention**: identifiers in `conflict_cols` MUST be
56    /// passed **unquoted** (raw column names). Implementations are
57    /// responsible for quoting them per dialect via `self.quote_ident`.
58    fn upsert_clause(&self, conflict_cols: &[&str], update_set: &str) -> String;
59
60    /// Whether this dialect supports SQL emission for upsert / nested-write
61    /// generation. Returns `false` for document-store dialects (`NotSql`)
62    /// that `unimplemented!()` on `quote_ident` / `placeholder` /
63    /// `upsert_clause`. Callers must check this before issuing any SQL
64    /// via the dialect.
65    fn supports_sql_emission(&self) -> bool {
66        true
67    }
68
69    /// True if this dialect's `upsert_clause` can produce a usable
70    /// single-statement `INSERT ... ON CONFLICT/ON DUPLICATE` form, and
71    /// (separately) if `placeholder` and `quote_ident` are real
72    /// implementations. False for document-store/NotSql dialects whose
73    /// SQL-emitting methods are `unimplemented!()`.
74    fn supports_upsert(&self) -> bool {
75        true
76    }
77
78    /// Emit a `DO NOTHING`/insert-ignore clause for the conflict target.
79    /// Default empty (fallback dialects skip the single-statement path
80    /// entirely). PG/SQLite/DuckDB return ` ON CONFLICT (...) DO NOTHING`;
81    /// MySQL returns ` ON DUPLICATE KEY UPDATE id = id` (no-op self-assign).
82    ///
83    /// Inputs are raw identifiers; the dialect quotes them internally.
84    fn upsert_do_nothing_clause(&self, _conflict_cols: &[&str]) -> String {
85        String::new()
86    }
87
88    /// LIKE-pattern escape clause (leading space included) appended to
89    /// `<col> LIKE <placeholder>` when the pattern carries backslash-escaped
90    /// user input. Postgres/SQLite/MSSQL accept `ESCAPE '\'`; MySQL treats
91    /// backslash as a string-literal escape character, so its spelling must
92    /// double the backslash (`ESCAPE '\\'`) — the single-backslash form is
93    /// an unterminated literal there.
94    fn like_escape_clause(&self) -> &'static str {
95        " ESCAPE '\\'"
96    }
97
98    /// SQL keyword that begins a transaction. Defaults to `BEGIN`.
99    fn begin_sql(&self) -> &'static str {
100        "BEGIN"
101    }
102
103    /// SQL keyword that commits a transaction. Defaults to `COMMIT`.
104    fn commit_sql(&self) -> &'static str {
105        "COMMIT"
106    }
107
108    /// SQL keyword that rolls back a transaction. Defaults to `ROLLBACK`.
109    fn rollback_sql(&self) -> &'static str {
110        "ROLLBACK"
111    }
112}
113
114/// PostgreSQL dialect: `$N` placeholders, `RETURNING`, `"ident"` quoting,
115/// `ON CONFLICT (cols) DO UPDATE SET ...` upserts, `DISTINCT ON` support.
116pub struct Postgres;
117
118/// SQLite dialect: `?N` placeholders, `RETURNING`, `"ident"` quoting,
119/// `ON CONFLICT (cols) DO UPDATE SET ...` upserts.
120pub struct Sqlite;
121
122/// MySQL dialect: `?` placeholders (positionless), no `RETURNING`
123/// support (that's a MariaDB 10.5+ extension, not MySQL 8.0),
124/// backtick-quoted identifiers, `ON DUPLICATE KEY UPDATE ...` upserts.
125///
126/// Because MySQL can't emit the inserted/updated row in-line, the
127/// `MysqlEngine` compensates at the driver layer: inserts look up
128/// `LAST_INSERT_ID()` and SELECT back, updates re-run the WHERE as a
129/// SELECT. See `prax_mysql::MysqlEngine::execute_insert` /
130/// `execute_update` for details.
131pub struct Mysql;
132
133/// Microsoft SQL Server dialect: `@PN` placeholders, `OUTPUT INSERTED.*`,
134/// bracket-quoted identifiers, `BEGIN/COMMIT/ROLLBACK TRANSACTION`. Upserts
135/// require MERGE, which the engine post-processes; the upsert clause emits
136/// empty.
137pub struct Mssql;
138
139/// Cassandra Query Language dialect, used by `ScyllaEngine` and the
140/// Cassandra driver. CQL overlaps with SQL for the basic
141/// `SELECT/INSERT/UPDATE/DELETE ... WHERE` shapes the Client API emits,
142/// but diverges on many details: no `RETURNING`, no cross-partition
143/// joins, no traditional `ON CONFLICT` (use `IF NOT EXISTS` LWT
144/// instead), no transactions, and `?` positional placeholders only.
145/// The CQL dialect emits that subset safely; engine-level compensation
146/// covers the RETURNING gap the way MySQL does.
147pub struct Cql;
148
149/// Inert dialect for engines that do not emit SQL (document stores such as
150/// MongoDB). Every helper returns an empty or identity value. Calling these
151/// methods is a bug — no SQL string built from this dialect would be valid
152/// against any real database. The driver's own non-SQL operation path should
153/// never reach these helpers.
154pub struct NotSql;
155
156impl SqlDialect for Postgres {
157    fn placeholder(&self, i: usize) -> String {
158        format!("${}", i)
159    }
160    fn returning_clause(&self, cols: &str) -> String {
161        format!(" RETURNING {}", cols)
162    }
163    fn quote_ident(&self, i: &str) -> String {
164        format!("\"{}\"", i.replace('"', "\"\""))
165    }
166    fn supports_distinct_on(&self) -> bool {
167        true
168    }
169    fn upsert_clause(&self, c: &[&str], s: &str) -> String {
170        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
171        format!(" ON CONFLICT ({}) DO UPDATE SET {}", quoted.join(", "), s)
172    }
173    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
174        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
175        format!(" ON CONFLICT ({}) DO NOTHING", quoted.join(", "))
176    }
177}
178
179impl SqlDialect for Sqlite {
180    fn placeholder(&self, i: usize) -> String {
181        format!("?{}", i)
182    }
183    fn returning_clause(&self, cols: &str) -> String {
184        format!(" RETURNING {}", cols)
185    }
186    fn quote_ident(&self, i: &str) -> String {
187        format!("\"{}\"", i.replace('"', "\"\""))
188    }
189    fn upsert_clause(&self, c: &[&str], s: &str) -> String {
190        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
191        format!(" ON CONFLICT ({}) DO UPDATE SET {}", quoted.join(", "), s)
192    }
193    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
194        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
195        format!(" ON CONFLICT ({}) DO NOTHING", quoted.join(", "))
196    }
197}
198
199impl SqlDialect for Mysql {
200    fn placeholder(&self, _i: usize) -> String {
201        "?".into()
202    }
203    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
204        let col = c.first().copied().unwrap_or("id");
205        format!(
206            " ON DUPLICATE KEY UPDATE {} = {}",
207            self.quote_ident(col),
208            self.quote_ident(col)
209        )
210    }
211    fn returning_clause(&self, _cols: &str) -> String {
212        // MySQL 8.0 does NOT support `INSERT ... RETURNING` / `UPDATE ...
213        // RETURNING` / `DELETE ... RETURNING`. That syntax only works on
214        // MariaDB 10.5+. Emitting it here produces a 1064 syntax error
215        // on every insert/update through a typed client.
216        //
217        // The `MysqlEngine`'s `execute_insert` / `execute_update`
218        // implementations compensate by running the DML first, then
219        // issuing a follow-up SELECT keyed on `LAST_INSERT_ID()` (for
220        // inserts) or re-running the filter (for updates). Returning
221        // an empty clause keeps the rest of the build_sql machinery
222        // working without driver-specific branches.
223        String::new()
224    }
225    fn insert_has_returning(&self) -> bool {
226        false
227    }
228    fn quote_ident(&self, i: &str) -> String {
229        format!("`{}`", i.replace('`', "``"))
230    }
231    fn upsert_clause(&self, _c: &[&str], s: &str) -> String {
232        format!(" ON DUPLICATE KEY UPDATE {}", s)
233    }
234    fn like_escape_clause(&self) -> &'static str {
235        // MySQL treats backslash as an escape character inside string
236        // literals, so `ESCAPE '\'` is an unterminated literal (error
237        // 1064). Doubling the backslash spells the escape character
238        // correctly — the SQL text reads ESCAPE '\\'.
239        " ESCAPE '\\\\'"
240    }
241}
242
243impl SqlDialect for Mssql {
244    fn placeholder(&self, i: usize) -> String {
245        format!("@P{}", i)
246    }
247    fn returning_clause(&self, cols: &str) -> String {
248        if cols == "*" {
249            // OUTPUT INSERTED.* is the only syntactic shortcut T-SQL accepts;
250            // bare OUTPUT INSERTED.cols_with_commas would need per-column
251            // prefixing, which this branch short-circuits.
252            return " OUTPUT INSERTED.*".into();
253        }
254        let prefixed: Vec<String> = cols
255            .split(',')
256            .map(|c| format!("INSERTED.{}", c.trim()))
257            .collect();
258        format!(" OUTPUT {}", prefixed.join(", "))
259    }
260    fn quote_ident(&self, i: &str) -> String {
261        format!("[{}]", i.replace(']', "]]"))
262    }
263    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
264        String::new()
265    }
266    fn begin_sql(&self) -> &'static str {
267        "BEGIN TRANSACTION"
268    }
269    fn commit_sql(&self) -> &'static str {
270        "COMMIT TRANSACTION"
271    }
272    fn rollback_sql(&self) -> &'static str {
273        "ROLLBACK TRANSACTION"
274    }
275}
276
277impl SqlDialect for Cql {
278    fn placeholder(&self, _i: usize) -> String {
279        "?".into()
280    }
281    fn returning_clause(&self, _cols: &str) -> String {
282        // CQL has no RETURNING; the Cassandra/Scylla engine issues a
283        // follow-up SELECT keyed on primary-key equality after every
284        // INSERT/UPDATE. See prax-scylladb's execute_insert for details.
285        String::new()
286    }
287    fn insert_has_returning(&self) -> bool {
288        false
289    }
290    fn quote_ident(&self, i: &str) -> String {
291        // CQL accepts double-quoted case-sensitive identifiers; without
292        // quoting, identifiers are lowercased.
293        format!("\"{}\"", i.replace('"', "\"\""))
294    }
295    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
296        // CQL has no ON CONFLICT. An INSERT is effectively an upsert
297        // by default (last-write-wins). Callers that need strict
298        // insert-or-fail should append `IF NOT EXISTS` via raw SQL.
299        String::new()
300    }
301}
302
303impl SqlDialect for NotSql {
304    fn placeholder(&self, _i: usize) -> String {
305        unimplemented!(
306            "NotSql dialect does not emit SQL; engines that return NotSql from \
307             QueryEngine::dialect() must not route requests through the SQL \
308             operation builders (FindManyOperation, CreateOperation, etc.). \
309             Use a SQL-capable dialect (Postgres/Mysql/Sqlite/Mssql) or build \
310             queries natively (e.g. BSON for MongoDB)."
311        )
312    }
313    fn returning_clause(&self, _cols: &str) -> String {
314        unimplemented!("NotSql::returning_clause — see NotSql::placeholder for details")
315    }
316    fn quote_ident(&self, _ident: &str) -> String {
317        unimplemented!("NotSql::quote_ident — see NotSql::placeholder for details")
318    }
319    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
320        unimplemented!("NotSql::upsert_clause — see NotSql::placeholder for details")
321    }
322    fn supports_sql_emission(&self) -> bool {
323        false
324    }
325    fn supports_upsert(&self) -> bool {
326        false
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn placeholders_per_dialect() {
336        assert_eq!(Postgres.placeholder(3), "$3");
337        assert_eq!(Sqlite.placeholder(3), "?3");
338        assert_eq!(Mysql.placeholder(3), "?");
339        assert_eq!(Mssql.placeholder(3), "@P3");
340    }
341
342    #[test]
343    fn returning_mssql_is_output_inserted() {
344        assert_eq!(Mssql.returning_clause("*"), " OUTPUT INSERTED.*");
345        assert_eq!(Mssql.returning_clause("id"), " OUTPUT INSERTED.id");
346        assert_eq!(
347            Mssql.returning_clause("id, email"),
348            " OUTPUT INSERTED.id, INSERTED.email"
349        );
350        assert_eq!(
351            Mssql.returning_clause("id,email,name"),
352            " OUTPUT INSERTED.id, INSERTED.email, INSERTED.name"
353        );
354    }
355
356    #[test]
357    fn upsert_mysql_is_on_duplicate_key() {
358        assert_eq!(
359            Mysql.upsert_clause(&[], "x = 1"),
360            " ON DUPLICATE KEY UPDATE x = 1"
361        );
362    }
363
364    #[test]
365    fn upsert_postgres_is_on_conflict() {
366        // upsert_clause quotes the raw ident internally.
367        assert_eq!(
368            Postgres.upsert_clause(&["email"], "name = EXCLUDED.name"),
369            " ON CONFLICT (\"email\") DO UPDATE SET name = EXCLUDED.name"
370        );
371    }
372
373    #[test]
374    fn quote_ident_backends_escape_the_embedded_quote() {
375        assert_eq!(
376            Postgres.quote_ident(r#"col"with"quote"#),
377            r#""col""with""quote""#
378        );
379        assert_eq!(
380            Sqlite.quote_ident(r#"col"with"quote"#),
381            r#""col""with""quote""#
382        );
383        assert_eq!(Mysql.quote_ident("co`l"), "`co``l`");
384        assert_eq!(Mssql.quote_ident("col]ident"), "[col]]ident]");
385    }
386
387    #[test]
388    #[should_panic(expected = "NotSql dialect does not emit SQL")]
389    fn not_sql_placeholder_panics() {
390        let _ = NotSql.placeholder(1);
391    }
392
393    #[test]
394    #[should_panic]
395    fn not_sql_quote_ident_panics() {
396        let _ = NotSql.quote_ident("col");
397    }
398
399    #[test]
400    #[should_panic]
401    fn not_sql_returning_clause_panics() {
402        let _ = NotSql.returning_clause("*");
403    }
404
405    #[test]
406    #[should_panic]
407    fn not_sql_upsert_clause_panics() {
408        let _ = NotSql.upsert_clause(&[], "x = 1");
409    }
410
411    #[test]
412    fn mssql_transaction_keywords_are_distinct() {
413        assert_eq!(Mssql.begin_sql(), "BEGIN TRANSACTION");
414        assert_eq!(Mssql.commit_sql(), "COMMIT TRANSACTION");
415        assert_eq!(Mssql.rollback_sql(), "ROLLBACK TRANSACTION");
416    }
417
418    #[test]
419    fn distinct_on_support() {
420        assert!(Postgres.supports_distinct_on());
421        assert!(!Sqlite.supports_distinct_on());
422        assert!(!Mysql.supports_distinct_on());
423        assert!(!Mssql.supports_distinct_on());
424        assert!(!NotSql.supports_distinct_on());
425    }
426
427    #[test]
428    fn like_escape_clause_per_dialect() {
429        // Default: single-backslash escape character.
430        assert_eq!(Postgres.like_escape_clause(), r" ESCAPE '\'");
431        assert_eq!(Sqlite.like_escape_clause(), r" ESCAPE '\'");
432        assert_eq!(Mssql.like_escape_clause(), r" ESCAPE '\'");
433        assert_eq!(Cql.like_escape_clause(), r" ESCAPE '\'");
434        // MySQL: backslash must be doubled inside the string literal.
435        assert_eq!(Mysql.like_escape_clause(), r" ESCAPE '\\'");
436    }
437
438    #[test]
439    fn sealed_pattern_prevents_external_impl() {
440        // The sealed supertrait means only types that impl sealed::Sealed
441        // can impl SqlDialect. Downstream crates can't access
442        // `sealed::Sealed` so they can't add new dialects. This test
443        // merely documents the intent; the enforcement is the compiler
444        // refusing to accept `impl SqlDialect for MyDialect` outside this
445        // crate.
446        use crate::dialect::{Postgres, SqlDialect};
447        let _p: &dyn SqlDialect = &Postgres;
448    }
449}