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