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}