Skip to main content

fraiseql_db/
where_clause.rs

1//! WHERE clause abstract syntax tree.
2
3pub mod field_types;
4pub mod operator_table;
5
6use std::{collections::HashMap, sync::Arc};
7
8use fraiseql_error::{FraiseQLError, Result};
9use serde::{Deserialize, Serialize};
10
11pub use self::{
12    field_types::{
13        FieldTypeMap, RelationFieldMaps, SharedFieldTypes, WhereFieldInfo, WhereFieldSchema,
14    },
15    operator_table::{OperatorCategory, WHERE_OPERATORS, WhereOperatorSpec, operator_spec},
16};
17use crate::{types::sql_hints::ScalarFieldType, utils::to_snake_case};
18
19/// WHERE clause abstract syntax tree.
20///
21/// Represents a type-safe WHERE condition that can be compiled to database-specific SQL.
22///
23/// # Example
24///
25/// ```rust
26/// use fraiseql_db::{WhereClause, WhereOperator};
27/// use serde_json::json;
28///
29/// // Simple condition: email ILIKE '%example.com%'
30/// let where_clause = WhereClause::Field {
31///     path: vec!["email".to_string()],
32///     operator: WhereOperator::Icontains,
33///     value: json!("example.com"),
34/// };
35///
36/// // Complex condition: (published = true) AND (views >= 100)
37/// let where_clause = WhereClause::And(vec![
38///     WhereClause::Field {
39///         path: vec!["published".to_string()],
40///         operator: WhereOperator::Eq,
41///         value: json!(true),
42///     },
43///     WhereClause::Field {
44///         path: vec!["views".to_string()],
45///         operator: WhereOperator::Gte,
46///         value: json!(100),
47///     },
48/// ]);
49/// ```
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[non_exhaustive]
52pub enum WhereClause {
53    /// Single field condition.
54    Field {
55        /// JSONB path (e.g., `["email"]` or `["posts", "title"]`).
56        path:     Vec<String>,
57        /// Comparison operator.
58        operator: WhereOperator,
59        /// Value to compare against.
60        value:    serde_json::Value,
61    },
62
63    /// Logical AND of multiple conditions.
64    And(Vec<WhereClause>),
65
66    /// Logical OR of multiple conditions.
67    Or(Vec<WhereClause>),
68
69    /// Logical NOT of a condition.
70    Not(Box<WhereClause>),
71
72    /// Native column condition — bypasses JSONB extraction.
73    ///
74    /// Used when a direct query argument maps to a native column on `sql_source`,
75    /// detected at compile time. Generates `"column" = $N` (with an optional
76    /// PostgreSQL type cast on the parameter, e.g. `$1::uuid`) instead of the
77    /// default `data->>'column' = $N`.
78    NativeField {
79        /// Native column name (e.g., `"id"`).
80        column:   String,
81        /// PostgreSQL parameter cast suffix (e.g., `"uuid"`, `"int4"`).
82        /// Empty string means no cast is applied.
83        pg_cast:  String,
84        /// Comparison operator.
85        operator: WhereOperator,
86        /// Value to compare against.
87        value:    serde_json::Value,
88    },
89
90    /// A subtree whose fields carry their declared scalar types.
91    ///
92    /// The generator needs the *field's* type to pick a SQL cast; deciding the
93    /// cast from the operator instead is what made every non-numeric range
94    /// filter a hard error (#798). The annotation is a node of the clause rather
95    /// than a parameter of the generator on purpose: a clause travels through
96    /// `ProjectionRequest`, the relay cursor path, the wire adapter, federation
97    /// and the cache key, and an extra argument on any one of those seams is an
98    /// extra place to drop it. Carried by the clause, it cannot be dropped
99    /// without deleting the clause.
100    ///
101    /// Only the *user-supplied* filter is wrapped. RLS and injected-parameter
102    /// conditions are composed separately and stay untyped — they compare
103    /// tenant identifiers as text.
104    Typed {
105        /// Declared type per dotted snake_case field path.
106        types: SharedFieldTypes,
107        /// The annotated subtree.
108        inner: Box<WhereClause>,
109    },
110
111    /// A subtree whose values under `under` are visible only where `guard` holds.
112    ///
113    /// Every path in `inner` that runs through `under` reads its value through the guard —
114    /// `CASE WHEN <guard> THEN <value> END` — so where the guard fails the value is `NULL`:
115    /// exactly what the response serves for a related row the caller may not read. The
116    /// guard's paths are absolute (they start with `under`). The executor builds it for a
117    /// client filter through a relation into a row-gated type; like [`Typed`], it is a
118    /// node of the clause so that no seam the clause travels through can drop it.
119    ///
120    /// [`Typed`]: WhereClause::Typed
121    Guarded {
122        /// The path of the relation whose embedded documents the guard gates.
123        under: Vec<String>,
124        /// The row predicate the related document must meet, over absolute paths.
125        guard: Box<WhereClause>,
126        /// The guarded subtree.
127        inner: Box<WhereClause>,
128    },
129
130    /// Whether the key stored at `path` is among the `target_key` values of the rows of
131    /// `view` that satisfy `predicate`, both compared as `key_type` (ruling AL).
132    ///
133    /// A related row's visibility, decided over the related type's own view: where a type's
134    /// row predicate cannot be read off the document its parent's view embedded, the related
135    /// level is read from its view through the declared relationship, and a filter through
136    /// the relation asks the same question here, as the `guard` of a [`Guarded`]. Rendered
137    /// as `<key> IN (SELECT <target key> FROM <view> WHERE <predicate>)` — uncorrelated, so
138    /// `predicate` reads the view's rows and `path` this row. Built by the executor, never
139    /// parsed from a client. `predicate` is over the view's rows: its native columns are the
140    /// view's, not this row's.
141    ///
142    /// [`Guarded`]: WhereClause::Guarded
143    KeyIn {
144        /// The stored path of the key on this row.
145        path:       Vec<String>,
146        /// The declared scalar type both keys are compared as.
147        key_type:   ScalarFieldType,
148        /// The related type's view.
149        view:       String,
150        /// The stored path of the key on the view's rows.
151        target_key: Vec<String>,
152        /// The related type's row predicate, over the view's rows.
153        predicate:  Box<WhereClause>,
154    },
155}
156
157impl WhereClause {
158    /// Check if WHERE clause is empty.
159    #[must_use]
160    pub fn is_empty(&self) -> bool {
161        match self {
162            Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
163            Self::Typed { inner, .. } | Self::Guarded { inner, .. } => inner.is_empty(),
164            Self::Not(_) | Self::Field { .. } | Self::NativeField { .. } | Self::KeyIn { .. } => {
165                false
166            },
167        }
168    }
169
170    /// Annotate this clause with the declared scalar types of its fields.
171    #[must_use]
172    pub fn typed(self, types: SharedFieldTypes) -> Self {
173        Self::Typed {
174            types,
175            inner: Box::new(self),
176        }
177    }
178
179    /// Collect all native column names referenced in this WHERE clause.
180    ///
181    /// Used to enrich error messages when a native column does not exist on the
182    /// target table — the caller can hint that the column was auto-inferred from
183    /// an `ID`/`UUID`-typed argument and suggest adding the column or using
184    /// explicit `native_columns` annotation.
185    #[must_use]
186    pub fn native_column_names(&self) -> Vec<&str> {
187        let mut names = Vec::new();
188        self.collect_native_column_names(&mut names);
189        names
190    }
191
192    fn collect_native_column_names<'a>(&'a self, out: &mut Vec<&'a str>) {
193        match self {
194            Self::And(clauses) | Self::Or(clauses) => {
195                for c in clauses {
196                    c.collect_native_column_names(out);
197                }
198            },
199            Self::Not(inner) | Self::Typed { inner, .. } => inner.collect_native_column_names(out),
200            Self::Guarded { guard, inner, .. } => {
201                guard.collect_native_column_names(out);
202                inner.collect_native_column_names(out);
203            },
204            Self::NativeField { column, .. } => out.push(column),
205            // Its predicate's native columns are the related view's, not this row's.
206            Self::Field { .. } | Self::KeyIn { .. } => {},
207        }
208    }
209
210    /// Parse a `WhereClause` from a nested GraphQL JSON `where` variable.
211    ///
212    /// Expected format (nested object with field → operator → value):
213    /// ```json
214    /// {
215    ///   "status": { "eq": "active" },
216    ///   "name": { "icontains": "john" },
217    ///   "_and": [ { "age": { "gte": 18 } }, { "age": { "lte": 65 } } ],
218    ///   "_or": [ { "role": { "eq": "admin" } } ],
219    ///   "_not": { "deleted": { "eq": true } }
220    /// }
221    /// ```
222    ///
223    /// Each top-level key is either a field name (mapped to `WhereClause::Field`
224    /// with operator sub-keys) or a logical combinator (`_and`, `_or`, `_not`).
225    /// Multiple top-level keys are combined with AND.
226    ///
227    /// `types` carries the declared scalar type of each filterable field, taken
228    /// from the compiled schema. It is a required argument rather than an
229    /// optional enrichment step because the cast a filter needs is a property of
230    /// the field: a caller that has a schema and forgets to consult it produces
231    /// SQL that errors on dates and silently under-matches on numbers. Callers
232    /// that genuinely have no schema pass [`SharedFieldTypes::default`] and get
233    /// value-shape inference.
234    ///
235    /// # Errors
236    ///
237    /// Returns `FraiseQLError::Validation` if the JSON structure is invalid or
238    /// contains unknown operators.
239    ///
240    /// # Panics
241    ///
242    /// Cannot panic: the internal `.expect("checked len == 1")` is only reached
243    /// after verifying `conditions.len() == 1`.
244    pub fn from_graphql_json(value: &serde_json::Value, schema: &WhereFieldSchema) -> Result<Self> {
245        // Casts discovered while descending, keyed by the dotted path actually
246        // walked (#1157). `WhereFieldSchema::casts()` covers the entry type only;
247        // a nested path such as `customer.signed_up_at` has no entry there, and
248        // the generator would otherwise infer the type from the shape of the
249        // supplied JSON value — right for a number, wrong for a string that
250        // denotes an instant, a date or a UUID.
251        //
252        // Collected during the descent rather than enumerated up front because a
253        // relation cycle (`Order → Customer → Order`) makes the set of dotted
254        // paths unbounded. The query's own depth bounds this one.
255        let mut discovered = Vec::new();
256        let parsed =
257            Self::parse_where_object(value, &[], schema, schema.root_level(), &mut discovered)?;
258
259        let types = schema.casts();
260        if types.is_empty() && discovered.is_empty() {
261            return Ok(parsed);
262        }
263        Ok(parsed.typed(Arc::new(types.as_ref().clone().with_paths(discovered))))
264    }
265
266    /// Recursive WHERE parser that builds multi-segment paths for nested objects.
267    ///
268    /// When parsing `{ machine: { id: { eq: "..." } } }`:
269    /// 1. Key `machine`, value is `{ id: { eq: "..." } }` — not an operator map.
270    /// 2. Recurse with path prefix `["machine"]`.
271    /// 3. Key `id`, value is `{ eq: "..." }` — this IS an operator map.
272    /// 4. Emit `Field { path: ["machine", "id"], operator: Eq, value: "..." }`.
273    ///
274    /// The multi-segment path is then handled by `GenericWhereGenerator`, which
275    /// checks `IndexedColumnsCache` for `machine__id` (native column with index)
276    /// and falls back to JSONB extraction (`data->'machine'->>'id'`).
277    ///
278    /// `level` is the set of keys legal *at this depth* — the entry type's at the
279    /// top, and the relation target's below it. `None` means the schema cannot
280    /// adjudicate here, and every key passes (#939).
281    ///
282    /// `discovered` accumulates `(dotted path, cast)` for every comparison the
283    /// descent reaches, which is how a nested path gets a declared type without
284    /// enumerating the (possibly cyclic) closure of paths up front (#1157).
285    fn parse_where_object(
286        value: &serde_json::Value,
287        path_prefix: &[String],
288        schema: &WhereFieldSchema,
289        level: Option<&HashMap<String, WhereFieldInfo>>,
290        discovered: &mut Vec<(String, ScalarFieldType)>,
291    ) -> Result<Self> {
292        let Some(obj) = value.as_object() else {
293            return Err(FraiseQLError::Validation {
294                message: "where clause must be a JSON object".to_string(),
295                path:    None,
296            });
297        };
298
299        let mut conditions = Vec::new();
300
301        for (key, val) in obj {
302            match key.as_str() {
303                "_and" => {
304                    let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
305                        message: "_and must be an array".to_string(),
306                        path:    None,
307                    })?;
308                    let sub: Result<Vec<Self>> = arr
309                        .iter()
310                        .map(|v| {
311                            Self::parse_where_object(v, path_prefix, schema, level, discovered)
312                        })
313                        .collect();
314                    conditions.push(Self::And(sub?));
315                },
316                "_or" => {
317                    let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
318                        message: "_or must be an array".to_string(),
319                        path:    None,
320                    })?;
321                    let sub: Result<Vec<Self>> = arr
322                        .iter()
323                        .map(|v| {
324                            Self::parse_where_object(v, path_prefix, schema, level, discovered)
325                        })
326                        .collect();
327                    conditions.push(Self::Or(sub?));
328                },
329                "_not" => {
330                    let sub =
331                        Self::parse_where_object(val, path_prefix, schema, level, discovered)?;
332                    conditions.push(Self::Not(Box::new(sub)));
333                },
334                field_name => {
335                    // Security boundary (#833): the name will be interpolated
336                    // into SQL text (JSON path literals, `data->>'…'`), so it
337                    // must be a plain GraphQL identifier — same rule orderBy
338                    // enforces. Rejecting here covers every dialect and every
339                    // downstream consumer.
340                    crate::utils::validate_graphql_identifier(field_name, "where")?;
341
342                    // Every level the schema can name is adjudicated. A nested
343                    // path such as `{machine: {id: …}}` resolves `id` against
344                    // *machine's* type, which the compiled schema now names —
345                    // and which the published `MachineWhereInput` already
346                    // promises. A level the schema cannot name passes, because
347                    // rejecting there would be guessing (#939).
348                    // The lookup normalises to the storage key, because that is
349                    // what `FieldTypeMap` is keyed by and what the path below
350                    // lowers to. The *acceptance* does not: a key is legal only
351                    // under the spelling the schema declares, which is the one
352                    // the published `{Entity}WhereInput` carries. Accepting the
353                    // storage spelling as well would mean honouring keys the
354                    // published input type does not declare — the defect class
355                    // this release closes, and the asymmetry `orderBy` never had.
356                    //
357                    // The rule is "equals `declared_name`", not "is camelCase":
358                    // a schema that declares `ip_address` keeps working under
359                    // that spelling, because that is what it publishes.
360                    let snake = to_snake_case(field_name);
361                    if let Some(level) = level {
362                        match level.get(&snake) {
363                            Some(info) if info.declared_name == field_name => {},
364                            Some(info) => {
365                                return Err(undeclared_where_spelling(
366                                    field_name,
367                                    &info.declared_name,
368                                ));
369                            },
370                            None => return Err(unknown_where_field(field_name, level)),
371                        }
372                    }
373
374                    let ops = val.as_object().ok_or_else(|| FraiseQLError::Validation {
375                        message: format!(
376                            "where field '{field_name}' must be an object of {{operator: value}}"
377                        ),
378                        path:    None,
379                    })?;
380                    let mut field_path = path_prefix.to_vec();
381                    field_path.push(snake.clone());
382
383                    // A field the schema calls a relation carries a whole nested
384                    // predicate, not an operator map — which is exactly what the
385                    // published `{Target}WhereInput` on it says. Descending
386                    // wholesale is what makes `_and`/`_or`/`_not` work inside it:
387                    // the key-at-a-time path below only recognises a combinator
388                    // when its value is an object, and `_and`/`_or` take arrays,
389                    // so `{machine: {_or: […]}}` failed with
390                    // "Unknown WHERE operator: _or" while the type advertised it.
391                    if let Some(info) = level.and_then(|l| l.get(&snake)) {
392                        if info.is_relation {
393                            let nested_level = info
394                                .relation_type
395                                .as_deref()
396                                .and_then(|target| schema.level_of(target));
397                            conditions.push(Self::parse_where_object(
398                                val,
399                                &field_path,
400                                schema,
401                                nested_level,
402                                discovered,
403                            )?);
404                            continue;
405                        }
406                    }
407
408                    // #1157: a comparison below the entry type has no entry in
409                    // `WhereFieldSchema::casts()`, so record the one this level
410                    // declares against the path actually walked. Top-level paths
411                    // are already covered and are skipped rather than re-asserted,
412                    // so this can never contradict the entry type's own map.
413                    if !path_prefix.is_empty() {
414                        if let Some(cast) = level.and_then(|l| l.get(&snake)).and_then(|i| i.cast) {
415                            discovered.push((field_path.join("."), cast));
416                        }
417                    }
418
419                    for (op_str, op_val) in ops {
420                        match WhereOperator::from_str(op_str) {
421                            Ok(operator) => {
422                                conditions.push(Self::Field {
423                                    path: field_path.clone(),
424                                    operator,
425                                    value: op_val.clone(),
426                                });
427                            },
428                            Err(_) if op_val.is_object() => {
429                                // Nested relation/object filter: recurse with extended path.
430                                // e.g., { machine: { id: { eq: "..." } } }
431                                //   → path_prefix=["machine"], key="id", value={ eq: "..." }
432                                //
433                                // …but only when the field is actually a
434                                // relation. On a *scalar* field this arm was
435                                // reinterpreting an unknown operator as a
436                                // relation filter, so
437                                // `{"reference": {"notAnOperator": {"eq": "x"}}}`
438                                // built the path `reference.notAnOperator`,
439                                // matched nothing, and returned `[]` with no
440                                // error — while the same bogus operator with a
441                                // *scalar* value was correctly refused. A nested
442                                // relation filter on a scalar field is never
443                                // legitimate.
444                                if level
445                                    .and_then(|l| l.get(&snake))
446                                    .is_some_and(|info| !info.is_relation)
447                                {
448                                    return Err(FraiseQLError::Validation {
449                                        message: format!(
450                                            "Unknown WHERE operator '{op_str}' on field \
451                                             '{field_name}'. '{field_name}' is a scalar field, so \
452                                             it cannot take a nested filter."
453                                        ),
454                                        path:    None,
455                                    });
456                                }
457                                let nested_json = serde_json::json!({ op_str: op_val });
458                                // The level below is the relation target's own
459                                // keys. `None` when the field is a relation the
460                                // caller could not resolve — then, as before,
461                                // the nested level is not adjudicated.
462                                let nested_level = level
463                                    .and_then(|l| l.get(&snake))
464                                    .and_then(|info| info.relation_type.as_deref())
465                                    .and_then(|target| schema.level_of(target));
466                                let nested = Self::parse_where_object(
467                                    &nested_json,
468                                    &field_path,
469                                    schema,
470                                    nested_level,
471                                    discovered,
472                                )?;
473                                conditions.push(nested);
474                            },
475                            Err(e) => return Err(e),
476                        }
477                    }
478                },
479            }
480        }
481
482        if conditions.len() == 1 {
483            // Reason: iterator has exactly one element — length was checked on the line above
484            Ok(conditions.into_iter().next().expect("checked len == 1"))
485        } else {
486            Ok(Self::And(conditions))
487        }
488    }
489}
490
491/// The error for a `where` key that names a real field by a spelling the schema
492/// does not declare — in practice the `snake_case` storage key, which this engine
493/// accepted alongside the declared name until 2.15.0.
494///
495/// Named exactly rather than hinted: the client wrote a key that *resolves*, so
496/// "did you mean" fuzzy matching would be answering a question we already know
497/// the answer to. This is also the whole migration instruction for the change,
498/// so it is worth stating outright.
499fn undeclared_where_spelling(field_name: &str, declared: &str) -> FraiseQLError {
500    FraiseQLError::Validation {
501        message: format!(
502            "Unknown field '{field_name}' in where clause. Use '{declared}', the name this \
503             schema declares — the underlying storage spelling is not part of the published \
504             filter input."
505        ),
506        path:    Some(format!("where.{field_name}")),
507    }
508}
509
510/// The error for a `where` key the type does not declare.
511///
512/// Mirrors the shape of the unknown-operator error already in this file, plus a
513/// "did you mean" hint: a `where` key is most often a rename or a
514/// camelCase/snake_case slip, and both are one edit from the name that works.
515fn unknown_where_field(field_name: &str, level: &HashMap<String, WhereFieldInfo>) -> FraiseQLError {
516    let candidates: Vec<&str> = level.values().map(|i| i.declared_name.as_str()).collect();
517    let subject = format!("Unknown field '{field_name}' in where clause.");
518    let message = match crate::utils::suggest_similar(field_name, &candidates).as_slice() {
519        [s] => format!("{subject} Did you mean '{s}'?"),
520        [a, b] => format!("{subject} Did you mean '{a}' or '{b}'?"),
521        [a, b, c, ..] => format!("{subject} Did you mean '{a}', '{b}', or '{c}'?"),
522        _ => subject,
523    };
524    FraiseQLError::Validation {
525        message,
526        path: Some(format!("where.{field_name}")),
527    }
528}
529
530/// Maximum nesting depth for recursive WHERE field parsing.
531/// WHERE operators (FraiseQL v1 compatibility).
532///
533/// All standard operators are supported.
534/// No underscore prefix (e.g., `eq`, `icontains`, not `_eq`, `_icontains`).
535///
536/// This enum is marked `#[non_exhaustive]` so that new operators (e.g., `Between`,
537/// `Similar`) can be added in future minor versions without breaking downstream
538/// exhaustive `match` expressions.
539#[non_exhaustive]
540#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
541pub enum WhereOperator {
542    // ========================================================================
543    // Comparison Operators
544    // ========================================================================
545    /// Equal (=).
546    Eq,
547    /// Not equal (!=).
548    Neq,
549    /// Greater than (>).
550    Gt,
551    /// Greater than or equal (>=).
552    Gte,
553    /// Less than (<).
554    Lt,
555    /// Less than or equal (<=).
556    Lte,
557
558    // ========================================================================
559    // Containment Operators
560    // ========================================================================
561    /// In list (IN).
562    In,
563    /// Not in list (NOT IN).
564    Nin,
565
566    // ========================================================================
567    // String Operators
568    // ========================================================================
569    /// Contains substring (LIKE '%value%').
570    Contains,
571    /// Contains substring (case-insensitive) (ILIKE '%value%').
572    Icontains,
573    /// Starts with (LIKE 'value%').
574    Startswith,
575    /// Starts with (case-insensitive) (ILIKE 'value%').
576    Istartswith,
577    /// Ends with (LIKE '%value').
578    Endswith,
579    /// Ends with (case-insensitive) (ILIKE '%value').
580    Iendswith,
581    /// Pattern matching (LIKE).
582    Like,
583    /// Pattern matching (case-insensitive) (ILIKE).
584    Ilike,
585    /// Negated pattern matching (NOT LIKE).
586    Nlike,
587    /// Negated pattern matching (case-insensitive) (NOT ILIKE).
588    Nilike,
589    /// POSIX regex match (~).
590    Regex,
591    /// POSIX regex match (case-insensitive) (~*).
592    Iregex,
593    /// Negated POSIX regex match (!~).
594    Nregex,
595    /// Negated POSIX regex match (case-insensitive) (!~*).
596    Niregex,
597
598    // ========================================================================
599    // Null Checks
600    // ========================================================================
601    /// Is null. A `false` operand inverts it to IS NOT NULL.
602    IsNull,
603    /// Is not null. A `false` operand inverts it to IS NULL.
604    ///
605    /// The inverse spelling exists because REST advertises `is_not_null` as a
606    /// bracket operator; expressing it as `is_null: false` requires the client
607    /// to send a boolean through a URL query string, which is exactly where
608    /// #828's coercion inverted the meaning.
609    IsNotNull,
610
611    // ========================================================================
612    // Array Operators
613    // ========================================================================
614    /// Array contains (@>).
615    ArrayContains,
616    /// Array contained by (<@).
617    ArrayContainedBy,
618    /// Array overlaps (&&).
619    ArrayOverlaps,
620    /// Array length equal.
621    LenEq,
622    /// Array length greater than.
623    LenGt,
624    /// Array length less than.
625    LenLt,
626    /// Array length greater than or equal.
627    LenGte,
628    /// Array length less than or equal.
629    LenLte,
630    /// Array length not equal.
631    LenNeq,
632
633    // ========================================================================
634    // Vector Operators (pgvector)
635    // ========================================================================
636    /// Cosine distance (<=>).
637    CosineDistance,
638    /// L2 (Euclidean) distance (<->).
639    L2Distance,
640    /// L1 (Manhattan) distance (<+>).
641    L1Distance,
642    /// Hamming distance (<~>).
643    HammingDistance,
644    /// Inner product (<#>). Higher values = more similar.
645    InnerProduct,
646    /// Jaccard distance for set similarity.
647    JaccardDistance,
648
649    // ========================================================================
650    // Full-Text Search
651    // ========================================================================
652    /// Full-text search (@@).
653    Matches,
654    /// Plain text query (plainto_tsquery).
655    PlainQuery,
656    /// Phrase query (phraseto_tsquery).
657    PhraseQuery,
658    /// Web search query (websearch_to_tsquery).
659    WebsearchQuery,
660
661    // ========================================================================
662    // Network Operators (INET/CIDR)
663    // ========================================================================
664    /// Is IPv4.
665    IsIPv4,
666    /// Is IPv6.
667    IsIPv6,
668    /// Is private IP (RFC1918 ranges). Value controls negation (false = public).
669    IsPrivate,
670    /// Is loopback address (127.0.0.0/8 or ::1). Value controls negation.
671    IsLoopback,
672    /// Is multicast (224.0.0.0/4 or ff00::/8). Value controls negation.
673    IsMulticast,
674    /// Is link-local (169.254.0.0/16 or fe80::/10). Value controls negation.
675    IsLinkLocal,
676    /// Is documentation range (RFC 5737/3849). Value controls negation.
677    IsDocumentation,
678    /// Is carrier-grade NAT (100.64.0.0/10, RFC 6598). Value controls negation.
679    IsCarrierGrade,
680    /// In subnet (<<) - IP is contained within subnet.
681    InSubnet,
682    /// Contains subnet (>>) - subnet contains another subnet.
683    ContainsSubnet,
684    /// Contains IP (>>) - subnet contains an IP address.
685    ContainsIP,
686    /// Overlaps (&&) - subnets overlap.
687    Overlaps,
688
689    // ========================================================================
690    // JSONB Operators
691    // ========================================================================
692    /// Strictly contains (@>).
693    StrictlyContains,
694
695    // ========================================================================
696    // LTree Operators (Hierarchical)
697    // ========================================================================
698    /// Ancestor of (@>).
699    AncestorOf,
700    /// Descendant of (<@).
701    DescendantOf,
702    /// Matches lquery (~).
703    MatchesLquery,
704    /// Matches ltxtquery (@) - Boolean query syntax.
705    MatchesLtxtquery,
706    /// Matches any lquery (?).
707    MatchesAnyLquery,
708    /// Depth equal (nlevel() =).
709    DepthEq,
710    /// Depth not equal (nlevel() !=).
711    DepthNeq,
712    /// Depth greater than (nlevel() >).
713    DepthGt,
714    /// Depth greater than or equal (nlevel() >=).
715    DepthGte,
716    /// Depth less than (nlevel() <).
717    DepthLt,
718    /// Depth less than or equal (nlevel() <=).
719    DepthLte,
720    /// Lowest common ancestor (lca()).
721    Lca,
722
723    // ========================================================================
724    // LTree ID-Based Operators (resolve path from entity UUID)
725    // ========================================================================
726    /// Descendant of entity by ID: `path <@ (SELECT path FROM t WHERE id = $1)`.
727    DescendantOfId,
728    /// Ancestor of entity by ID: `path @> (SELECT path FROM t WHERE id = $1)`.
729    AncestorOfId,
730}
731
732impl WhereOperator {
733    /// Parse operator from string (GraphQL input).
734    ///
735    /// # Errors
736    ///
737    /// Returns `FraiseQLError::Validation` if operator name is unknown.
738    #[allow(clippy::should_implement_trait)] // Reason: intentionally not implementing `FromStr` because this returns `FraiseQLError`, not `<Self as FromStr>::Err`.
739    pub fn from_str(s: &str) -> Result<Self> {
740        if let Some(op) = Self::match_exact(s) {
741            return Ok(op);
742        }
743
744        // If the name has no underscores and contains uppercase letters, it may
745        // be a camelCase form of a registered snake_case operator. Convert and
746        // retry. This avoids allocation when the first match succeeds.
747        if !s.contains('_') && s.chars().any(char::is_uppercase) {
748            let snake = crate::utils::to_snake_case(s);
749            if let Some(op) = Self::match_exact(&snake) {
750                return Ok(op);
751            }
752        }
753
754        Err(FraiseQLError::validation(format!("Unknown WHERE operator: {s}")))
755    }
756
757    /// Check if operator requires array value.
758    #[must_use]
759    pub const fn expects_array(&self) -> bool {
760        matches!(self, Self::In | Self::Nin)
761    }
762
763    /// Check if operator is case-insensitive.
764    #[must_use]
765    pub const fn is_case_insensitive(&self) -> bool {
766        matches!(
767            self,
768            Self::Icontains
769                | Self::Istartswith
770                | Self::Iendswith
771                | Self::Ilike
772                | Self::Nilike
773                | Self::Iregex
774                | Self::Niregex
775        )
776    }
777
778    /// Check if operator works with strings.
779    #[must_use]
780    pub const fn is_string_operator(&self) -> bool {
781        matches!(
782            self,
783            Self::Contains
784                | Self::Icontains
785                | Self::Startswith
786                | Self::Istartswith
787                | Self::Endswith
788                | Self::Iendswith
789                | Self::Like
790                | Self::Ilike
791                | Self::Nlike
792                | Self::Nilike
793                | Self::Regex
794                | Self::Iregex
795                | Self::Nregex
796                | Self::Niregex
797        )
798    }
799}
800
801/// HAVING clause abstract syntax tree.
802///
803/// HAVING filters aggregated results after GROUP BY, while WHERE filters rows before aggregation.
804///
805/// # Example
806///
807/// ```rust
808/// use fraiseql_db::{HavingClause, WhereOperator};
809/// use serde_json::json;
810///
811/// // Simple condition: COUNT(*) > 10
812/// let having_clause = HavingClause::Aggregate {
813///     aggregate: "count".to_string(),
814///     operator: WhereOperator::Gt,
815///     value: json!(10),
816/// };
817///
818/// // Complex condition: (COUNT(*) > 10) AND (SUM(revenue) >= 1000)
819/// let having_clause = HavingClause::And(vec![
820///     HavingClause::Aggregate {
821///         aggregate: "count".to_string(),
822///         operator: WhereOperator::Gt,
823///         value: json!(10),
824///     },
825///     HavingClause::Aggregate {
826///         aggregate: "revenue_sum".to_string(),
827///         operator: WhereOperator::Gte,
828///         value: json!(1000),
829///     },
830/// ]);
831/// ```
832#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
833#[non_exhaustive]
834pub enum HavingClause {
835    /// Aggregate field condition (e.g., count_gt, revenue_sum_gte).
836    Aggregate {
837        /// Aggregate name: "count" or "field_function" (e.g., "revenue_sum").
838        aggregate: String,
839        /// Comparison operator.
840        operator:  WhereOperator,
841        /// Value to compare against.
842        value:     serde_json::Value,
843    },
844
845    /// Logical AND of multiple conditions.
846    And(Vec<HavingClause>),
847
848    /// Logical OR of multiple conditions.
849    Or(Vec<HavingClause>),
850
851    /// Logical NOT of a condition.
852    Not(Box<HavingClause>),
853}
854
855impl HavingClause {
856    /// Check if HAVING clause is empty.
857    #[must_use]
858    pub const fn is_empty(&self) -> bool {
859        match self {
860            Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
861            Self::Not(_) | Self::Aggregate { .. } => false,
862        }
863    }
864}
865
866#[cfg(test)]
867mod tests;