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