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    /// SQL keyword that begins a transaction. Defaults to `BEGIN`.
89    fn begin_sql(&self) -> &'static str {
90        "BEGIN"
91    }
92
93    /// SQL keyword that commits a transaction. Defaults to `COMMIT`.
94    fn commit_sql(&self) -> &'static str {
95        "COMMIT"
96    }
97
98    /// SQL keyword that rolls back a transaction. Defaults to `ROLLBACK`.
99    fn rollback_sql(&self) -> &'static str {
100        "ROLLBACK"
101    }
102}
103
104/// PostgreSQL dialect: `$N` placeholders, `RETURNING`, `"ident"` quoting,
105/// `ON CONFLICT (cols) DO UPDATE SET ...` upserts, `DISTINCT ON` support.
106pub struct Postgres;
107
108/// SQLite dialect: `?N` placeholders, `RETURNING`, `"ident"` quoting,
109/// `ON CONFLICT (cols) DO UPDATE SET ...` upserts.
110pub struct Sqlite;
111
112/// MySQL dialect: `?` placeholders (positionless), no `RETURNING`
113/// support (that's a MariaDB 10.5+ extension, not MySQL 8.0),
114/// backtick-quoted identifiers, `ON DUPLICATE KEY UPDATE ...` upserts.
115///
116/// Because MySQL can't emit the inserted/updated row in-line, the
117/// `MysqlEngine` compensates at the driver layer: inserts look up
118/// `LAST_INSERT_ID()` and SELECT back, updates re-run the WHERE as a
119/// SELECT. See `prax_mysql::MysqlEngine::execute_insert` /
120/// `execute_update` for details.
121pub struct Mysql;
122
123/// Microsoft SQL Server dialect: `@PN` placeholders, `OUTPUT INSERTED.*`,
124/// bracket-quoted identifiers, `BEGIN/COMMIT/ROLLBACK TRANSACTION`. Upserts
125/// require MERGE, which the engine post-processes; the upsert clause emits
126/// empty.
127pub struct Mssql;
128
129/// Cassandra Query Language dialect, used by `ScyllaEngine` and the
130/// Cassandra driver. CQL overlaps with SQL for the basic
131/// `SELECT/INSERT/UPDATE/DELETE ... WHERE` shapes the Client API emits,
132/// but diverges on many details: no `RETURNING`, no cross-partition
133/// joins, no traditional `ON CONFLICT` (use `IF NOT EXISTS` LWT
134/// instead), no transactions, and `?` positional placeholders only.
135/// The CQL dialect emits that subset safely; engine-level compensation
136/// covers the RETURNING gap the way MySQL does.
137pub struct Cql;
138
139/// Inert dialect for engines that do not emit SQL (document stores such as
140/// MongoDB). Every helper returns an empty or identity value. Calling these
141/// methods is a bug — no SQL string built from this dialect would be valid
142/// against any real database. The driver's own non-SQL operation path should
143/// never reach these helpers.
144pub struct NotSql;
145
146impl SqlDialect for Postgres {
147    fn placeholder(&self, i: usize) -> String {
148        format!("${}", i)
149    }
150    fn returning_clause(&self, cols: &str) -> String {
151        format!(" RETURNING {}", cols)
152    }
153    fn quote_ident(&self, i: &str) -> String {
154        format!("\"{}\"", i.replace('"', "\"\""))
155    }
156    fn supports_distinct_on(&self) -> bool {
157        true
158    }
159    fn upsert_clause(&self, c: &[&str], s: &str) -> String {
160        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
161        format!(" ON CONFLICT ({}) DO UPDATE SET {}", quoted.join(", "), s)
162    }
163    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
164        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
165        format!(" ON CONFLICT ({}) DO NOTHING", quoted.join(", "))
166    }
167}
168
169impl SqlDialect for Sqlite {
170    fn placeholder(&self, i: usize) -> String {
171        format!("?{}", i)
172    }
173    fn returning_clause(&self, cols: &str) -> String {
174        format!(" RETURNING {}", cols)
175    }
176    fn quote_ident(&self, i: &str) -> String {
177        format!("\"{}\"", i.replace('"', "\"\""))
178    }
179    fn upsert_clause(&self, c: &[&str], s: &str) -> String {
180        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
181        format!(" ON CONFLICT ({}) DO UPDATE SET {}", quoted.join(", "), s)
182    }
183    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
184        let quoted: Vec<String> = c.iter().map(|col| self.quote_ident(col)).collect();
185        format!(" ON CONFLICT ({}) DO NOTHING", quoted.join(", "))
186    }
187}
188
189impl SqlDialect for Mysql {
190    fn placeholder(&self, _i: usize) -> String {
191        "?".into()
192    }
193    fn upsert_do_nothing_clause(&self, c: &[&str]) -> String {
194        let col = c.first().copied().unwrap_or("id");
195        format!(
196            " ON DUPLICATE KEY UPDATE {} = {}",
197            self.quote_ident(col),
198            self.quote_ident(col)
199        )
200    }
201    fn returning_clause(&self, _cols: &str) -> String {
202        // MySQL 8.0 does NOT support `INSERT ... RETURNING` / `UPDATE ...
203        // RETURNING` / `DELETE ... RETURNING`. That syntax only works on
204        // MariaDB 10.5+. Emitting it here produces a 1064 syntax error
205        // on every insert/update through a typed client.
206        //
207        // The `MysqlEngine`'s `execute_insert` / `execute_update`
208        // implementations compensate by running the DML first, then
209        // issuing a follow-up SELECT keyed on `LAST_INSERT_ID()` (for
210        // inserts) or re-running the filter (for updates). Returning
211        // an empty clause keeps the rest of the build_sql machinery
212        // working without driver-specific branches.
213        String::new()
214    }
215    fn insert_has_returning(&self) -> bool {
216        false
217    }
218    fn quote_ident(&self, i: &str) -> String {
219        format!("`{}`", i.replace('`', "``"))
220    }
221    fn upsert_clause(&self, _c: &[&str], s: &str) -> String {
222        format!(" ON DUPLICATE KEY UPDATE {}", s)
223    }
224}
225
226impl SqlDialect for Mssql {
227    fn placeholder(&self, i: usize) -> String {
228        format!("@P{}", i)
229    }
230    fn returning_clause(&self, cols: &str) -> String {
231        if cols == "*" {
232            // OUTPUT INSERTED.* is the only syntactic shortcut T-SQL accepts;
233            // bare OUTPUT INSERTED.cols_with_commas would need per-column
234            // prefixing, which this branch short-circuits.
235            return " OUTPUT INSERTED.*".into();
236        }
237        let prefixed: Vec<String> = cols
238            .split(',')
239            .map(|c| format!("INSERTED.{}", c.trim()))
240            .collect();
241        format!(" OUTPUT {}", prefixed.join(", "))
242    }
243    fn quote_ident(&self, i: &str) -> String {
244        format!("[{}]", i.replace(']', "]]"))
245    }
246    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
247        String::new()
248    }
249    fn begin_sql(&self) -> &'static str {
250        "BEGIN TRANSACTION"
251    }
252    fn commit_sql(&self) -> &'static str {
253        "COMMIT TRANSACTION"
254    }
255    fn rollback_sql(&self) -> &'static str {
256        "ROLLBACK TRANSACTION"
257    }
258}
259
260impl SqlDialect for Cql {
261    fn placeholder(&self, _i: usize) -> String {
262        "?".into()
263    }
264    fn returning_clause(&self, _cols: &str) -> String {
265        // CQL has no RETURNING; the Cassandra/Scylla engine issues a
266        // follow-up SELECT keyed on primary-key equality after every
267        // INSERT/UPDATE. See prax-scylladb's execute_insert for details.
268        String::new()
269    }
270    fn insert_has_returning(&self) -> bool {
271        false
272    }
273    fn quote_ident(&self, i: &str) -> String {
274        // CQL accepts double-quoted case-sensitive identifiers; without
275        // quoting, identifiers are lowercased.
276        format!("\"{}\"", i.replace('"', "\"\""))
277    }
278    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
279        // CQL has no ON CONFLICT. An INSERT is effectively an upsert
280        // by default (last-write-wins). Callers that need strict
281        // insert-or-fail should append `IF NOT EXISTS` via raw SQL.
282        String::new()
283    }
284}
285
286impl SqlDialect for NotSql {
287    fn placeholder(&self, _i: usize) -> String {
288        unimplemented!(
289            "NotSql dialect does not emit SQL; engines that return NotSql from \
290             QueryEngine::dialect() must not route requests through the SQL \
291             operation builders (FindManyOperation, CreateOperation, etc.). \
292             Use a SQL-capable dialect (Postgres/Mysql/Sqlite/Mssql) or build \
293             queries natively (e.g. BSON for MongoDB)."
294        )
295    }
296    fn returning_clause(&self, _cols: &str) -> String {
297        unimplemented!("NotSql::returning_clause — see NotSql::placeholder for details")
298    }
299    fn quote_ident(&self, _ident: &str) -> String {
300        unimplemented!("NotSql::quote_ident — see NotSql::placeholder for details")
301    }
302    fn upsert_clause(&self, _c: &[&str], _s: &str) -> String {
303        unimplemented!("NotSql::upsert_clause — see NotSql::placeholder for details")
304    }
305    fn supports_sql_emission(&self) -> bool {
306        false
307    }
308    fn supports_upsert(&self) -> bool {
309        false
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn placeholders_per_dialect() {
319        assert_eq!(Postgres.placeholder(3), "$3");
320        assert_eq!(Sqlite.placeholder(3), "?3");
321        assert_eq!(Mysql.placeholder(3), "?");
322        assert_eq!(Mssql.placeholder(3), "@P3");
323    }
324
325    #[test]
326    fn returning_mssql_is_output_inserted() {
327        assert_eq!(Mssql.returning_clause("*"), " OUTPUT INSERTED.*");
328        assert_eq!(Mssql.returning_clause("id"), " OUTPUT INSERTED.id");
329        assert_eq!(
330            Mssql.returning_clause("id, email"),
331            " OUTPUT INSERTED.id, INSERTED.email"
332        );
333        assert_eq!(
334            Mssql.returning_clause("id,email,name"),
335            " OUTPUT INSERTED.id, INSERTED.email, INSERTED.name"
336        );
337    }
338
339    #[test]
340    fn upsert_mysql_is_on_duplicate_key() {
341        assert_eq!(
342            Mysql.upsert_clause(&[], "x = 1"),
343            " ON DUPLICATE KEY UPDATE x = 1"
344        );
345    }
346
347    #[test]
348    fn upsert_postgres_is_on_conflict() {
349        // upsert_clause quotes the raw ident internally.
350        assert_eq!(
351            Postgres.upsert_clause(&["email"], "name = EXCLUDED.name"),
352            " ON CONFLICT (\"email\") DO UPDATE SET name = EXCLUDED.name"
353        );
354    }
355
356    #[test]
357    fn quote_ident_backends_escape_the_embedded_quote() {
358        assert_eq!(
359            Postgres.quote_ident(r#"col"with"quote"#),
360            r#""col""with""quote""#
361        );
362        assert_eq!(
363            Sqlite.quote_ident(r#"col"with"quote"#),
364            r#""col""with""quote""#
365        );
366        assert_eq!(Mysql.quote_ident("co`l"), "`co``l`");
367        assert_eq!(Mssql.quote_ident("col]ident"), "[col]]ident]");
368    }
369
370    #[test]
371    #[should_panic(expected = "NotSql dialect does not emit SQL")]
372    fn not_sql_placeholder_panics() {
373        let _ = NotSql.placeholder(1);
374    }
375
376    #[test]
377    #[should_panic]
378    fn not_sql_quote_ident_panics() {
379        let _ = NotSql.quote_ident("col");
380    }
381
382    #[test]
383    #[should_panic]
384    fn not_sql_returning_clause_panics() {
385        let _ = NotSql.returning_clause("*");
386    }
387
388    #[test]
389    #[should_panic]
390    fn not_sql_upsert_clause_panics() {
391        let _ = NotSql.upsert_clause(&[], "x = 1");
392    }
393
394    #[test]
395    fn mssql_transaction_keywords_are_distinct() {
396        assert_eq!(Mssql.begin_sql(), "BEGIN TRANSACTION");
397        assert_eq!(Mssql.commit_sql(), "COMMIT TRANSACTION");
398        assert_eq!(Mssql.rollback_sql(), "ROLLBACK TRANSACTION");
399    }
400
401    #[test]
402    fn distinct_on_support() {
403        assert!(Postgres.supports_distinct_on());
404        assert!(!Sqlite.supports_distinct_on());
405        assert!(!Mysql.supports_distinct_on());
406        assert!(!Mssql.supports_distinct_on());
407        assert!(!NotSql.supports_distinct_on());
408    }
409
410    #[test]
411    fn sealed_pattern_prevents_external_impl() {
412        // The sealed supertrait means only types that impl sealed::Sealed
413        // can impl SqlDialect. Downstream crates can't access
414        // `sealed::Sealed` so they can't add new dialects. This test
415        // merely documents the intent; the enforcement is the compiler
416        // refusing to accept `impl SqlDialect for MyDialect` outside this
417        // crate.
418        use crate::dialect::{Postgres, SqlDialect};
419        let _p: &dyn SqlDialect = &Postgres;
420    }
421}