Skip to main content

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}