fraiseql_db/dialect/trait_def.rs
1//! `SqlDialect` trait — dialect-specific SQL rendering primitives.
2
3use std::borrow::Cow;
4
5use crate::types::sql_hints::ScalarFieldType;
6
7/// Column type used by row-shaped view (`vr_*`) DDL generation.
8///
9/// Maps GraphQL scalar types to their SQL equivalents for typed column
10/// extraction from JSON/JSONB data columns.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum RowViewColumnType {
13 /// Text / varchar column.
14 Text,
15 /// 32-bit integer.
16 Int32,
17 /// 64-bit integer.
18 Int64,
19 /// 64-bit floating point.
20 Float64,
21 /// Boolean.
22 Boolean,
23 /// UUID.
24 Uuid,
25 /// Timestamp with timezone.
26 Timestamptz,
27 /// JSON / JSONB.
28 Json,
29 /// Date (without time).
30 Date,
31}
32
33/// Error returned when an operator is not supported by a dialect.
34#[derive(Debug)]
35pub struct UnsupportedOperator {
36 /// Dialect name (e.g., "MySQL").
37 pub dialect: &'static str,
38 /// Operator name (e.g., "ArrayContainedBy").
39 pub operator: &'static str,
40}
41
42impl std::fmt::Display for UnsupportedOperator {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 write!(
45 f,
46 "operator `{}` is not supported by the {} dialect",
47 self.operator, self.dialect
48 )
49 }
50}
51
52impl std::error::Error for UnsupportedOperator {}
53
54/// Dialect-specific SQL rendering primitives for WHERE clause generation.
55///
56/// Implement this trait to add a new database backend. All methods that are
57/// identical across dialects have default implementations — override only
58/// what your dialect requires.
59///
60/// # Security contract
61///
62/// Implementations MUST:
63/// - Never interpolate user-supplied values into the returned SQL string. Use [`Self::placeholder`]
64/// and append values to the `params` vector instead.
65/// - Escape field / column names via the `path_escape` module.
66/// - Escape literal SQL identifiers (not values) by doubling the delimiter.
67pub trait SqlDialect: Send + Sync + 'static {
68 // ── Core primitives (must implement) ──────────────────────────────────────
69
70 /// Dialect name for error messages (e.g., `"PostgreSQL"`, `"MySQL"`).
71 fn name(&self) -> &'static str;
72
73 /// Quote a database identifier (table or column name).
74 ///
75 /// # Examples
76 /// - PostgreSQL: `v_user` → `"v_user"`, `evil"name` → `"evil""name"`
77 /// - MySQL: `v_user` → `` `v_user` ``, `` evil`name `` → `` `evil``name` ``
78 /// - SQL Server: `v_user` → `[v_user]`, `evil]name` → `[evil]]name]`
79 fn quote_identifier(&self, name: &str) -> String;
80
81 /// Generate SQL to extract a scalar string from a JSON/JSONB column.
82 ///
83 /// `column` is the unquoted column name (typically `"data"`).
84 /// `path` is the slice of field-name segments (pre-escaped by caller if needed).
85 ///
86 /// # Examples
87 /// - PostgreSQL (1 segment): `data->>'field'`
88 /// - MySQL: `JSON_UNQUOTE(JSON_EXTRACT(data, '$.outer.inner'))`
89 /// - SQLite: `json_extract(data, '$.outer.inner')`
90 /// - SQL Server: `JSON_VALUE(data, '$.outer.inner')`
91 fn json_extract_scalar(&self, column: &str, path: &[String]) -> String;
92
93 /// Next parameter placeholder. Called with the current 1-based index.
94 ///
95 /// - PostgreSQL: `$1`, `$2`, …
96 /// - SQL Server: `@p1`, `@p2`, …
97 /// - MySQL / SQLite: `?`
98 fn placeholder(&self, n: usize) -> String;
99
100 // ── Scalar type casts ──────────────────────────────────────────────────────
101
102 /// SQL type name this dialect casts a JSON-extracted value to for `ty`,
103 /// or `None` when the raw `text` extraction is already the right type.
104 ///
105 /// This is **the** type → SQL-type table for the dialect. Both the ORDER BY
106 /// renderer ([`DatabaseType::typed_json_field_expr`]) and the WHERE
107 /// generator read it, so a sort and a filter on the same field can never
108 /// disagree about how that field is typed.
109 ///
110 /// [`DatabaseType::typed_json_field_expr`]: crate::types::DatabaseType::typed_json_field_expr
111 fn cast_type_name(&self, ty: ScalarFieldType) -> Option<&'static str>;
112
113 /// Wrap a JSON-extracted scalar expression so it compares as `ty`.
114 ///
115 /// Default renders ANSI `CAST(expr AS type)`; PostgreSQL overrides with
116 /// `(expr)::type`. `ScalarFieldType::Text` — and any type the dialect has no
117 /// cast for — returns the expression unchanged.
118 fn cast_expr_as<'a>(&self, expr: &'a str, ty: ScalarFieldType) -> Cow<'a, str> {
119 match self.cast_type_name(ty) {
120 Some(name) => Cow::Owned(format!("CAST({expr} AS {name})")),
121 None => Cow::Borrowed(expr),
122 }
123 }
124
125 /// Wrap a parameter placeholder so it is compared as `ty`.
126 ///
127 /// PostgreSQL overrides this with `({p}::text)::type`: tokio-postgres binds
128 /// JSON scalars as text, and without forcing the server to resolve the
129 /// parameter as `text` first it expects the binary encoding of the target
130 /// type. sqlx (MySQL, SQLite) and tiberius (SQL Server) send string
131 /// parameters as text regardless, so the default passes the placeholder
132 /// through and lets the database coerce it.
133 fn cast_param_as<'a>(&self, placeholder: &'a str, _ty: ScalarFieldType) -> Cow<'a, str> {
134 Cow::Borrowed(placeholder)
135 }
136
137 /// Wrap a parameter placeholder for a native-column equality condition.
138 ///
139 /// `native_type` is the PostgreSQL canonical type name stored in
140 /// `native_columns` (e.g. `"uuid"`, `"int4"`, `"timestamp"`).
141 ///
142 /// **Only PostgreSQL needs to override this.** tokio-postgres uses a binary
143 /// wire protocol: when the query contains `$1::uuid`, the server resolves `$1`
144 /// as OID 2950 and expects 16-byte binary UUID encoding, but `QueryParam::Text`
145 /// sends UTF-8 bytes, causing "incorrect binary data format". The two-step
146 /// cast `$1::text::uuid` forces the server to resolve `$1` as `text` first.
147 ///
148 /// sqlx (MySQL, SQLite) and tiberius (SQL Server) send string parameters as
149 /// text by default regardless of column type, so no cast is needed — the
150 /// database coerces the text value at comparison time.
151 ///
152 /// Default: return the placeholder unchanged (correct for MySQL, SQLite,
153 /// SQL Server).
154 fn cast_native_param(&self, placeholder: &str, _native_type: &str) -> String {
155 placeholder.to_string()
156 }
157
158 // ── LIKE / pattern matching ────────────────────────────────────────────────
159
160 /// Trailing `ESCAPE` clause required to make `escape_like_literal`'s
161 /// backslash escaping effective on this dialect, or `""` when the dialect
162 /// already treats `\` as the escape character.
163 ///
164 /// PostgreSQL and MySQL default to `\`; SQLite and SQL Server have **no**
165 /// default escape character, so without this clause an escaped `\%` matches
166 /// a literal backslash followed by the wildcard — the pattern over-matches
167 /// instead of being neutralised (#722).
168 fn like_escape_clause(&self) -> &'static str {
169 ""
170 }
171
172 /// SQL fragment for case-sensitive LIKE: `lhs LIKE rhs`.
173 fn like_sql(&self, lhs: &str, rhs: &str) -> String {
174 format!("{lhs} LIKE {rhs}{}", self.like_escape_clause())
175 }
176
177 /// SQL fragment for case-insensitive LIKE.
178 ///
179 /// Default: `LOWER(lhs) LIKE LOWER(rhs)` (MySQL / SQLite compatible).
180 /// PostgreSQL overrides with `lhs ILIKE rhs`.
181 /// SQL Server overrides with `lhs LIKE rhs COLLATE Latin1_General_CI_AI`.
182 fn ilike_sql(&self, lhs: &str, rhs: &str) -> String {
183 format!("LOWER({lhs}) LIKE LOWER({rhs}){}", self.like_escape_clause())
184 }
185
186 /// String concatenation operator / function for building LIKE patterns.
187 ///
188 /// Default: `||` (ANSI SQL — works for PostgreSQL and SQLite).
189 /// MySQL overrides with `CONCAT(…)`.
190 /// SQL Server overrides with `+`.
191 fn concat_sql(&self, parts: &[&str]) -> String {
192 parts.join(" || ")
193 }
194
195 // ── Empty clause sentinels ─────────────────────────────────────────────────
196
197 /// SQL literal for "always false" (used for empty IN clauses, empty OR).
198 ///
199 /// Default: `FALSE`. SQLite and SQL Server use `1=0`.
200 fn always_false(&self) -> &'static str {
201 "FALSE"
202 }
203
204 /// SQL literal for "always true" (used for empty AND).
205 ///
206 /// Default: `TRUE`. SQLite and SQL Server use `1=1`.
207 fn always_true(&self) -> &'static str {
208 "TRUE"
209 }
210
211 // ── Inequality operator ────────────────────────────────────────────────────
212
213 /// SQL inequality operator. Default `!=`. SQL Server uses `<>`.
214 fn neq_operator(&self) -> &'static str {
215 "!="
216 }
217
218 // ── Array length function ──────────────────────────────────────────────────
219
220 /// SQL expression for the length of a JSON array stored in `expr`.
221 fn json_array_length(&self, expr: &str) -> String;
222
223 // ── Array containment (returns Err if not supported) ──────────────────────
224
225 /// SQL for "array contains this element".
226 ///
227 /// Default: returns `Err(UnsupportedOperator)`.
228 ///
229 /// # Errors
230 ///
231 /// Returns [`UnsupportedOperator`] if this dialect does not support array containment.
232 fn array_contains_sql(&self, _lhs: &str, _rhs: &str) -> Result<String, UnsupportedOperator> {
233 Err(UnsupportedOperator {
234 dialect: self.name(),
235 operator: "ArrayContains",
236 })
237 }
238
239 /// SQL for "element is contained by array".
240 ///
241 /// # Errors
242 ///
243 /// Returns [`UnsupportedOperator`] if this dialect does not support array containment.
244 fn array_contained_by_sql(
245 &self,
246 _lhs: &str,
247 _rhs: &str,
248 ) -> Result<String, UnsupportedOperator> {
249 Err(UnsupportedOperator {
250 dialect: self.name(),
251 operator: "ArrayContainedBy",
252 })
253 }
254
255 /// SQL for "arrays overlap".
256 ///
257 /// # Errors
258 ///
259 /// Returns [`UnsupportedOperator`] if this dialect does not support array overlap.
260 fn array_overlaps_sql(&self, _lhs: &str, _rhs: &str) -> Result<String, UnsupportedOperator> {
261 Err(UnsupportedOperator {
262 dialect: self.name(),
263 operator: "ArrayOverlaps",
264 })
265 }
266
267 // ── Full-text search (returns Err if not supported) ────────────────────────
268
269 /// SQL for `to_tsvector(expr) @@ to_tsquery(param)`.
270 ///
271 /// # Errors
272 ///
273 /// Returns [`UnsupportedOperator`] if this dialect does not support full-text search.
274 fn fts_matches_sql(&self, _expr: &str, _param: &str) -> Result<String, UnsupportedOperator> {
275 Err(UnsupportedOperator {
276 dialect: self.name(),
277 operator: "Matches",
278 })
279 }
280
281 /// SQL for plain-text full-text search.
282 ///
283 /// # Errors
284 ///
285 /// Returns [`UnsupportedOperator`] if this dialect does not support plain-text FTS.
286 fn fts_plain_query_sql(
287 &self,
288 _expr: &str,
289 _param: &str,
290 ) -> Result<String, UnsupportedOperator> {
291 Err(UnsupportedOperator {
292 dialect: self.name(),
293 operator: "PlainQuery",
294 })
295 }
296
297 /// SQL for phrase full-text search.
298 ///
299 /// # Errors
300 ///
301 /// Returns [`UnsupportedOperator`] if this dialect does not support phrase FTS.
302 fn fts_phrase_query_sql(
303 &self,
304 _expr: &str,
305 _param: &str,
306 ) -> Result<String, UnsupportedOperator> {
307 Err(UnsupportedOperator {
308 dialect: self.name(),
309 operator: "PhraseQuery",
310 })
311 }
312
313 /// SQL for web-search full-text search.
314 ///
315 /// # Errors
316 ///
317 /// Returns [`UnsupportedOperator`] if this dialect does not support web-search FTS.
318 fn fts_websearch_query_sql(
319 &self,
320 _expr: &str,
321 _param: &str,
322 ) -> Result<String, UnsupportedOperator> {
323 Err(UnsupportedOperator {
324 dialect: self.name(),
325 operator: "WebsearchQuery",
326 })
327 }
328
329 /// SQL for the full-text *relevance* of a document against a web-search
330 /// query — the ordering half of `?search=` (#1284).
331 ///
332 /// `document_expr` is a text expression; `query_param` is a placeholder for
333 /// the search text, which is arbitrary client input and is therefore bound
334 /// rather than interpolated. Lives beside
335 /// [`fts_websearch_query_sql`](Self::fts_websearch_query_sql) so the
336 /// text-search configuration the two resolve against cannot drift: a rank
337 /// computed under a different configuration from the predicate that matched
338 /// would order rows by a document nothing searched.
339 ///
340 /// # Errors
341 ///
342 /// Returns [`UnsupportedOperator`] if this dialect does not support
343 /// full-text ranking.
344 fn fts_rank_sql(
345 &self,
346 _document_expr: &str,
347 _query_param: &str,
348 ) -> Result<String, UnsupportedOperator> {
349 Err(UnsupportedOperator {
350 dialect: self.name(),
351 operator: "Relevance",
352 })
353 }
354
355 // ── Regex (returns Err if not supported) ───────────────────────────────────
356
357 /// SQL for POSIX-style regex match.
358 ///
359 /// Default: returns `Err(UnsupportedOperator)`.
360 /// PostgreSQL overrides with `~`, `~*`, `!~`, `!~*`.
361 /// MySQL overrides with `REGEXP` / `NOT REGEXP`.
362 ///
363 /// # Errors
364 ///
365 /// Returns [`UnsupportedOperator`] if this dialect does not support regex matching.
366 fn regex_sql(
367 &self,
368 _lhs: &str,
369 _rhs: &str,
370 _case_insensitive: bool,
371 _negate: bool,
372 ) -> Result<String, UnsupportedOperator> {
373 Err(UnsupportedOperator {
374 dialect: self.name(),
375 operator: "Regex",
376 })
377 }
378
379 // ── PostgreSQL-only operators (Vector, Network, LTree) ────────────────────
380 // These methods have default `Err` implementations; only `PostgresDialect`
381 // overrides them. Callers push parameter values before calling these methods
382 // and pass the already-generated placeholder strings.
383
384 /// Generate SQL for an INET unary check (IsIPv4, IsIPv6, IsPrivate, IsPublic, IsLoopback,
385 /// IsNotLoopback).
386 ///
387 /// `check_name` identifies the operator (passed to `UnsupportedOperator` on failure).
388 ///
389 /// # Errors
390 ///
391 /// Returns [`UnsupportedOperator`] if this dialect does not support INET checks.
392 fn inet_check_sql(&self, _lhs: &str, _check_name: &str) -> Result<String, UnsupportedOperator> {
393 Err(UnsupportedOperator {
394 dialect: self.name(),
395 operator: "InetCheck",
396 })
397 }
398
399 /// Generate SQL for an INET binary operation (InSubnet, ContainsSubnet, ContainsIP, Overlaps).
400 ///
401 /// `pg_op` is one of `<<`, `>>`, `&&`.
402 ///
403 /// # Errors
404 ///
405 /// Returns [`UnsupportedOperator`] if this dialect does not support INET binary operations.
406 fn inet_binary_sql(
407 &self,
408 _pg_op: &str,
409 _lhs: &str,
410 _rhs: &str,
411 ) -> Result<String, UnsupportedOperator> {
412 Err(UnsupportedOperator {
413 dialect: self.name(),
414 operator: "InetBinaryOp",
415 })
416 }
417
418 /// Generate SQL for an LTree binary operator.
419 ///
420 /// `pg_op` is one of `@>`, `<@`, `~`, `@`.
421 /// `rhs_type` is the cast type for `rhs` (e.g., `"ltree"`, `"lquery"`, `"ltxtquery"`).
422 ///
423 /// # Errors
424 ///
425 /// Returns [`UnsupportedOperator`] if this dialect does not support LTree operations.
426 fn ltree_binary_sql(
427 &self,
428 _pg_op: &str,
429 _lhs: &str,
430 _rhs: &str,
431 _rhs_type: &str,
432 ) -> Result<String, UnsupportedOperator> {
433 Err(UnsupportedOperator {
434 dialect: self.name(),
435 operator: "LTreeBinaryOp",
436 })
437 }
438
439 /// Generate SQL for `ltree ? ARRAY[...]` (MatchesAnyLquery).
440 ///
441 /// `placeholders` contains pre-formatted placeholder strings
442 /// (e.g., `["$1::lquery", "$2::lquery"]`).
443 ///
444 /// # Errors
445 ///
446 /// Returns [`UnsupportedOperator`] if this dialect does not support LTree lquery arrays.
447 fn ltree_any_lquery_sql(
448 &self,
449 _lhs: &str,
450 _placeholders: &[String],
451 ) -> Result<String, UnsupportedOperator> {
452 Err(UnsupportedOperator {
453 dialect: self.name(),
454 operator: "MatchesAnyLquery",
455 })
456 }
457
458 /// Generate SQL for `nlevel(ltree) OP param` (depth comparison operators).
459 ///
460 /// # Errors
461 ///
462 /// Returns [`UnsupportedOperator`] if this dialect does not support LTree depth comparisons.
463 fn ltree_depth_sql(
464 &self,
465 _op: &str,
466 _lhs: &str,
467 _rhs: &str,
468 ) -> Result<String, UnsupportedOperator> {
469 Err(UnsupportedOperator {
470 dialect: self.name(),
471 operator: "LTreeDepth",
472 })
473 }
474
475 /// Generate SQL for ID-based ltree operators (`descendantOfId`, `ancestorOfId`).
476 ///
477 /// Generates a subquery that resolves the ltree path from an entity UUID:
478 /// - Self-referencing: `field_expr <@ (SELECT path FROM t WHERE id = $1)`
479 /// - Cross-table: `fk IN (SELECT id FROM t WHERE path <@ (SELECT path FROM t WHERE id = $1))`
480 ///
481 /// `pg_op` is `"<@"` for `descendantOfId` or `"@>"` for `ancestorOfId`.
482 /// `field_expr` is the resolved LHS expression (e.g., `data->>'category_path'`).
483 ///
484 /// # Errors
485 ///
486 /// Returns [`UnsupportedOperator`] if this dialect does not support LTree operations.
487 fn ltree_id_subquery_sql(
488 &self,
489 _pg_op: &str,
490 _field_expr: &str,
491 _table: &str,
492 _path_column: &str,
493 _fk_column: Option<&str>,
494 _param: &str,
495 ) -> Result<String, UnsupportedOperator> {
496 Err(UnsupportedOperator {
497 dialect: self.name(),
498 operator: "LTreeIdSubquery",
499 })
500 }
501
502 /// Generate SQL for `ltree = lca(ARRAY[...])` (Lca operator).
503 ///
504 /// `placeholders` contains pre-formatted placeholder strings
505 /// (e.g., `["$1::ltree", "$2::ltree"]`).
506 ///
507 /// # Errors
508 ///
509 /// Returns [`UnsupportedOperator`] if this dialect does not support LTree LCA.
510 fn ltree_lca_sql(
511 &self,
512 _lhs: &str,
513 _placeholders: &[String],
514 ) -> Result<String, UnsupportedOperator> {
515 Err(UnsupportedOperator {
516 dialect: self.name(),
517 operator: "Lca",
518 })
519 }
520
521 // ── Row-view DDL helpers (for gRPC transport) ──────────────────────────────
522
523 /// Generate a SQL expression that extracts a scalar field from a JSON column
524 /// and casts it to the given [`RowViewColumnType`].
525 ///
526 /// `json_column` is the unquoted column name (e.g., `"data"`).
527 /// `field_name` is the JSON key to extract.
528 /// `col_type` selects the target SQL type.
529 ///
530 /// Default: panics — each dialect must override.
531 fn row_view_column_expr(
532 &self,
533 json_column: &str,
534 field_name: &str,
535 col_type: &RowViewColumnType,
536 ) -> String {
537 let _ = (json_column, field_name, col_type);
538 // Reason: this is a default-impl panic that fires only if a dialect
539 // forgets to override; that's a compile-time-discoverable programmer
540 // error, not user input, so a panic on first call is the right
541 // failure mode.
542 #[allow(clippy::panic)]
543 {
544 panic!("{} dialect has not implemented row_view_column_expr", self.name())
545 }
546 }
547
548 /// Generate the full DDL statement(s) to create a row-shaped view.
549 ///
550 /// `view_name` is the unquoted view name (e.g., `"vr_user"`).
551 /// `source_table` is the unquoted source table (e.g., `"tb_user"`).
552 /// `columns` are `(alias, expression)` pairs from [`Self::row_view_column_expr`].
553 ///
554 /// Default: `CREATE OR REPLACE VIEW "view" AS SELECT ... FROM "table"`.
555 fn create_row_view_ddl(
556 &self,
557 view_name: &str,
558 source_table: &str,
559 columns: &[(String, String)],
560 ) -> String {
561 let quoted_view = self.quote_identifier(view_name);
562 let quoted_table = self.quote_identifier(source_table);
563 let col_list: Vec<String> = columns
564 .iter()
565 .map(|(alias, expr)| format!("{expr} AS {}", self.quote_identifier(alias)))
566 .collect();
567 format!(
568 "CREATE OR REPLACE VIEW {quoted_view} AS\nSELECT\n {}\nFROM {quoted_table};",
569 col_list.join(",\n ")
570 )
571 }
572}