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;