Skip to main content

fraiseql_db/where_generator/
generic.rs

1//! Generic WHERE clause generator parameterised over a SQL dialect.
2
3use fraiseql_error::{FraiseQLError, Result};
4
5use super::counter::ParamCounter;
6use crate::{
7    dialect::SqlDialect,
8    types::sql_hints::ScalarFieldType,
9    where_clause::{FieldTypeMap, WhereClause, WhereOperator, field_types::operand_type},
10};
11
12/// The two renderings of `value` as a UUID — as the client wrote it, and the
13/// canonical lower-case form — or `None` when they would be identical or the
14/// value is not a hyphenated UUID.
15///
16/// Returning `None` for an already-canonical literal is what keeps the common
17/// path byte-identical: the generated SQL only grows the `ANY(ARRAY[…])` form
18/// when the client actually sent a differently-cased UUID.
19///
20/// The hyphenated-shape check matters. `Uuid::parse_str` also accepts braced,
21/// URN and simple forms, and rewriting one of those to canonical would change
22/// what the filter compares rather than merely its case — so only inputs that
23/// differ from canonical **by case alone** are treated as case variants.
24fn uuid_case_variants(value: &serde_json::Value) -> Option<(String, String)> {
25    let raw = value.as_str()?;
26    let canonical = uuid::Uuid::parse_str(raw).ok()?.hyphenated().to_string();
27    if raw == canonical || !raw.eq_ignore_ascii_case(&canonical) {
28        return None;
29    }
30    Some((raw.to_string(), canonical))
31}
32
33/// The left-hand side of a pgvector distance predicate, and whether it already
34/// carries a pgvector type (#1117).
35///
36/// Which of the two a predicate gets is the whole of #1117: reading the JSONB
37/// text costs a per-row parse of every embedding in the relation — 2667 ms
38/// against the native column's 22 ms for the same 400 matching rows.
39enum VectorOperand<'a> {
40    /// A native `vector(N)` / `halfvec(N)` / `sparsevec(N)` / `bit(N)` column,
41    /// quoted. Rendered **bare**: the column already has its type, and casting
42    /// it to whichever pgvector type the literal happens to use would both lose
43    /// precision and make the ANN index ineligible.
44    ///
45    /// The reverse direction — casting the *literal* to `vector` against a
46    /// `halfvec` column — is safe and is what the ORDER BY path already does:
47    /// PostgreSQL resolves the literal to `halfvec` and picks the same
48    /// `halfvec_*_ops` index (see [`VectorOperandKind`]).
49    ///
50    /// [`VectorOperandKind`]: crate::types::sql_hints::VectorOperandKind
51    Native(&'a str),
52    /// A JSONB extraction, which is `text` and therefore has no pgvector
53    /// operator until it is cast to the operand's kind.
54    Json(&'a str),
55}
56
57impl VectorOperand<'_> {
58    /// This operand rendered for a comparison against a literal of pgvector type
59    /// `cast` (`vector`, `sparsevec`, `varbit`).
60    fn render(&self, cast: &str) -> String {
61        match *self {
62            Self::Native(column) => column.to_string(),
63            // Parenthesised before the cast: `data->>'f'::vector` parses as
64            // `data->>('f'::vector)`, because `::` binds tighter than `->>`.
65            Self::Json(expr) => format!("({expr})::{cast}"),
66        }
67    }
68}
69
70/// Escape LIKE metacharacters (`%`, `_`, `\`) in a user-supplied string so
71/// that it is treated as a literal substring inside a LIKE/ILIKE pattern.
72///
73/// Order matters: `\` is escaped first to avoid double-escaping.
74pub(crate) fn escape_like_literal(s: &str) -> String {
75    s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
76}
77
78/// Maximum allowed length for user-supplied regex patterns.
79///
80/// PostgreSQL has no built-in regex timeout, so excessively long patterns
81/// or patterns with nested quantifiers can cause CPU exhaustion (ReDoS).
82const MAX_REGEX_PATTERN_LEN: usize = 1_000;
83
84/// Validate a user-supplied regex pattern for obvious ReDoS risks.
85///
86/// Rejects:
87/// - Patterns exceeding `MAX_REGEX_PATTERN_LEN` bytes
88/// - Patterns containing nested quantifiers (e.g., `(a+)+`, `(a*)*`, `(a+)*`)
89///
90/// This is not a full ReDoS detector but catches the most common attack vectors.
91fn validate_regex_pattern(pattern: &str) -> Result<()> {
92    if pattern.len() > MAX_REGEX_PATTERN_LEN {
93        return Err(FraiseQLError::Validation {
94            message: format!(
95                "Regex pattern exceeds maximum length of {MAX_REGEX_PATTERN_LEN} bytes"
96            ),
97            path:    None,
98        });
99    }
100
101    // Detect nested quantifiers: a quantifier (+, *, ?, {n}) immediately after
102    // a closing paren that itself follows a quantifier. Simplified heuristic:
103    // look for `)` followed by a quantifier, where the group contains a quantifier.
104    let bytes = pattern.as_bytes();
105    let mut depth: i32 = 0;
106    let mut group_has_quantifier = Vec::new(); // stack: does current group have a quantifier?
107
108    for (i, &b) in bytes.iter().enumerate() {
109        // Skip escaped characters
110        if i > 0 && bytes[i - 1] == b'\\' {
111            continue;
112        }
113        match b {
114            b'(' => {
115                depth += 1;
116                group_has_quantifier.push(false);
117            },
118            b')' => {
119                let had_quantifier = group_has_quantifier.pop().unwrap_or(false);
120                depth -= 1;
121                // Check if a quantifier follows this closing paren
122                if had_quantifier {
123                    let next = bytes.get(i + 1).copied();
124                    if matches!(next, Some(b'+' | b'*' | b'?' | b'{')) {
125                        return Err(FraiseQLError::Validation {
126                            message: "Regex pattern contains nested quantifiers (potential \
127                                      ReDoS). Simplify the pattern to avoid `(…+)+`, \
128                                      `(…*)*`, or similar constructs."
129                                .to_string(),
130                            path:    None,
131                        });
132                    }
133                }
134            },
135            b'+' | b'*' | b'?' => {
136                if let Some(flag) = group_has_quantifier.last_mut() {
137                    *flag = true;
138                }
139            },
140            b'{' if depth > 0 => {
141                if let Some(flag) = group_has_quantifier.last_mut() {
142                    *flag = true;
143                }
144            },
145            _ => {},
146        }
147    }
148
149    Ok(())
150}
151
152/// Generic WHERE clause SQL generator.
153///
154/// Replaces `PostgresWhereGenerator`, `MySqlWhereGenerator`,
155/// `SqliteWhereGenerator`, and `SqlServerWhereGenerator` — all dialect-specific
156/// primitives are delegated to `D: SqlDialect`.
157///
158/// # Interior mutability
159///
160/// The parameter counter uses `Cell<usize>` (via `ParamCounter`).  This is
161/// safe because:
162/// - `GenericWhereGenerator` is not `Sync` — no concurrent access is possible.
163/// - `generate()` resets the counter before every call.
164///
165/// # Example
166///
167/// ```rust
168/// use fraiseql_db::dialect::PostgresDialect;
169/// use fraiseql_db::where_generator::GenericWhereGenerator;
170/// use fraiseql_db::{WhereClause, WhereOperator};
171/// use serde_json::json;
172///
173/// let gen = GenericWhereGenerator::new(PostgresDialect);
174/// let clause = WhereClause::Field {
175///     path: vec!["email".to_string()],
176///     operator: WhereOperator::Eq,
177///     value: json!("alice@example.com"),
178/// };
179/// let (sql, params) = gen.generate(&clause).unwrap();
180/// assert_eq!(sql, "data->>'email' = $1");
181/// ```
182pub struct GenericWhereGenerator<D: SqlDialect> {
183    dialect: D,
184    counter: ParamCounter,
185}
186
187impl<D: SqlDialect> GenericWhereGenerator<D> {
188    /// Create a new generator for the given dialect.
189    pub const fn new(dialect: D) -> Self {
190        Self {
191            dialect,
192            counter: ParamCounter::new(),
193        }
194    }
195
196    /// Generate SQL WHERE clause starting parameter numbering at 1.
197    ///
198    /// # Errors
199    ///
200    /// Returns `FraiseQLError::Validation` if the clause uses an operator
201    /// not supported by the dialect.
202    pub fn generate(&self, clause: &WhereClause) -> Result<(String, Vec<serde_json::Value>)> {
203        self.generate_with_param_offset(clause, 0)
204    }
205
206    /// Generate SQL WHERE clause with parameter numbering starting after `offset`.
207    ///
208    /// Use when the WHERE clause is appended to a query that already has bound
209    /// parameters (e.g. cursor values in relay pagination).
210    ///
211    /// # Errors
212    ///
213    /// Returns `FraiseQLError::Validation` if the clause uses an unsupported
214    /// operator.
215    pub fn generate_with_param_offset(
216        &self,
217        clause: &WhereClause,
218        offset: usize,
219    ) -> Result<(String, Vec<serde_json::Value>)> {
220        self.counter.reset_to(offset);
221        let mut params = Vec::new();
222        let sql = self.visit(clause, &mut params)?;
223        Ok((sql, params))
224    }
225
226    // ── Visitor ───────────────────────────────────────────────────────────────
227
228    fn visit(&self, clause: &WhereClause, params: &mut Vec<serde_json::Value>) -> Result<String> {
229        self.visit_impl(clause, params, None, None, &[])
230    }
231
232    fn visit_impl(
233        &self,
234        clause: &WhereClause,
235        params: &mut Vec<serde_json::Value>,
236        hierarchy_ctx: Option<&super::HierarchyContext>,
237        types: Option<&FieldTypeMap>,
238        guards: &[(&[String], &WhereClause)],
239    ) -> Result<String> {
240        match clause {
241            WhereClause::And(clauses) => {
242                if clauses.is_empty() {
243                    return Ok(self.dialect.always_true().to_string());
244                }
245                let parts: Result<Vec<_>> = clauses
246                    .iter()
247                    .map(|c| self.visit_impl(c, params, hierarchy_ctx, types, guards))
248                    .collect();
249                Ok(format!("({})", parts?.join(" AND ")))
250            },
251            WhereClause::Or(clauses) => {
252                if clauses.is_empty() {
253                    return Ok(self.dialect.always_false().to_string());
254                }
255                let parts: Result<Vec<_>> = clauses
256                    .iter()
257                    .map(|c| self.visit_impl(c, params, hierarchy_ctx, types, guards))
258                    .collect();
259                Ok(format!("({})", parts?.join(" OR ")))
260            },
261            WhereClause::Not(inner) => Ok(format!(
262                "NOT ({})",
263                self.visit_impl(inner, params, hierarchy_ctx, types, guards)?
264            )),
265            WhereClause::Typed {
266                types: subtree_types,
267                inner,
268            } => self.visit_impl(inner, params, hierarchy_ctx, Some(subtree_types), guards),
269            // #1396: the field's hierarchy, attached by the parser, scopes its leaf.
270            WhereClause::InHierarchy { context, inner } => {
271                self.visit_impl(inner, params, Some(context), types, guards)
272            },
273            // Ruling AH: a path through `under` reads its value only where `guard` holds.
274            WhereClause::Guarded {
275                under,
276                guard,
277                inner,
278            } => {
279                let mut scoped = guards.to_vec();
280                scoped.push((under.as_slice(), guard.as_ref()));
281                self.visit_impl(inner, params, hierarchy_ctx, types, &scoped)
282            },
283            WhereClause::Field {
284                path,
285                operator,
286                value,
287            } => self.visit_field(path, operator, value, params, hierarchy_ctx, types, guards),
288            WhereClause::NativeField {
289                column,
290                pg_cast,
291                operator,
292                value,
293            } => self.visit_native_field(column, pg_cast, operator, value, params),
294            // Ruling AL: uncorrelated, so the predicate's unqualified `data` is the view's row
295            // and the key's is this one.
296            WhereClause::KeyIn {
297                path,
298                key_type,
299                view,
300                target_key,
301                predicate,
302            } => {
303                let key = self
304                    .dialect
305                    .cast_expr_as(&self.dialect.json_extract_scalar("data", path), *key_type)
306                    .into_owned();
307                let target = self
308                    .dialect
309                    .cast_expr_as(&self.dialect.json_extract_scalar("data", target_key), *key_type)
310                    .into_owned();
311                let view = view
312                    .split('.')
313                    .map(|part| self.dialect.quote_identifier(part))
314                    .collect::<Vec<_>>()
315                    .join(".");
316                let predicate = self.visit_impl(predicate, params, None, None, &[])?;
317                Ok(format!("({key} IN (SELECT {target} FROM {view} WHERE {predicate}))"))
318            },
319        }
320    }
321
322    /// Generate SQL for a native-column condition.
323    ///
324    /// Emits `"column" = <cast>` where `<cast>` is a dialect-appropriate
325    /// expression (e.g. `$1::text::uuid` for PostgreSQL, `CAST(? AS CHAR)` for
326    /// MySQL) instead of the JSONB extraction path.
327    fn visit_native_field(
328        &self,
329        column: &str,
330        pg_cast: &str,
331        operator: &WhereOperator,
332        value: &serde_json::Value,
333        params: &mut Vec<serde_json::Value>,
334    ) -> Result<String> {
335        let col_expr = self.dialect.quote_identifier(column);
336        let p = self.push_param(params, value.clone());
337        let rhs = if pg_cast.is_empty() {
338            p
339        } else {
340            self.dialect.cast_native_param(&p, pg_cast)
341        };
342        match operator {
343            WhereOperator::Eq => Ok(format!("{col_expr} = {rhs}")),
344            WhereOperator::Neq => {
345                let neq = self.dialect.neq_operator();
346                Ok(format!("{col_expr} {neq} {rhs}"))
347            },
348            _ => Err(FraiseQLError::validation(format!(
349                "Operator {operator:?} is not supported for native column conditions"
350            ))),
351        }
352    }
353
354    // ── Field expression resolution ───────────────────────────────────────────
355
356    /// The native pgvector column `path` names, quoted — or `None` when this
357    /// path is not a declared vector field and the predicate must go through
358    /// JSONB (#1117).
359    ///
360    /// Three conditions, and each carries its own reason:
361    ///
362    /// * **The schema declares the path as a vector field.** That is the fact that makes the column
363    ///   *contractually present*: the storage contract requires the view to expose it, and
364    ///   `nearest` already refuses to lower without it. Nothing else here can tell whether a column
365    ///   exists.
366    /// * **The path is a single segment.** A vector field reached through a relation
367    ///   (`machine.embedding`) is a column of the *other* view; this relation's projection has no
368    ///   such column, and naming one would be a guess. Those keep the JSONB path.
369    /// * **The derived name is a bare identifier**, checked by the one shared derivation the ORDER
370    ///   BY lowering also uses.
371    ///
372    /// Note what the first condition also buys: the name is only used when it
373    /// **matched a key in the schema-derived cast map**, whose keys come from
374    /// the compiled type's own field list. So the identifier interpolated here
375    /// is one the schema published, not a string a client chose.
376    fn vector_native_column(
377        &self,
378        path: &[String],
379        types: Option<&FieldTypeMap>,
380    ) -> Option<String> {
381        if path.len() != 1 {
382            return None;
383        }
384        if types?.get(path)? != ScalarFieldType::Vector {
385            return None;
386        }
387        let column = crate::utils::vector_storage_column(path.first()?).ok()?;
388        Some(self.dialect.quote_identifier(&column))
389    }
390
391    /// `expr` read through every guard whose relation `path` runs through (ruling AH):
392    /// `(CASE WHEN <guards> THEN <expr> END)`, `NULL` where a guard fails — as the response
393    /// serves a related row the caller may not read. Unguarded, `expr` is unchanged, so the
394    /// SQL is byte-identical to a clause without guards.
395    fn guarded_expr(
396        &self,
397        expr: String,
398        path: &[String],
399        params: &mut Vec<serde_json::Value>,
400        guards: &[(&[String], &WhereClause)],
401    ) -> Result<String> {
402        let mut conditions = Vec::new();
403        for (under, guard) in guards {
404            if path.len() > under.len() && path.starts_with(under) {
405                conditions.push(self.visit_impl(guard, params, None, None, &[])?);
406            }
407        }
408        if conditions.is_empty() {
409            return Ok(expr);
410        }
411        Ok(format!("(CASE WHEN {} THEN {expr} END)", conditions.join(" AND ")))
412    }
413
414    fn resolve_field_expr(&self, path: &[String]) -> String {
415        self.dialect.json_extract_scalar("data", path)
416    }
417
418    // ── Push a parameter and return its placeholder ───────────────────────────
419
420    fn push_param(&self, params: &mut Vec<serde_json::Value>, v: serde_json::Value) -> String {
421        params.push(v);
422        self.dialect.placeholder(self.counter.next())
423    }
424
425    /// Vector threshold predicate (#386): `((expr)::vector <op> $v::vector) <= $t`.
426    ///
427    /// Operand shape: `{vector: [Float, …], threshold: Float}`.
428    ///
429    /// - For the distance operators (`<=>`, `<->`, `<+>`), `threshold` is the **maximum distance**:
430    ///   rows within it (inclusive) match.
431    /// - For inner product, `threshold` is the **minimum (raw) inner product**: rows at least that
432    ///   similar match. pgvector's `<#>` returns the *negated* inner product, so the comparison
433    ///   stays `<=` and the bound threshold value is negated instead (`ip >= t  ⇔  -ip <= -t`).
434    ///
435    /// The query vector binds as a **text** parameter (`$n::vector` — jsonb has
436    /// no cast to vector), built from the operand's numbers, so its dimension
437    /// and syntax are checked by pgvector itself. How the left-hand side is
438    /// rendered — bare native column or parenthesised-and-cast JSONB extraction
439    /// — is [`VectorOperand`]'s decision (#1117).
440    fn vector_threshold_sql(
441        &self,
442        field: &VectorOperand<'_>,
443        op: &str,
444        value: &serde_json::Value,
445        params: &mut Vec<serde_json::Value>,
446        negate_threshold: bool,
447    ) -> Result<String> {
448        let shape_err = || {
449            FraiseQLError::validation(
450                "vector operators take {vector: [Float, ...], threshold: Float} — e.g. \
451                 {cosine_distance: {vector: [0.1, 0.2], threshold: 0.5}}",
452            )
453        };
454        let obj = value.as_object().ok_or_else(shape_err)?;
455        let operand = obj.get("vector").ok_or_else(shape_err)?;
456        let threshold =
457            obj.get("threshold").and_then(serde_json::Value::as_f64).ok_or_else(shape_err)?;
458        if !threshold.is_finite() {
459            return Err(FraiseQLError::validation("vector threshold must be a finite number"));
460        }
461        // A sparse operand names itself: pgvector's `{1:0.5,7:0.25}/1000` text
462        // form is a string where a dense vector is an array, and casting it to
463        // `::vector` is an input-syntax error rather than a wrong answer. The
464        // shape of the operand picks the cast, exactly as it does for the
465        // binary operators (#959).
466        if let Some(sparse) = operand.as_str() {
467            return self.sparse_threshold_sql(
468                field,
469                op,
470                sparse,
471                threshold,
472                params,
473                negate_threshold,
474            );
475        }
476        let vector = operand.as_array().ok_or_else(shape_err)?;
477        if vector.is_empty() {
478            return Err(FraiseQLError::validation("vector operand must not be empty"));
479        }
480        let mut literal = String::with_capacity(vector.len() * 8 + 2);
481        literal.push('[');
482        for (i, component) in vector.iter().enumerate() {
483            let n = component.as_f64().filter(|n| n.is_finite()).ok_or_else(|| {
484                FraiseQLError::validation("vector operand components must all be finite numbers")
485            })?;
486            if i > 0 {
487                literal.push(',');
488            }
489            // Reason: fmt::Write for String is infallible
490            std::fmt::Write::write_fmt(&mut literal, format_args!("{n}"))
491                .expect("write to String is infallible");
492        }
493        literal.push(']');
494
495        let vector_param = self.push_param(params, serde_json::Value::String(literal));
496        let bound = if negate_threshold {
497            -threshold
498        } else {
499            threshold
500        };
501        let threshold_param = self.push_param(params, serde_json::json!(bound));
502        // `$n::text::vector`, not `$n::vector`: a bare `::vector` cast makes the
503        // prepared-statement protocol infer the *parameter type* as vector, and
504        // the driver binds text — a wire-format mismatch. The double cast pins
505        // the parameter as text and converts server-side.
506        // The threshold likewise goes through text (`::text::float8`): this
507        // crate's parameter convention sends JSON numbers as text (see
508        // `QueryParam::from`), so the placeholder must be inferred as text.
509        let lhs = field.render("vector");
510        Ok(format!(
511            "({lhs} {op} {vector_param}::text::vector) <= {threshold_param}::text::float8"
512        ))
513    }
514
515    /// Sparse-vector threshold predicate (#959):
516    /// `((expr)::sparsevec <=> $v::text::sparsevec) <= $t`.
517    ///
518    /// The operand is pgvector's own text form, `{index:value,…}/dimensions`
519    /// with 1-based indices. Its character set is re-validated here before it
520    /// binds, as it is for every other vector literal — it reaches the server as
521    /// a parameter, and the cast is what gives it a type.
522    ///
523    /// A dimension disagreement is `different sparsevec dimensions 5 and 4` from
524    /// the server, which is a refusal rather than a plausible number, so no
525    /// length check is needed on this path.
526    ///
527    /// `negate_threshold` carries the inner-product convention through
528    /// unchanged: pgvector's `<#>` returns the negated inner product on a sparse
529    /// vector exactly as it does on a dense one, so the bound value is negated
530    /// here too. Dropping it would make `inner_product` mean its own opposite on
531    /// this one operand shape.
532    #[allow(clippy::too_many_arguments)] // Reason: mirrors `vector_threshold_sql`'s operands
533    fn sparse_threshold_sql(
534        &self,
535        field: &VectorOperand<'_>,
536        op: &str,
537        sparse: &str,
538        threshold: f64,
539        params: &mut Vec<serde_json::Value>,
540        negate_threshold: bool,
541    ) -> Result<String> {
542        let valid = sparse.starts_with('{')
543            && sparse.contains("}/")
544            && sparse.chars().all(|c| {
545                c.is_ascii_digit()
546                    || matches!(c, '{' | '}' | '/' | ':' | ',' | '.' | '-' | '+' | 'e' | 'E')
547            });
548        if !valid {
549            return Err(FraiseQLError::validation(
550                "a sparse vector operand is pgvector's `{index:value,...}/dimensions` form — \
551                 e.g. {cosine_distance: {vector: \"{1:0.5,7:0.25}/1000\", threshold: 0.25}}",
552            ));
553        }
554        let vector_param = self.push_param(params, serde_json::Value::String(sparse.to_string()));
555        let bound = if negate_threshold {
556            -threshold
557        } else {
558            threshold
559        };
560        let threshold_param = self.push_param(params, serde_json::json!(bound));
561        let lhs = field.render("sparsevec");
562        Ok(format!(
563            "({lhs} {op} {vector_param}::text::sparsevec) <= {threshold_param}::text::float8"
564        ))
565    }
566
567    /// Binary-vector threshold predicate (#959):
568    /// `((expr)::varbit <~> $v::text::varbit) <= $t`.
569    ///
570    /// Operand shape: `{vector: "1011…", threshold: Float}` — the query vector
571    /// is the text form of a `bit(N)` value, which is what a `BitVector` field
572    /// carries in the JSONB payload and what `binary_quantize(…)::bit(N)`
573    /// produces. `threshold` is the maximum distance, inclusive, as for the
574    /// float metrics: hamming counts differing bits, jaccard is `1 -
575    /// |intersection| / |union|` over set bits.
576    ///
577    /// Both sides are cast to `varbit`, never to `bit`: `'1011'::bit` is
578    /// `bit(1)`, so the length-less cast would compare the first bit of the
579    /// column against the first bit of the operand and answer *0* for every
580    /// row that happens to start the same way. `varbit` keeps each side's own
581    /// width, and pgvector then refuses a width disagreement with
582    /// `different bit lengths 8 and 4` instead of returning a plausible number.
583    fn bit_threshold_sql(
584        &self,
585        field: &VectorOperand<'_>,
586        op: &str,
587        value: &serde_json::Value,
588        params: &mut Vec<serde_json::Value>,
589    ) -> Result<String> {
590        let shape_err = || {
591            FraiseQLError::validation(
592                "binary vector operators take {vector: \"1011…\", threshold: Float} — e.g. \
593                 {hamming_distance: {vector: \"11110000\", threshold: 2}}",
594            )
595        };
596        let obj = value.as_object().ok_or_else(shape_err)?;
597        let bits = obj.get("vector").and_then(serde_json::Value::as_str).ok_or_else(shape_err)?;
598        let threshold =
599            obj.get("threshold").and_then(serde_json::Value::as_f64).ok_or_else(shape_err)?;
600        if !threshold.is_finite() {
601            return Err(FraiseQLError::validation("vector threshold must be a finite number"));
602        }
603        if bits.is_empty() || !bits.chars().all(|c| matches!(c, '0' | '1')) {
604            return Err(FraiseQLError::validation(
605                "a binary vector operand must be a non-empty run of '0' and '1' characters",
606            ));
607        }
608
609        let vector_param = self.push_param(params, serde_json::Value::String(bits.to_string()));
610        let threshold_param = self.push_param(params, serde_json::json!(threshold));
611        // `$n::text::varbit` for the same reason `::text::vector` is used on the
612        // float path: a bare `::varbit` cast makes the prepared-statement
613        // protocol infer the parameter type as varbit while the driver binds
614        // text.
615        let lhs = field.render("varbit");
616        Ok(format!(
617            "({lhs} {op} {vector_param}::text::varbit) <= {threshold_param}::text::float8"
618        ))
619    }
620
621    // ── Field visitor ─────────────────────────────────────────────────────────
622
623    fn visit_field(
624        &self,
625        path: &[String],
626        operator: &WhereOperator,
627        value: &serde_json::Value,
628        params: &mut Vec<serde_json::Value>,
629        hierarchy_ctx: Option<&super::HierarchyContext>,
630        types: Option<&FieldTypeMap>,
631        guards: &[(&[String], &WhereClause)],
632    ) -> Result<String> {
633        let field_expr = self.guarded_expr(self.resolve_field_expr(path), path, params, guards)?;
634
635        match operator {
636            // ── Comparison ────────────────────────────────────────────────────
637            // One cast decision for every scalar comparison. Splitting it per
638            // operator is what left `Gt`/`Gte`/`Lt`/`Lte` casting unconditionally
639            // to numeric (#798) while `In`/`Nin` cast not at all (#800).
640            WhereOperator::Eq
641            | WhereOperator::Neq
642            | WhereOperator::Gt
643            | WhereOperator::Gte
644            | WhereOperator::Lt
645            | WhereOperator::Lte => {
646                let op = match operator {
647                    WhereOperator::Eq => "=",
648                    WhereOperator::Neq => self.dialect.neq_operator(),
649                    WhereOperator::Gt => ">",
650                    WhereOperator::Gte => ">=",
651                    WhereOperator::Lt => "<",
652                    _ => "<=",
653                };
654                let ty = operand_type(types, path, value);
655                let lhs = self.dialect.cast_expr_as(&field_expr, ty);
656
657                // Identity equality is case-insensitive over the two renderings
658                // of the same UUID (#F4). PostgreSQL emits `uuid` lower-case, so
659                // an upper-cased literal compared as text matched **zero rows**
660                // for a row that exists — a well-formed empty list, which is
661                // indistinguishable from "nothing matched".
662                //
663                // Only the *literal* is inspected, never the column, so this
664                // needs no knowledge of the backing type and cannot raise on a
665                // row holding a non-UUID identity. When the literal is already
666                // canonical the SQL is byte-identical to before.
667                if matches!(ty, ScalarFieldType::Uuid)
668                    && matches!(operator, WhereOperator::Eq | WhereOperator::Neq)
669                {
670                    if let Some((as_given, lowered)) = uuid_case_variants(value) {
671                        let a = self.push_param(params, serde_json::Value::String(as_given));
672                        let b = self.push_param(params, serde_json::Value::String(lowered));
673                        let quantifier = if matches!(operator, WhereOperator::Eq) {
674                            "ANY"
675                        } else {
676                            "ALL"
677                        };
678                        return Ok(format!("{lhs} {op} {quantifier}(ARRAY[{a}, {b}])"));
679                    }
680                }
681
682                let p = self.push_param(params, value.clone());
683                let rhs = self.dialect.cast_param_as(&p, ty);
684                Ok(format!("{lhs} {op} {rhs}"))
685            },
686
687            // ── Containment ───────────────────────────────────────────────────
688            WhereOperator::In | WhereOperator::Nin => {
689                let arr = value.as_array().ok_or_else(|| {
690                    FraiseQLError::validation("IN operator requires an array value".to_string())
691                })?;
692                if arr.is_empty() {
693                    return Ok(if matches!(operator, WhereOperator::In) {
694                        self.dialect.always_false().to_string()
695                    } else {
696                        self.dialect.always_true().to_string()
697                    });
698                }
699                let ty = operand_type(types, path, value);
700                let lhs = self.dialect.cast_expr_as(&field_expr, ty);
701                let placeholders: Vec<_> = arr
702                    .iter()
703                    .flat_map(|v| {
704                        // Same identity rule as `eq`, per element: a UUID
705                        // literal contributes both of its renderings.
706                        if matches!(ty, ScalarFieldType::Uuid) {
707                            if let Some((as_given, lowered)) = uuid_case_variants(v) {
708                                let a =
709                                    self.push_param(params, serde_json::Value::String(as_given));
710                                let b = self.push_param(params, serde_json::Value::String(lowered));
711                                return vec![a, b];
712                            }
713                        }
714                        let p = self.push_param(params, v.clone());
715                        vec![self.dialect.cast_param_as(&p, ty).into_owned()]
716                    })
717                    .collect();
718                let in_list = placeholders.join(", ");
719                let sql = format!("{lhs} IN ({in_list})");
720                Ok(if matches!(operator, WhereOperator::Nin) {
721                    format!("NOT ({sql})")
722                } else {
723                    sql
724                })
725            },
726
727            // ── NULL ──────────────────────────────────────────────────────────
728            WhereOperator::IsNull | WhereOperator::IsNotNull => {
729                let negated = matches!(operator, WhereOperator::IsNotNull);
730                // A non-boolean operand is a client error, not a licence to
731                // pick a default: `?deletedAt[is_null]=false` coerced to the
732                // field's declared type used to silently mean IS NULL (#828).
733                let asserted = match value {
734                    serde_json::Value::Bool(b) => *b,
735                    serde_json::Value::Null => true,
736                    other => {
737                        return Err(FraiseQLError::validation(format!(
738                            "{operator:?} requires a boolean operand, got {other}"
739                        )));
740                    },
741                };
742                let null_op = if asserted != negated {
743                    "IS NULL"
744                } else {
745                    "IS NOT NULL"
746                };
747                Ok(format!("{field_expr} {null_op}"))
748            },
749
750            // ── String: LIKE family ───────────────────────────────────────────
751            WhereOperator::Contains => {
752                let val_str = self.require_str(value, "Contains")?;
753                let escaped = escape_like_literal(val_str);
754                let p = self.push_param(params, serde_json::Value::String(escaped));
755                let pattern = self.dialect.concat_sql(&["'%'", &p, "'%'"]);
756                Ok(self.dialect.like_sql(&field_expr, &pattern))
757            },
758            WhereOperator::Icontains => {
759                let val_str = self.require_str(value, "Icontains")?;
760                let escaped = escape_like_literal(val_str);
761                let p = self.push_param(params, serde_json::Value::String(escaped));
762                let pattern = self.dialect.concat_sql(&["'%'", &p, "'%'"]);
763                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
764            },
765            WhereOperator::Startswith => {
766                let val_str = self.require_str(value, "Startswith")?;
767                let escaped = escape_like_literal(val_str);
768                let p = self.push_param(params, serde_json::Value::String(escaped));
769                let pattern = self.dialect.concat_sql(&[&p, "'%'"]);
770                Ok(self.dialect.like_sql(&field_expr, &pattern))
771            },
772            WhereOperator::Istartswith => {
773                let val_str = self.require_str(value, "Istartswith")?;
774                let escaped = escape_like_literal(val_str);
775                let p = self.push_param(params, serde_json::Value::String(escaped));
776                let pattern = self.dialect.concat_sql(&[&p, "'%'"]);
777                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
778            },
779            WhereOperator::Endswith => {
780                let val_str = self.require_str(value, "Endswith")?;
781                let escaped = escape_like_literal(val_str);
782                let p = self.push_param(params, serde_json::Value::String(escaped));
783                let pattern = self.dialect.concat_sql(&["'%'", &p]);
784                Ok(self.dialect.like_sql(&field_expr, &pattern))
785            },
786            WhereOperator::Iendswith => {
787                let val_str = self.require_str(value, "Iendswith")?;
788                let escaped = escape_like_literal(val_str);
789                let p = self.push_param(params, serde_json::Value::String(escaped));
790                let pattern = self.dialect.concat_sql(&["'%'", &p]);
791                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
792            },
793            WhereOperator::Like => {
794                let p = self.push_param(params, value.clone());
795                Ok(self.dialect.like_sql(&field_expr, &p))
796            },
797            WhereOperator::Ilike => {
798                let p = self.push_param(params, value.clone());
799                Ok(self.dialect.ilike_sql(&field_expr, &p))
800            },
801            WhereOperator::Nlike => {
802                let p = self.push_param(params, value.clone());
803                Ok(format!("NOT ({})", self.dialect.like_sql(&field_expr, &p)))
804            },
805            WhereOperator::Nilike => {
806                let p = self.push_param(params, value.clone());
807                Ok(format!("NOT ({})", self.dialect.ilike_sql(&field_expr, &p)))
808            },
809
810            // ── String: Regex ─────────────────────────────────────────────────
811            WhereOperator::Regex => {
812                if let Some(s) = value.as_str() {
813                    validate_regex_pattern(s)?;
814                }
815                let p = self.push_param(params, value.clone());
816                self.dialect
817                    .regex_sql(&field_expr, &p, false, false)
818                    .map_err(|e| FraiseQLError::validation(e.to_string()))
819            },
820            WhereOperator::Iregex => {
821                if let Some(s) = value.as_str() {
822                    validate_regex_pattern(s)?;
823                }
824                let p = self.push_param(params, value.clone());
825                self.dialect
826                    .regex_sql(&field_expr, &p, true, false)
827                    .map_err(|e| FraiseQLError::validation(e.to_string()))
828            },
829            WhereOperator::Nregex => {
830                if let Some(s) = value.as_str() {
831                    validate_regex_pattern(s)?;
832                }
833                let p = self.push_param(params, value.clone());
834                self.dialect
835                    .regex_sql(&field_expr, &p, false, true)
836                    .map_err(|e| FraiseQLError::validation(e.to_string()))
837            },
838            WhereOperator::Niregex => {
839                if let Some(s) = value.as_str() {
840                    validate_regex_pattern(s)?;
841                }
842                let p = self.push_param(params, value.clone());
843                self.dialect
844                    .regex_sql(&field_expr, &p, true, true)
845                    .map_err(|e| FraiseQLError::validation(e.to_string()))
846            },
847
848            // ── Array: length ─────────────────────────────────────────────────
849            WhereOperator::LenEq
850            | WhereOperator::LenNeq
851            | WhereOperator::LenGt
852            | WhereOperator::LenGte
853            | WhereOperator::LenLt
854            | WhereOperator::LenLte => {
855                let op = match operator {
856                    WhereOperator::LenEq => "=",
857                    WhereOperator::LenNeq => self.dialect.neq_operator(),
858                    WhereOperator::LenGt => ">",
859                    WhereOperator::LenGte => ">=",
860                    WhereOperator::LenLt => "<",
861                    _ => "<=",
862                };
863                let len_expr = self.dialect.json_array_length(&field_expr);
864                let p = self.push_param(params, value.clone());
865                Ok(format!("{len_expr} {op} {p}"))
866            },
867
868            // ── Array: containment ────────────────────────────────────────────
869            WhereOperator::ArrayContains | WhereOperator::StrictlyContains => {
870                // Both @> (ArrayContains) and @> (StrictlyContains, a JSONB-level
871                // strict containment) are routed to array_contains_sql.
872                let p = self.push_param(params, value.clone());
873                self.dialect
874                    .array_contains_sql(&field_expr, &p)
875                    .map_err(|e| FraiseQLError::validation(e.to_string()))
876            },
877            WhereOperator::ArrayContainedBy => {
878                let p = self.push_param(params, value.clone());
879                self.dialect
880                    .array_contained_by_sql(&field_expr, &p)
881                    .map_err(|e| FraiseQLError::validation(e.to_string()))
882            },
883            WhereOperator::ArrayOverlaps => {
884                let p = self.push_param(params, value.clone());
885                self.dialect
886                    .array_overlaps_sql(&field_expr, &p)
887                    .map_err(|e| FraiseQLError::validation(e.to_string()))
888            },
889
890            // ── Full-text search ──────────────────────────────────────────────
891            WhereOperator::Matches => {
892                let p = self.push_param(params, value.clone());
893                self.dialect
894                    .fts_matches_sql(&field_expr, &p)
895                    .map_err(|e| FraiseQLError::validation(e.to_string()))
896            },
897            WhereOperator::PlainQuery => {
898                let p = self.push_param(params, value.clone());
899                self.dialect
900                    .fts_plain_query_sql(&field_expr, &p)
901                    .map_err(|e| FraiseQLError::validation(e.to_string()))
902            },
903            WhereOperator::PhraseQuery => {
904                let p = self.push_param(params, value.clone());
905                self.dialect
906                    .fts_phrase_query_sql(&field_expr, &p)
907                    .map_err(|e| FraiseQLError::validation(e.to_string()))
908            },
909            WhereOperator::WebsearchQuery => {
910                let p = self.push_param(params, value.clone());
911                self.dialect
912                    .fts_websearch_query_sql(&field_expr, &p)
913                    .map_err(|e| FraiseQLError::validation(e.to_string()))
914            },
915
916            // ── Vector (pgvector) threshold predicates (#386) ─────────────────
917            // Operand: `{vector: [Float,…], threshold: Float}`; semantics and
918            // the three repairs over the original never-executable emission are
919            // documented on `vector_threshold_sql`.
920            //
921            // These six are the only operators that read the native column when
922            // the schema declares one (#1117). Deliberately not the others: an
923            // `eq` against `"embedding"` would compare a `vector` to a text
924            // literal, where `data->>'embedding' = '[1,0,0]'` is the text
925            // comparison the caller asked for. Only the distance operators have
926            // a pgvector operator to resolve.
927            WhereOperator::CosineDistance
928            | WhereOperator::L2Distance
929            | WhereOperator::L1Distance
930            | WhereOperator::InnerProduct => {
931                let native = self.vector_native_column(path, types);
932                let field = native
933                    .as_deref()
934                    .map_or(VectorOperand::Json(field_expr.as_str()), VectorOperand::Native);
935                let (op, negate) = match operator {
936                    WhereOperator::CosineDistance => ("<=>", false),
937                    WhereOperator::L2Distance => ("<->", false),
938                    WhereOperator::L1Distance => ("<+>", false),
939                    // Reason: the outer arm admits exactly these four operators.
940                    _ => ("<#>", true),
941                };
942                self.vector_threshold_sql(&field, op, value, params, negate)
943            },
944            // Hamming and Jaccard operate on pgvector's *binary* (`bit`)
945            // vectors, which a `BitVector` field declares (#959).
946            WhereOperator::HammingDistance | WhereOperator::JaccardDistance => {
947                let native = self.vector_native_column(path, types);
948                let field = native
949                    .as_deref()
950                    .map_or(VectorOperand::Json(field_expr.as_str()), VectorOperand::Native);
951                let op = if matches!(operator, WhereOperator::HammingDistance) {
952                    "<~>"
953                } else {
954                    "<%>"
955                };
956                self.bit_threshold_sql(&field, op, value, params)
957            },
958
959            // ── Network (INET/CIDR) ───────────────────────────────────────────
960            WhereOperator::IsIPv4 => {
961                // Value controls negation, like every sibling INET operator:
962                // `is_ipv4: false` asks to EXCLUDE IPv4 (#870.2).
963                let negate = value.as_bool().is_some_and(|v| !v);
964                let check_name = if negate { "IsNotIPv4" } else { "IsIPv4" };
965                self.dialect
966                    .inet_check_sql(&field_expr, check_name)
967                    .map_err(|e| FraiseQLError::validation(e.to_string()))
968            },
969            WhereOperator::IsIPv6 => {
970                let negate = value.as_bool().is_some_and(|v| !v);
971                let check_name = if negate { "IsNotIPv6" } else { "IsIPv6" };
972                self.dialect
973                    .inet_check_sql(&field_expr, check_name)
974                    .map_err(|e| FraiseQLError::validation(e.to_string()))
975            },
976            WhereOperator::IsPrivate => {
977                let negate = value.as_bool().is_some_and(|v| !v);
978                let check_name = if negate { "IsPublic" } else { "IsPrivate" };
979                self.dialect
980                    .inet_check_sql(&field_expr, check_name)
981                    .map_err(|e| FraiseQLError::validation(e.to_string()))
982            },
983            WhereOperator::IsLoopback => {
984                let negate = value.as_bool().is_some_and(|v| !v);
985                let check_name = if negate {
986                    "IsNotLoopback"
987                } else {
988                    "IsLoopback"
989                };
990                self.dialect
991                    .inet_check_sql(&field_expr, check_name)
992                    .map_err(|e| FraiseQLError::validation(e.to_string()))
993            },
994            WhereOperator::IsMulticast => {
995                let negate = value.as_bool().is_some_and(|v| !v);
996                let check_name = if negate {
997                    "IsNotMulticast"
998                } else {
999                    "IsMulticast"
1000                };
1001                self.dialect
1002                    .inet_check_sql(&field_expr, check_name)
1003                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1004            },
1005            WhereOperator::IsLinkLocal => {
1006                let negate = value.as_bool().is_some_and(|v| !v);
1007                let check_name = if negate {
1008                    "IsNotLinkLocal"
1009                } else {
1010                    "IsLinkLocal"
1011                };
1012                self.dialect
1013                    .inet_check_sql(&field_expr, check_name)
1014                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1015            },
1016            WhereOperator::IsDocumentation => {
1017                let negate = value.as_bool().is_some_and(|v| !v);
1018                let check_name = if negate {
1019                    "IsNotDocumentation"
1020                } else {
1021                    "IsDocumentation"
1022                };
1023                self.dialect
1024                    .inet_check_sql(&field_expr, check_name)
1025                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1026            },
1027            WhereOperator::IsCarrierGrade => {
1028                let negate = value.as_bool().is_some_and(|v| !v);
1029                let check_name = if negate {
1030                    "IsNotCarrierGrade"
1031                } else {
1032                    "IsCarrierGrade"
1033                };
1034                self.dialect
1035                    .inet_check_sql(&field_expr, check_name)
1036                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1037            },
1038            WhereOperator::InSubnet => {
1039                let p = self.push_param(params, value.clone());
1040                self.dialect
1041                    .inet_binary_sql("<<", &field_expr, &p)
1042                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1043            },
1044            WhereOperator::ContainsSubnet | WhereOperator::ContainsIP => {
1045                let p = self.push_param(params, value.clone());
1046                self.dialect
1047                    .inet_binary_sql(">>", &field_expr, &p)
1048                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1049            },
1050            WhereOperator::Overlaps => {
1051                let p = self.push_param(params, value.clone());
1052                self.dialect
1053                    .inet_binary_sql("&&", &field_expr, &p)
1054                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1055            },
1056
1057            // ── LTree ─────────────────────────────────────────────────────────
1058            WhereOperator::AncestorOf => {
1059                let p = self.push_param(params, value.clone());
1060                self.dialect
1061                    .ltree_binary_sql("@>", &field_expr, &p, "ltree")
1062                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1063            },
1064            WhereOperator::DescendantOf => {
1065                let p = self.push_param(params, value.clone());
1066                self.dialect
1067                    .ltree_binary_sql("<@", &field_expr, &p, "ltree")
1068                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1069            },
1070            WhereOperator::MatchesLquery => {
1071                let p = self.push_param(params, value.clone());
1072                self.dialect
1073                    .ltree_binary_sql("~", &field_expr, &p, "lquery")
1074                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1075            },
1076            WhereOperator::MatchesLtxtquery => {
1077                let p = self.push_param(params, value.clone());
1078                self.dialect
1079                    .ltree_binary_sql("@", &field_expr, &p, "ltxtquery")
1080                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1081            },
1082            WhereOperator::MatchesAnyLquery => {
1083                let arr = value.as_array().ok_or_else(|| {
1084                    FraiseQLError::validation(
1085                        "matches_any_lquery operator requires an array value".to_string(),
1086                    )
1087                })?;
1088                if arr.is_empty() {
1089                    return Err(FraiseQLError::validation(
1090                        "matches_any_lquery requires at least one lquery".to_string(),
1091                    ));
1092                }
1093                let placeholders: Vec<_> = arr
1094                    .iter()
1095                    .map(|v| format!("{}::lquery", self.push_param(params, v.clone())))
1096                    .collect();
1097                self.dialect
1098                    .ltree_any_lquery_sql(&field_expr, &placeholders)
1099                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1100            },
1101            WhereOperator::DepthEq => {
1102                let p = self.push_param(params, value.clone());
1103                self.dialect
1104                    .ltree_depth_sql("=", &field_expr, &p)
1105                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1106            },
1107            WhereOperator::DepthNeq => {
1108                let p = self.push_param(params, value.clone());
1109                self.dialect
1110                    .ltree_depth_sql("!=", &field_expr, &p)
1111                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1112            },
1113            WhereOperator::DepthGt => {
1114                let p = self.push_param(params, value.clone());
1115                self.dialect
1116                    .ltree_depth_sql(">", &field_expr, &p)
1117                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1118            },
1119            WhereOperator::DepthGte => {
1120                let p = self.push_param(params, value.clone());
1121                self.dialect
1122                    .ltree_depth_sql(">=", &field_expr, &p)
1123                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1124            },
1125            WhereOperator::DepthLt => {
1126                let p = self.push_param(params, value.clone());
1127                self.dialect
1128                    .ltree_depth_sql("<", &field_expr, &p)
1129                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1130            },
1131            WhereOperator::DepthLte => {
1132                let p = self.push_param(params, value.clone());
1133                self.dialect
1134                    .ltree_depth_sql("<=", &field_expr, &p)
1135                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1136            },
1137            WhereOperator::Lca => {
1138                let arr = value.as_array().ok_or_else(|| {
1139                    FraiseQLError::validation("lca operator requires an array value".to_string())
1140                })?;
1141                if arr.is_empty() {
1142                    return Err(FraiseQLError::validation(
1143                        "lca operator requires at least one path".to_string(),
1144                    ));
1145                }
1146                let placeholders: Vec<_> = arr
1147                    .iter()
1148                    .map(|v| format!("{}::ltree", self.push_param(params, v.clone())))
1149                    .collect();
1150                self.dialect
1151                    .ltree_lca_sql(&field_expr, &placeholders)
1152                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1153            },
1154
1155            // ── LTree ID-based operators ──────────────────────────────────────
1156            WhereOperator::DescendantOfId | WhereOperator::AncestorOfId => {
1157                let ctx = hierarchy_ctx.ok_or_else(|| {
1158                    FraiseQLError::validation(
1159                        "descendantOfId/ancestorOfId reached SQL generation without its \
1160                         field's hierarchy: the clause was built outside the `where` parser, \
1161                         which attaches it (WhereClause::InHierarchy)"
1162                            .to_string(),
1163                    )
1164                })?;
1165                let pg_op = if matches!(operator, WhereOperator::DescendantOfId) {
1166                    "<@"
1167                } else {
1168                    "@>"
1169                };
1170                // The hierarchy table's `id` is a UUID (trinity pattern); an uncast text
1171                // parameter against it is rejected by the driver's binary bind.
1172                let p = self.push_param(params, value.clone());
1173                let p = self.dialect.cast_native_param(&p, "uuid");
1174                self.dialect
1175                    .ltree_id_subquery_sql(
1176                        pg_op,
1177                        &field_expr,
1178                        &ctx.table,
1179                        &ctx.path_column,
1180                        ctx.fk_column.as_deref(),
1181                        &p,
1182                    )
1183                    .map_err(|e| FraiseQLError::validation(e.to_string()))
1184            },
1185
1186            // ── Unknown / future operators ────────────────────────────────────
1187            // This arm is only reachable if WhereOperator gains new variants
1188            // (it is #[non_exhaustive]).  Suppress the lint that fires when all
1189            // current variants are already matched above.
1190            #[allow(unreachable_patterns)]
1191            // Reason: defensive catch-all for future non_exhaustive variants
1192            _ => Err(FraiseQLError::Validation {
1193                message: format!(
1194                    "Operator {operator:?} is not supported by the {} dialect",
1195                    self.dialect.name()
1196                ),
1197                path:    None,
1198            }),
1199        }
1200    }
1201
1202    fn require_str<'a>(&self, value: &'a serde_json::Value, op: &'static str) -> Result<&'a str> {
1203        value.as_str().ok_or_else(|| {
1204            FraiseQLError::validation(format!("{op} operator requires a string value"))
1205        })
1206    }
1207}
1208
1209// ── Default impl ──────────────────────────────────────────────────────────────
1210
1211impl<D: SqlDialect + Default> Default for GenericWhereGenerator<D> {
1212    fn default() -> Self {
1213        Self::new(D::default())
1214    }
1215}
1216
1217#[cfg(test)]
1218mod tests;