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;