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