Skip to main content

fraiseql_db/where_generator/
generic.rs

1//! Generic WHERE clause generator parameterised over a SQL dialect.
2
3use std::{collections::HashSet, sync::Arc};
4
5use fraiseql_error::{FraiseQLError, Result};
6
7use super::counter::ParamCounter;
8use crate::{
9    dialect::SqlDialect,
10    where_clause::{WhereClause, WhereOperator},
11};
12
13/// Escape LIKE metacharacters (`%`, `_`, `\`) in a user-supplied string so
14/// that it is treated as a literal substring inside a LIKE/ILIKE pattern.
15///
16/// Order matters: `\` is escaped first to avoid double-escaping.
17pub(crate) fn escape_like_literal(s: &str) -> String {
18    s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
19}
20
21/// Maximum allowed length for user-supplied regex patterns.
22///
23/// PostgreSQL has no built-in regex timeout, so excessively long patterns
24/// or patterns with nested quantifiers can cause CPU exhaustion (ReDoS).
25const MAX_REGEX_PATTERN_LEN: usize = 1_000;
26
27/// Validate a user-supplied regex pattern for obvious ReDoS risks.
28///
29/// Rejects:
30/// - Patterns exceeding `MAX_REGEX_PATTERN_LEN` bytes
31/// - Patterns containing nested quantifiers (e.g., `(a+)+`, `(a*)*`, `(a+)*`)
32///
33/// This is not a full ReDoS detector but catches the most common attack vectors.
34fn validate_regex_pattern(pattern: &str) -> Result<()> {
35    if pattern.len() > MAX_REGEX_PATTERN_LEN {
36        return Err(FraiseQLError::Validation {
37            message: format!(
38                "Regex pattern exceeds maximum length of {MAX_REGEX_PATTERN_LEN} bytes"
39            ),
40            path:    None,
41        });
42    }
43
44    // Detect nested quantifiers: a quantifier (+, *, ?, {n}) immediately after
45    // a closing paren that itself follows a quantifier. Simplified heuristic:
46    // look for `)` followed by a quantifier, where the group contains a quantifier.
47    let bytes = pattern.as_bytes();
48    let mut depth: i32 = 0;
49    let mut group_has_quantifier = Vec::new(); // stack: does current group have a quantifier?
50
51    for (i, &b) in bytes.iter().enumerate() {
52        // Skip escaped characters
53        if i > 0 && bytes[i - 1] == b'\\' {
54            continue;
55        }
56        match b {
57            b'(' => {
58                depth += 1;
59                group_has_quantifier.push(false);
60            },
61            b')' => {
62                let had_quantifier = group_has_quantifier.pop().unwrap_or(false);
63                depth -= 1;
64                // Check if a quantifier follows this closing paren
65                if had_quantifier {
66                    let next = bytes.get(i + 1).copied();
67                    if matches!(next, Some(b'+' | b'*' | b'?' | b'{')) {
68                        return Err(FraiseQLError::Validation {
69                            message: "Regex pattern contains nested quantifiers (potential \
70                                      ReDoS). Simplify the pattern to avoid `(…+)+`, \
71                                      `(…*)*`, or similar constructs."
72                                .to_string(),
73                            path:    None,
74                        });
75                    }
76                }
77            },
78            b'+' | b'*' | b'?' => {
79                if let Some(flag) = group_has_quantifier.last_mut() {
80                    *flag = true;
81                }
82            },
83            b'{' if depth > 0 => {
84                if let Some(flag) = group_has_quantifier.last_mut() {
85                    *flag = true;
86                }
87            },
88            _ => {},
89        }
90    }
91
92    Ok(())
93}
94
95/// Generic WHERE clause SQL generator.
96///
97/// Replaces `PostgresWhereGenerator`, `MySqlWhereGenerator`,
98/// `SqliteWhereGenerator`, and `SqlServerWhereGenerator` — all dialect-specific
99/// primitives are delegated to `D: SqlDialect`.
100///
101/// # Interior mutability
102///
103/// The parameter counter uses `Cell<usize>` (via `ParamCounter`).  This is
104/// safe because:
105/// - `GenericWhereGenerator` is not `Sync` — no concurrent access is possible.
106/// - `generate()` resets the counter before every call.
107///
108/// # Example
109///
110/// ```rust
111/// use fraiseql_db::dialect::PostgresDialect;
112/// use fraiseql_db::where_generator::GenericWhereGenerator;
113/// use fraiseql_db::{WhereClause, WhereOperator};
114/// use serde_json::json;
115///
116/// let gen = GenericWhereGenerator::new(PostgresDialect);
117/// let clause = WhereClause::Field {
118///     path: vec!["email".to_string()],
119///     operator: WhereOperator::Eq,
120///     value: json!("alice@example.com"),
121/// };
122/// let (sql, params) = gen.generate(&clause).unwrap();
123/// assert_eq!(sql, "data->>'email' = $1");
124/// ```
125pub struct GenericWhereGenerator<D: SqlDialect> {
126    dialect:         D,
127    counter:         ParamCounter,
128    /// Optional indexed-column set (PostgreSQL optimisation: short-circuits JSONB
129    /// extraction when a generated column covers the path).
130    indexed_columns: Option<Arc<HashSet<String>>>,
131}
132
133impl<D: SqlDialect> GenericWhereGenerator<D> {
134    /// Create a new generator for the given dialect.
135    pub const fn new(dialect: D) -> Self {
136        Self {
137            dialect,
138            counter: ParamCounter::new(),
139            indexed_columns: None,
140        }
141    }
142
143    /// Attach an indexed-columns set (PostgreSQL optimisation).
144    ///
145    /// When a WHERE path matches a column name in this set, the generator
146    /// emits `"col_name" = $N` instead of `data->>'col_name' = $N`.
147    #[must_use]
148    pub fn with_indexed_columns(mut self, cols: Arc<HashSet<String>>) -> Self {
149        self.indexed_columns = Some(cols);
150        self
151    }
152
153    /// Generate SQL WHERE clause starting parameter numbering at 1.
154    ///
155    /// # Errors
156    ///
157    /// Returns `FraiseQLError::Validation` if the clause uses an operator
158    /// not supported by the dialect.
159    pub fn generate(&self, clause: &WhereClause) -> Result<(String, Vec<serde_json::Value>)> {
160        self.generate_with_param_offset(clause, 0)
161    }
162
163    /// Generate SQL WHERE clause with hierarchy context for ID-based ltree operators.
164    ///
165    /// The `hierarchy_ctx` provides metadata (`table`, `path_column`, `fk_column`)
166    /// needed by `DescendantOfId` / `AncestorOfId` operators to generate the
167    /// correct subquery SQL.
168    ///
169    /// # Errors
170    ///
171    /// Returns `FraiseQLError::Validation` if the clause uses an unsupported
172    /// operator or the hierarchy context is missing for an ID-based operator.
173    pub fn generate_with_hierarchy(
174        &self,
175        clause: &WhereClause,
176        hierarchy_ctx: &super::HierarchyContext,
177    ) -> Result<(String, Vec<serde_json::Value>)> {
178        self.counter.reset_to(0);
179        let mut params = Vec::new();
180        let sql = self.visit_impl(clause, &mut params, Some(hierarchy_ctx))?;
181        Ok((sql, params))
182    }
183
184    /// Generate SQL WHERE clause with parameter numbering starting after `offset`.
185    ///
186    /// Use when the WHERE clause is appended to a query that already has bound
187    /// parameters (e.g. cursor values in relay pagination).
188    ///
189    /// # Errors
190    ///
191    /// Returns `FraiseQLError::Validation` if the clause uses an unsupported
192    /// operator.
193    pub fn generate_with_param_offset(
194        &self,
195        clause: &WhereClause,
196        offset: usize,
197    ) -> Result<(String, Vec<serde_json::Value>)> {
198        self.counter.reset_to(offset);
199        let mut params = Vec::new();
200        let sql = self.visit(clause, &mut params)?;
201        Ok((sql, params))
202    }
203
204    // ── Visitor ───────────────────────────────────────────────────────────────
205
206    fn visit(&self, clause: &WhereClause, params: &mut Vec<serde_json::Value>) -> Result<String> {
207        self.visit_impl(clause, params, None)
208    }
209
210    fn visit_impl(
211        &self,
212        clause: &WhereClause,
213        params: &mut Vec<serde_json::Value>,
214        hierarchy_ctx: Option<&super::HierarchyContext>,
215    ) -> Result<String> {
216        match clause {
217            WhereClause::And(clauses) => {
218                if clauses.is_empty() {
219                    return Ok(self.dialect.always_true().to_string());
220                }
221                let parts: Result<Vec<_>> =
222                    clauses.iter().map(|c| self.visit_impl(c, params, hierarchy_ctx)).collect();
223                Ok(format!("({})", parts?.join(" AND ")))
224            },
225            WhereClause::Or(clauses) => {
226                if clauses.is_empty() {
227                    return Ok(self.dialect.always_false().to_string());
228                }
229                let parts: Result<Vec<_>> =
230                    clauses.iter().map(|c| self.visit_impl(c, params, hierarchy_ctx)).collect();
231                Ok(format!("({})", parts?.join(" OR ")))
232            },
233            WhereClause::Not(inner) => {
234                Ok(format!("NOT ({})", self.visit_impl(inner, params, hierarchy_ctx)?))
235            },
236            WhereClause::Field {
237                path,
238                operator,
239                value,
240            } => self.visit_field(path, operator, value, params, hierarchy_ctx),
241            WhereClause::NativeField {
242                column,
243                pg_cast,
244                operator,
245                value,
246            } => self.visit_native_field(column, pg_cast, operator, value, params),
247        }
248    }
249
250    /// Generate SQL for a native-column condition.
251    ///
252    /// Emits `"column" = <cast>` where `<cast>` is a dialect-appropriate
253    /// expression (e.g. `$1::text::uuid` for PostgreSQL, `CAST(? AS CHAR)` for
254    /// MySQL) instead of the JSONB extraction path.
255    fn visit_native_field(
256        &self,
257        column: &str,
258        pg_cast: &str,
259        operator: &WhereOperator,
260        value: &serde_json::Value,
261        params: &mut Vec<serde_json::Value>,
262    ) -> Result<String> {
263        let col_expr = self.dialect.quote_identifier(column);
264        let p = self.push_param(params, value.clone());
265        let rhs = if pg_cast.is_empty() {
266            p
267        } else {
268            self.dialect.cast_native_param(&p, pg_cast)
269        };
270        match operator {
271            WhereOperator::Eq => Ok(format!("{col_expr} = {rhs}")),
272            WhereOperator::Neq => {
273                let neq = self.dialect.neq_operator();
274                Ok(format!("{col_expr} {neq} {rhs}"))
275            },
276            _ => Err(FraiseQLError::validation(format!(
277                "Operator {operator:?} is not supported for native column conditions"
278            ))),
279        }
280    }
281
282    // ── Field expression resolution ───────────────────────────────────────────
283
284    fn resolve_field_expr(&self, path: &[String]) -> String {
285        // PostgreSQL indexed-column optimisation.
286        if let Some(indexed) = &self.indexed_columns {
287            let col_name = path.join("__");
288            if indexed.contains(&col_name) {
289                return self.dialect.quote_identifier(&col_name);
290            }
291        }
292        self.dialect.json_extract_scalar("data", path)
293    }
294
295    // ── Push a parameter and return its placeholder ───────────────────────────
296
297    fn push_param(&self, params: &mut Vec<serde_json::Value>, v: serde_json::Value) -> String {
298        params.push(v);
299        self.dialect.placeholder(self.counter.next())
300    }
301
302    // ── Field visitor ─────────────────────────────────────────────────────────
303
304    fn visit_field(
305        &self,
306        path: &[String],
307        operator: &WhereOperator,
308        value: &serde_json::Value,
309        params: &mut Vec<serde_json::Value>,
310        hierarchy_ctx: Option<&super::HierarchyContext>,
311    ) -> Result<String> {
312        let field_expr = self.resolve_field_expr(path);
313
314        match operator {
315            // ── Comparison ────────────────────────────────────────────────────
316            WhereOperator::Eq => {
317                let p = self.push_param(params, value.clone());
318                if value.is_number() {
319                    let cast = self.dialect.cast_to_numeric(&field_expr);
320                    // Dialect-specific RHS cast: PostgreSQL uses (p::text)::numeric to
321                    // avoid wire-protocol type mismatch; other dialects pass p unchanged.
322                    let rhs = self.dialect.cast_param_numeric(&p);
323                    Ok(format!("{cast} = {rhs}"))
324                } else if value.is_boolean() {
325                    let cast = self.dialect.cast_to_boolean(&field_expr);
326                    Ok(format!("{cast} = {p}"))
327                } else {
328                    Ok(format!("{field_expr} = {p}"))
329                }
330            },
331            WhereOperator::Neq => {
332                let p = self.push_param(params, value.clone());
333                let neq = self.dialect.neq_operator();
334                if value.is_number() {
335                    let cast = self.dialect.cast_to_numeric(&field_expr);
336                    let rhs = self.dialect.cast_param_numeric(&p);
337                    Ok(format!("{cast} {neq} {rhs}"))
338                } else if value.is_boolean() {
339                    let cast = self.dialect.cast_to_boolean(&field_expr);
340                    Ok(format!("{cast} {neq} {p}"))
341                } else {
342                    Ok(format!("{field_expr} {neq} {p}"))
343                }
344            },
345            WhereOperator::Gt | WhereOperator::Gte | WhereOperator::Lt | WhereOperator::Lte => {
346                let op = match operator {
347                    WhereOperator::Gt => ">",
348                    WhereOperator::Gte => ">=",
349                    WhereOperator::Lt => "<",
350                    _ => "<=",
351                };
352                let cast = self.dialect.cast_to_numeric(&field_expr);
353                let p = self.push_param(params, value.clone());
354                let rhs = self.dialect.cast_param_numeric(&p);
355                Ok(format!("{cast} {op} {rhs}"))
356            },
357
358            // ── Containment ───────────────────────────────────────────────────
359            WhereOperator::In | WhereOperator::Nin => {
360                let arr = value.as_array().ok_or_else(|| {
361                    FraiseQLError::validation("IN operator requires an array value".to_string())
362                })?;
363                if arr.is_empty() {
364                    return Ok(if matches!(operator, WhereOperator::In) {
365                        self.dialect.always_false().to_string()
366                    } else {
367                        self.dialect.always_true().to_string()
368                    });
369                }
370                let placeholders: Vec<_> =
371                    arr.iter().map(|v| self.push_param(params, v.clone())).collect();
372                let in_list = placeholders.join(", ");
373                let sql = format!("{field_expr} IN ({in_list})");
374                Ok(if matches!(operator, WhereOperator::Nin) {
375                    format!("NOT ({sql})")
376                } else {
377                    sql
378                })
379            },
380
381            // ── NULL ──────────────────────────────────────────────────────────
382            WhereOperator::IsNull => {
383                let is_null = value.as_bool().unwrap_or(true);
384                let null_op = if is_null { "IS NULL" } else { "IS NOT NULL" };
385                Ok(format!("{field_expr} {null_op}"))
386            },
387
388            // ── String: LIKE family ───────────────────────────────────────────
389            WhereOperator::Contains => {
390                let val_str = self.require_str(value, "Contains")?;
391                let escaped = escape_like_literal(val_str);
392                let p = self.push_param(params, serde_json::Value::String(escaped));
393                let pattern = self.dialect.concat_sql(&["'%'", &p, "'%'"]);
394                Ok(self.dialect.like_sql(&field_expr, &pattern))
395            },
396            WhereOperator::Icontains => {
397                let val_str = self.require_str(value, "Icontains")?;
398                let escaped = escape_like_literal(val_str);
399                let p = self.push_param(params, serde_json::Value::String(escaped));
400                let pattern = self.dialect.concat_sql(&["'%'", &p, "'%'"]);
401                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
402            },
403            WhereOperator::Startswith => {
404                let val_str = self.require_str(value, "Startswith")?;
405                let escaped = escape_like_literal(val_str);
406                let p = self.push_param(params, serde_json::Value::String(escaped));
407                let pattern = self.dialect.concat_sql(&[&p, "'%'"]);
408                Ok(self.dialect.like_sql(&field_expr, &pattern))
409            },
410            WhereOperator::Istartswith => {
411                let val_str = self.require_str(value, "Istartswith")?;
412                let escaped = escape_like_literal(val_str);
413                let p = self.push_param(params, serde_json::Value::String(escaped));
414                let pattern = self.dialect.concat_sql(&[&p, "'%'"]);
415                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
416            },
417            WhereOperator::Endswith => {
418                let val_str = self.require_str(value, "Endswith")?;
419                let escaped = escape_like_literal(val_str);
420                let p = self.push_param(params, serde_json::Value::String(escaped));
421                let pattern = self.dialect.concat_sql(&["'%'", &p]);
422                Ok(self.dialect.like_sql(&field_expr, &pattern))
423            },
424            WhereOperator::Iendswith => {
425                let val_str = self.require_str(value, "Iendswith")?;
426                let escaped = escape_like_literal(val_str);
427                let p = self.push_param(params, serde_json::Value::String(escaped));
428                let pattern = self.dialect.concat_sql(&["'%'", &p]);
429                Ok(self.dialect.ilike_sql(&field_expr, &pattern))
430            },
431            WhereOperator::Like => {
432                let p = self.push_param(params, value.clone());
433                Ok(self.dialect.like_sql(&field_expr, &p))
434            },
435            WhereOperator::Ilike => {
436                let p = self.push_param(params, value.clone());
437                Ok(self.dialect.ilike_sql(&field_expr, &p))
438            },
439            WhereOperator::Nlike => {
440                let p = self.push_param(params, value.clone());
441                Ok(format!("NOT ({})", self.dialect.like_sql(&field_expr, &p)))
442            },
443            WhereOperator::Nilike => {
444                let p = self.push_param(params, value.clone());
445                Ok(format!("NOT ({})", self.dialect.ilike_sql(&field_expr, &p)))
446            },
447
448            // ── String: Regex ─────────────────────────────────────────────────
449            WhereOperator::Regex => {
450                if let Some(s) = value.as_str() {
451                    validate_regex_pattern(s)?;
452                }
453                let p = self.push_param(params, value.clone());
454                self.dialect
455                    .regex_sql(&field_expr, &p, false, false)
456                    .map_err(|e| FraiseQLError::validation(e.to_string()))
457            },
458            WhereOperator::Iregex => {
459                if let Some(s) = value.as_str() {
460                    validate_regex_pattern(s)?;
461                }
462                let p = self.push_param(params, value.clone());
463                self.dialect
464                    .regex_sql(&field_expr, &p, true, false)
465                    .map_err(|e| FraiseQLError::validation(e.to_string()))
466            },
467            WhereOperator::Nregex => {
468                if let Some(s) = value.as_str() {
469                    validate_regex_pattern(s)?;
470                }
471                let p = self.push_param(params, value.clone());
472                self.dialect
473                    .regex_sql(&field_expr, &p, false, true)
474                    .map_err(|e| FraiseQLError::validation(e.to_string()))
475            },
476            WhereOperator::Niregex => {
477                if let Some(s) = value.as_str() {
478                    validate_regex_pattern(s)?;
479                }
480                let p = self.push_param(params, value.clone());
481                self.dialect
482                    .regex_sql(&field_expr, &p, true, true)
483                    .map_err(|e| FraiseQLError::validation(e.to_string()))
484            },
485
486            // ── Array: length ─────────────────────────────────────────────────
487            WhereOperator::LenEq
488            | WhereOperator::LenNeq
489            | WhereOperator::LenGt
490            | WhereOperator::LenGte
491            | WhereOperator::LenLt
492            | WhereOperator::LenLte => {
493                let op = match operator {
494                    WhereOperator::LenEq => "=",
495                    WhereOperator::LenNeq => self.dialect.neq_operator(),
496                    WhereOperator::LenGt => ">",
497                    WhereOperator::LenGte => ">=",
498                    WhereOperator::LenLt => "<",
499                    _ => "<=",
500                };
501                let len_expr = self.dialect.json_array_length(&field_expr);
502                let p = self.push_param(params, value.clone());
503                Ok(format!("{len_expr} {op} {p}"))
504            },
505
506            // ── Array: containment ────────────────────────────────────────────
507            WhereOperator::ArrayContains | WhereOperator::StrictlyContains => {
508                // Both @> (ArrayContains) and @> (StrictlyContains, a JSONB-level
509                // strict containment) are routed to array_contains_sql.
510                let p = self.push_param(params, value.clone());
511                self.dialect
512                    .array_contains_sql(&field_expr, &p)
513                    .map_err(|e| FraiseQLError::validation(e.to_string()))
514            },
515            WhereOperator::ArrayContainedBy => {
516                let p = self.push_param(params, value.clone());
517                self.dialect
518                    .array_contained_by_sql(&field_expr, &p)
519                    .map_err(|e| FraiseQLError::validation(e.to_string()))
520            },
521            WhereOperator::ArrayOverlaps => {
522                let p = self.push_param(params, value.clone());
523                self.dialect
524                    .array_overlaps_sql(&field_expr, &p)
525                    .map_err(|e| FraiseQLError::validation(e.to_string()))
526            },
527
528            // ── Full-text search ──────────────────────────────────────────────
529            WhereOperator::Matches => {
530                let p = self.push_param(params, value.clone());
531                self.dialect
532                    .fts_matches_sql(&field_expr, &p)
533                    .map_err(|e| FraiseQLError::validation(e.to_string()))
534            },
535            WhereOperator::PlainQuery => {
536                let p = self.push_param(params, value.clone());
537                self.dialect
538                    .fts_plain_query_sql(&field_expr, &p)
539                    .map_err(|e| FraiseQLError::validation(e.to_string()))
540            },
541            WhereOperator::PhraseQuery => {
542                let p = self.push_param(params, value.clone());
543                self.dialect
544                    .fts_phrase_query_sql(&field_expr, &p)
545                    .map_err(|e| FraiseQLError::validation(e.to_string()))
546            },
547            WhereOperator::WebsearchQuery => {
548                let p = self.push_param(params, value.clone());
549                self.dialect
550                    .fts_websearch_query_sql(&field_expr, &p)
551                    .map_err(|e| FraiseQLError::validation(e.to_string()))
552            },
553
554            // ── Vector (pgvector) ─────────────────────────────────────────────
555            WhereOperator::CosineDistance => {
556                let p = self.push_param(params, value.clone());
557                self.dialect
558                    .vector_distance_sql("<=>", &field_expr, &p)
559                    .map_err(|e| FraiseQLError::validation(e.to_string()))
560            },
561            WhereOperator::L2Distance => {
562                let p = self.push_param(params, value.clone());
563                self.dialect
564                    .vector_distance_sql("<->", &field_expr, &p)
565                    .map_err(|e| FraiseQLError::validation(e.to_string()))
566            },
567            WhereOperator::L1Distance => {
568                let p = self.push_param(params, value.clone());
569                self.dialect
570                    .vector_distance_sql("<+>", &field_expr, &p)
571                    .map_err(|e| FraiseQLError::validation(e.to_string()))
572            },
573            WhereOperator::HammingDistance => {
574                let p = self.push_param(params, value.clone());
575                self.dialect
576                    .vector_distance_sql("<~>", &field_expr, &p)
577                    .map_err(|e| FraiseQLError::validation(e.to_string()))
578            },
579            WhereOperator::InnerProduct => {
580                let p = self.push_param(params, value.clone());
581                self.dialect
582                    .vector_distance_sql("<#>", &field_expr, &p)
583                    .map_err(|e| FraiseQLError::validation(e.to_string()))
584            },
585            WhereOperator::JaccardDistance => {
586                let p = self.push_param(params, value.clone());
587                self.dialect
588                    .jaccard_distance_sql(&field_expr, &p)
589                    .map_err(|e| FraiseQLError::validation(e.to_string()))
590            },
591
592            // ── Network (INET/CIDR) ───────────────────────────────────────────
593            WhereOperator::IsIPv4 => self
594                .dialect
595                .inet_check_sql(&field_expr, "IsIPv4")
596                .map_err(|e| FraiseQLError::validation(e.to_string())),
597            WhereOperator::IsIPv6 => self
598                .dialect
599                .inet_check_sql(&field_expr, "IsIPv6")
600                .map_err(|e| FraiseQLError::validation(e.to_string())),
601            WhereOperator::IsPrivate => {
602                let negate = value.as_bool().is_some_and(|v| !v);
603                let check_name = if negate { "IsPublic" } else { "IsPrivate" };
604                self.dialect
605                    .inet_check_sql(&field_expr, check_name)
606                    .map_err(|e| FraiseQLError::validation(e.to_string()))
607            },
608            WhereOperator::IsLoopback => {
609                let negate = value.as_bool().is_some_and(|v| !v);
610                let check_name = if negate {
611                    "IsNotLoopback"
612                } else {
613                    "IsLoopback"
614                };
615                self.dialect
616                    .inet_check_sql(&field_expr, check_name)
617                    .map_err(|e| FraiseQLError::validation(e.to_string()))
618            },
619            WhereOperator::IsMulticast => {
620                let negate = value.as_bool().is_some_and(|v| !v);
621                let check_name = if negate {
622                    "IsNotMulticast"
623                } else {
624                    "IsMulticast"
625                };
626                self.dialect
627                    .inet_check_sql(&field_expr, check_name)
628                    .map_err(|e| FraiseQLError::validation(e.to_string()))
629            },
630            WhereOperator::IsLinkLocal => {
631                let negate = value.as_bool().is_some_and(|v| !v);
632                let check_name = if negate {
633                    "IsNotLinkLocal"
634                } else {
635                    "IsLinkLocal"
636                };
637                self.dialect
638                    .inet_check_sql(&field_expr, check_name)
639                    .map_err(|e| FraiseQLError::validation(e.to_string()))
640            },
641            WhereOperator::IsDocumentation => {
642                let negate = value.as_bool().is_some_and(|v| !v);
643                let check_name = if negate {
644                    "IsNotDocumentation"
645                } else {
646                    "IsDocumentation"
647                };
648                self.dialect
649                    .inet_check_sql(&field_expr, check_name)
650                    .map_err(|e| FraiseQLError::validation(e.to_string()))
651            },
652            WhereOperator::IsCarrierGrade => {
653                let negate = value.as_bool().is_some_and(|v| !v);
654                let check_name = if negate {
655                    "IsNotCarrierGrade"
656                } else {
657                    "IsCarrierGrade"
658                };
659                self.dialect
660                    .inet_check_sql(&field_expr, check_name)
661                    .map_err(|e| FraiseQLError::validation(e.to_string()))
662            },
663            WhereOperator::InSubnet => {
664                let p = self.push_param(params, value.clone());
665                self.dialect
666                    .inet_binary_sql("<<", &field_expr, &p)
667                    .map_err(|e| FraiseQLError::validation(e.to_string()))
668            },
669            WhereOperator::ContainsSubnet | WhereOperator::ContainsIP => {
670                let p = self.push_param(params, value.clone());
671                self.dialect
672                    .inet_binary_sql(">>", &field_expr, &p)
673                    .map_err(|e| FraiseQLError::validation(e.to_string()))
674            },
675            WhereOperator::Overlaps => {
676                let p = self.push_param(params, value.clone());
677                self.dialect
678                    .inet_binary_sql("&&", &field_expr, &p)
679                    .map_err(|e| FraiseQLError::validation(e.to_string()))
680            },
681
682            // ── LTree ─────────────────────────────────────────────────────────
683            WhereOperator::AncestorOf => {
684                let p = self.push_param(params, value.clone());
685                self.dialect
686                    .ltree_binary_sql("@>", &field_expr, &p, "ltree")
687                    .map_err(|e| FraiseQLError::validation(e.to_string()))
688            },
689            WhereOperator::DescendantOf => {
690                let p = self.push_param(params, value.clone());
691                self.dialect
692                    .ltree_binary_sql("<@", &field_expr, &p, "ltree")
693                    .map_err(|e| FraiseQLError::validation(e.to_string()))
694            },
695            WhereOperator::MatchesLquery => {
696                let p = self.push_param(params, value.clone());
697                self.dialect
698                    .ltree_binary_sql("~", &field_expr, &p, "lquery")
699                    .map_err(|e| FraiseQLError::validation(e.to_string()))
700            },
701            WhereOperator::MatchesLtxtquery => {
702                let p = self.push_param(params, value.clone());
703                self.dialect
704                    .ltree_binary_sql("@", &field_expr, &p, "ltxtquery")
705                    .map_err(|e| FraiseQLError::validation(e.to_string()))
706            },
707            WhereOperator::MatchesAnyLquery => {
708                let arr = value.as_array().ok_or_else(|| {
709                    FraiseQLError::validation(
710                        "matches_any_lquery operator requires an array value".to_string(),
711                    )
712                })?;
713                if arr.is_empty() {
714                    return Err(FraiseQLError::validation(
715                        "matches_any_lquery requires at least one lquery".to_string(),
716                    ));
717                }
718                let placeholders: Vec<_> = arr
719                    .iter()
720                    .map(|v| format!("{}::lquery", self.push_param(params, v.clone())))
721                    .collect();
722                self.dialect
723                    .ltree_any_lquery_sql(&field_expr, &placeholders)
724                    .map_err(|e| FraiseQLError::validation(e.to_string()))
725            },
726            WhereOperator::DepthEq => {
727                let p = self.push_param(params, value.clone());
728                self.dialect
729                    .ltree_depth_sql("=", &field_expr, &p)
730                    .map_err(|e| FraiseQLError::validation(e.to_string()))
731            },
732            WhereOperator::DepthNeq => {
733                let p = self.push_param(params, value.clone());
734                self.dialect
735                    .ltree_depth_sql("!=", &field_expr, &p)
736                    .map_err(|e| FraiseQLError::validation(e.to_string()))
737            },
738            WhereOperator::DepthGt => {
739                let p = self.push_param(params, value.clone());
740                self.dialect
741                    .ltree_depth_sql(">", &field_expr, &p)
742                    .map_err(|e| FraiseQLError::validation(e.to_string()))
743            },
744            WhereOperator::DepthGte => {
745                let p = self.push_param(params, value.clone());
746                self.dialect
747                    .ltree_depth_sql(">=", &field_expr, &p)
748                    .map_err(|e| FraiseQLError::validation(e.to_string()))
749            },
750            WhereOperator::DepthLt => {
751                let p = self.push_param(params, value.clone());
752                self.dialect
753                    .ltree_depth_sql("<", &field_expr, &p)
754                    .map_err(|e| FraiseQLError::validation(e.to_string()))
755            },
756            WhereOperator::DepthLte => {
757                let p = self.push_param(params, value.clone());
758                self.dialect
759                    .ltree_depth_sql("<=", &field_expr, &p)
760                    .map_err(|e| FraiseQLError::validation(e.to_string()))
761            },
762            WhereOperator::Lca => {
763                let arr = value.as_array().ok_or_else(|| {
764                    FraiseQLError::validation("lca operator requires an array value".to_string())
765                })?;
766                if arr.is_empty() {
767                    return Err(FraiseQLError::validation(
768                        "lca operator requires at least one path".to_string(),
769                    ));
770                }
771                let placeholders: Vec<_> = arr
772                    .iter()
773                    .map(|v| format!("{}::ltree", self.push_param(params, v.clone())))
774                    .collect();
775                self.dialect
776                    .ltree_lca_sql(&field_expr, &placeholders)
777                    .map_err(|e| FraiseQLError::validation(e.to_string()))
778            },
779
780            // ── LTree ID-based operators ──────────────────────────────────────
781            WhereOperator::DescendantOfId | WhereOperator::AncestorOfId => {
782                let ctx = hierarchy_ctx.ok_or_else(|| {
783                    FraiseQLError::validation(
784                        "descendantOfId/ancestorOfId requires HierarchyContext — \
785                         configure [hierarchies] in fraiseql.toml"
786                            .to_string(),
787                    )
788                })?;
789                let pg_op = if matches!(operator, WhereOperator::DescendantOfId) {
790                    "<@"
791                } else {
792                    "@>"
793                };
794                let p = self.push_param(params, value.clone());
795                self.dialect
796                    .ltree_id_subquery_sql(
797                        pg_op,
798                        &field_expr,
799                        &ctx.table,
800                        &ctx.path_column,
801                        ctx.fk_column.as_deref(),
802                        &p,
803                    )
804                    .map_err(|e| FraiseQLError::validation(e.to_string()))
805            },
806
807            // ── Extended operators ────────────────────────────────────────────
808            WhereOperator::Extended(op) => {
809                self.dialect.generate_extended_sql(op, &field_expr, params)
810            },
811
812            // ── Unknown / future operators ────────────────────────────────────
813            // This arm is only reachable if WhereOperator gains new variants
814            // (it is #[non_exhaustive]).  Suppress the lint that fires when all
815            // current variants are already matched above.
816            #[allow(unreachable_patterns)]
817            // Reason: defensive catch-all for future non_exhaustive variants
818            _ => Err(FraiseQLError::Validation {
819                message: format!(
820                    "Operator {operator:?} is not supported by the {} dialect",
821                    self.dialect.name()
822                ),
823                path:    None,
824            }),
825        }
826    }
827
828    fn require_str<'a>(&self, value: &'a serde_json::Value, op: &'static str) -> Result<&'a str> {
829        value.as_str().ok_or_else(|| {
830            FraiseQLError::validation(format!("{op} operator requires a string value"))
831        })
832    }
833}
834
835// ── Default impl ──────────────────────────────────────────────────────────────
836
837impl<D: SqlDialect + Default> Default for GenericWhereGenerator<D> {
838    fn default() -> Self {
839        Self::new(D::default())
840    }
841}
842
843// ── ExtendedOperatorHandler — single blanket impl ─────────────────────────────
844// Delegates to `D::generate_extended_sql`, which each dialect implements.
845
846impl<D: SqlDialect> crate::filters::ExtendedOperatorHandler for GenericWhereGenerator<D> {
847    fn generate_extended_sql(
848        &self,
849        operator: &crate::filters::ExtendedOperator,
850        field_sql: &str,
851        params: &mut Vec<serde_json::Value>,
852    ) -> Result<String> {
853        self.dialect.generate_extended_sql(operator, field_sql, params)
854    }
855}
856
857#[cfg(test)]
858mod tests;