fraiseql_db/where_clause.rs
1//! WHERE clause abstract syntax tree.
2
3use fraiseql_error::{FraiseQLError, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::utils::to_snake_case;
7
8/// WHERE clause abstract syntax tree.
9///
10/// Represents a type-safe WHERE condition that can be compiled to database-specific SQL.
11///
12/// # Example
13///
14/// ```rust
15/// use fraiseql_db::{WhereClause, WhereOperator};
16/// use serde_json::json;
17///
18/// // Simple condition: email ILIKE '%example.com%'
19/// let where_clause = WhereClause::Field {
20/// path: vec!["email".to_string()],
21/// operator: WhereOperator::Icontains,
22/// value: json!("example.com"),
23/// };
24///
25/// // Complex condition: (published = true) AND (views >= 100)
26/// let where_clause = WhereClause::And(vec![
27/// WhereClause::Field {
28/// path: vec!["published".to_string()],
29/// operator: WhereOperator::Eq,
30/// value: json!(true),
31/// },
32/// WhereClause::Field {
33/// path: vec!["views".to_string()],
34/// operator: WhereOperator::Gte,
35/// value: json!(100),
36/// },
37/// ]);
38/// ```
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[non_exhaustive]
41pub enum WhereClause {
42 /// Single field condition.
43 Field {
44 /// JSONB path (e.g., `["email"]` or `["posts", "title"]`).
45 path: Vec<String>,
46 /// Comparison operator.
47 operator: WhereOperator,
48 /// Value to compare against.
49 value: serde_json::Value,
50 },
51
52 /// Logical AND of multiple conditions.
53 And(Vec<WhereClause>),
54
55 /// Logical OR of multiple conditions.
56 Or(Vec<WhereClause>),
57
58 /// Logical NOT of a condition.
59 Not(Box<WhereClause>),
60
61 /// Native column condition — bypasses JSONB extraction.
62 ///
63 /// Used when a direct query argument maps to a native column on `sql_source`,
64 /// detected at compile time. Generates `"column" = $N` (with an optional
65 /// PostgreSQL type cast on the parameter, e.g. `$1::uuid`) instead of the
66 /// default `data->>'column' = $N`.
67 NativeField {
68 /// Native column name (e.g., `"id"`).
69 column: String,
70 /// PostgreSQL parameter cast suffix (e.g., `"uuid"`, `"int4"`).
71 /// Empty string means no cast is applied.
72 pg_cast: String,
73 /// Comparison operator.
74 operator: WhereOperator,
75 /// Value to compare against.
76 value: serde_json::Value,
77 },
78}
79
80impl WhereClause {
81 /// Check if WHERE clause is empty.
82 #[must_use]
83 pub const fn is_empty(&self) -> bool {
84 match self {
85 Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
86 Self::Not(_) | Self::Field { .. } | Self::NativeField { .. } => false,
87 }
88 }
89
90 /// Collect all native column names referenced in this WHERE clause.
91 ///
92 /// Used to enrich error messages when a native column does not exist on the
93 /// target table — the caller can hint that the column was auto-inferred from
94 /// an `ID`/`UUID`-typed argument and suggest adding the column or using
95 /// explicit `native_columns` annotation.
96 #[must_use]
97 pub fn native_column_names(&self) -> Vec<&str> {
98 let mut names = Vec::new();
99 self.collect_native_column_names(&mut names);
100 names
101 }
102
103 fn collect_native_column_names<'a>(&'a self, out: &mut Vec<&'a str>) {
104 match self {
105 Self::And(clauses) | Self::Or(clauses) => {
106 for c in clauses {
107 c.collect_native_column_names(out);
108 }
109 },
110 Self::Not(inner) => inner.collect_native_column_names(out),
111 Self::NativeField { column, .. } => out.push(column),
112 Self::Field { .. } => {},
113 }
114 }
115
116 /// Parse a `WhereClause` from a nested GraphQL JSON `where` variable.
117 ///
118 /// Expected format (nested object with field → operator → value):
119 /// ```json
120 /// {
121 /// "status": { "eq": "active" },
122 /// "name": { "icontains": "john" },
123 /// "_and": [ { "age": { "gte": 18 } }, { "age": { "lte": 65 } } ],
124 /// "_or": [ { "role": { "eq": "admin" } } ],
125 /// "_not": { "deleted": { "eq": true } }
126 /// }
127 /// ```
128 ///
129 /// Each top-level key is either a field name (mapped to `WhereClause::Field`
130 /// with operator sub-keys) or a logical combinator (`_and`, `_or`, `_not`).
131 /// Multiple top-level keys are combined with AND.
132 ///
133 /// # Errors
134 ///
135 /// Returns `FraiseQLError::Validation` if the JSON structure is invalid or
136 /// contains unknown operators.
137 ///
138 /// # Panics
139 ///
140 /// Cannot panic: the internal `.expect("checked len == 1")` is only reached
141 /// after verifying `conditions.len() == 1`.
142 pub fn from_graphql_json(value: &serde_json::Value) -> Result<Self> {
143 Self::parse_where_object(value, &[])
144 }
145
146 /// Recursive WHERE parser that builds multi-segment paths for nested objects.
147 ///
148 /// When parsing `{ machine: { id: { eq: "..." } } }`:
149 /// 1. Key `machine`, value is `{ id: { eq: "..." } }` — not an operator map.
150 /// 2. Recurse with path prefix `["machine"]`.
151 /// 3. Key `id`, value is `{ eq: "..." }` — this IS an operator map.
152 /// 4. Emit `Field { path: ["machine", "id"], operator: Eq, value: "..." }`.
153 ///
154 /// The multi-segment path is then handled by `GenericWhereGenerator`, which
155 /// checks `IndexedColumnsCache` for `machine__id` (native column with index)
156 /// and falls back to JSONB extraction (`data->'machine'->>'id'`).
157 fn parse_where_object(value: &serde_json::Value, path_prefix: &[String]) -> Result<Self> {
158 let Some(obj) = value.as_object() else {
159 return Err(FraiseQLError::Validation {
160 message: "where clause must be a JSON object".to_string(),
161 path: None,
162 });
163 };
164
165 let mut conditions = Vec::new();
166
167 for (key, val) in obj {
168 match key.as_str() {
169 "_and" => {
170 let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
171 message: "_and must be an array".to_string(),
172 path: None,
173 })?;
174 let sub: Result<Vec<Self>> =
175 arr.iter().map(|v| Self::parse_where_object(v, path_prefix)).collect();
176 conditions.push(Self::And(sub?));
177 },
178 "_or" => {
179 let arr = val.as_array().ok_or_else(|| FraiseQLError::Validation {
180 message: "_or must be an array".to_string(),
181 path: None,
182 })?;
183 let sub: Result<Vec<Self>> =
184 arr.iter().map(|v| Self::parse_where_object(v, path_prefix)).collect();
185 conditions.push(Self::Or(sub?));
186 },
187 "_not" => {
188 let sub = Self::parse_where_object(val, path_prefix)?;
189 conditions.push(Self::Not(Box::new(sub)));
190 },
191 field_name => {
192 let ops = val.as_object().ok_or_else(|| FraiseQLError::Validation {
193 message: format!(
194 "where field '{field_name}' must be an object of {{operator: value}}"
195 ),
196 path: None,
197 })?;
198 let mut field_path = path_prefix.to_vec();
199 field_path.push(to_snake_case(field_name));
200
201 for (op_str, op_val) in ops {
202 match WhereOperator::from_str(op_str) {
203 Ok(operator) => {
204 conditions.push(Self::Field {
205 path: field_path.clone(),
206 operator,
207 value: op_val.clone(),
208 });
209 },
210 Err(_) if op_val.is_object() => {
211 // Nested relation/object filter: recurse with extended path.
212 // e.g., { machine: { id: { eq: "..." } } }
213 // → path_prefix=["machine"], key="id", value={ eq: "..." }
214 let nested_json = serde_json::json!({ op_str: op_val });
215 let nested = Self::parse_where_object(&nested_json, &field_path)?;
216 conditions.push(nested);
217 },
218 Err(e) => return Err(e),
219 }
220 }
221 },
222 }
223 }
224
225 if conditions.len() == 1 {
226 // Reason: iterator has exactly one element — length was checked on the line above
227 Ok(conditions.into_iter().next().expect("checked len == 1"))
228 } else {
229 Ok(Self::And(conditions))
230 }
231 }
232}
233
234/// Maximum nesting depth for recursive WHERE field parsing.
235/// WHERE operators (FraiseQL v1 compatibility).
236///
237/// All standard operators are supported.
238/// No underscore prefix (e.g., `eq`, `icontains`, not `_eq`, `_icontains`).
239///
240/// Note: ExtendedOperator variants may contain f64 values which don't implement Eq,
241/// so WhereOperator derives PartialEq only (not Eq).
242///
243/// This enum is marked `#[non_exhaustive]` so that new operators (e.g., `Between`,
244/// `Similar`) can be added in future minor versions without breaking downstream
245/// exhaustive `match` expressions.
246#[non_exhaustive]
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub enum WhereOperator {
249 // ========================================================================
250 // Comparison Operators
251 // ========================================================================
252 /// Equal (=).
253 Eq,
254 /// Not equal (!=).
255 Neq,
256 /// Greater than (>).
257 Gt,
258 /// Greater than or equal (>=).
259 Gte,
260 /// Less than (<).
261 Lt,
262 /// Less than or equal (<=).
263 Lte,
264
265 // ========================================================================
266 // Containment Operators
267 // ========================================================================
268 /// In list (IN).
269 In,
270 /// Not in list (NOT IN).
271 Nin,
272
273 // ========================================================================
274 // String Operators
275 // ========================================================================
276 /// Contains substring (LIKE '%value%').
277 Contains,
278 /// Contains substring (case-insensitive) (ILIKE '%value%').
279 Icontains,
280 /// Starts with (LIKE 'value%').
281 Startswith,
282 /// Starts with (case-insensitive) (ILIKE 'value%').
283 Istartswith,
284 /// Ends with (LIKE '%value').
285 Endswith,
286 /// Ends with (case-insensitive) (ILIKE '%value').
287 Iendswith,
288 /// Pattern matching (LIKE).
289 Like,
290 /// Pattern matching (case-insensitive) (ILIKE).
291 Ilike,
292 /// Negated pattern matching (NOT LIKE).
293 Nlike,
294 /// Negated pattern matching (case-insensitive) (NOT ILIKE).
295 Nilike,
296 /// POSIX regex match (~).
297 Regex,
298 /// POSIX regex match (case-insensitive) (~*).
299 Iregex,
300 /// Negated POSIX regex match (!~).
301 Nregex,
302 /// Negated POSIX regex match (case-insensitive) (!~*).
303 Niregex,
304
305 // ========================================================================
306 // Null Checks
307 // ========================================================================
308 /// Is null (IS NULL or IS NOT NULL).
309 IsNull,
310
311 // ========================================================================
312 // Array Operators
313 // ========================================================================
314 /// Array contains (@>).
315 ArrayContains,
316 /// Array contained by (<@).
317 ArrayContainedBy,
318 /// Array overlaps (&&).
319 ArrayOverlaps,
320 /// Array length equal.
321 LenEq,
322 /// Array length greater than.
323 LenGt,
324 /// Array length less than.
325 LenLt,
326 /// Array length greater than or equal.
327 LenGte,
328 /// Array length less than or equal.
329 LenLte,
330 /// Array length not equal.
331 LenNeq,
332
333 // ========================================================================
334 // Vector Operators (pgvector)
335 // ========================================================================
336 /// Cosine distance (<=>).
337 CosineDistance,
338 /// L2 (Euclidean) distance (<->).
339 L2Distance,
340 /// L1 (Manhattan) distance (<+>).
341 L1Distance,
342 /// Hamming distance (<~>).
343 HammingDistance,
344 /// Inner product (<#>). Higher values = more similar.
345 InnerProduct,
346 /// Jaccard distance for set similarity.
347 JaccardDistance,
348
349 // ========================================================================
350 // Full-Text Search
351 // ========================================================================
352 /// Full-text search (@@).
353 Matches,
354 /// Plain text query (plainto_tsquery).
355 PlainQuery,
356 /// Phrase query (phraseto_tsquery).
357 PhraseQuery,
358 /// Web search query (websearch_to_tsquery).
359 WebsearchQuery,
360
361 // ========================================================================
362 // Network Operators (INET/CIDR)
363 // ========================================================================
364 /// Is IPv4.
365 IsIPv4,
366 /// Is IPv6.
367 IsIPv6,
368 /// Is private IP (RFC1918 ranges). Value controls negation (false = public).
369 IsPrivate,
370 /// Is loopback address (127.0.0.0/8 or ::1). Value controls negation.
371 IsLoopback,
372 /// Is multicast (224.0.0.0/4 or ff00::/8). Value controls negation.
373 IsMulticast,
374 /// Is link-local (169.254.0.0/16 or fe80::/10). Value controls negation.
375 IsLinkLocal,
376 /// Is documentation range (RFC 5737/3849). Value controls negation.
377 IsDocumentation,
378 /// Is carrier-grade NAT (100.64.0.0/10, RFC 6598). Value controls negation.
379 IsCarrierGrade,
380 /// In subnet (<<) - IP is contained within subnet.
381 InSubnet,
382 /// Contains subnet (>>) - subnet contains another subnet.
383 ContainsSubnet,
384 /// Contains IP (>>) - subnet contains an IP address.
385 ContainsIP,
386 /// Overlaps (&&) - subnets overlap.
387 Overlaps,
388
389 // ========================================================================
390 // JSONB Operators
391 // ========================================================================
392 /// Strictly contains (@>).
393 StrictlyContains,
394
395 // ========================================================================
396 // LTree Operators (Hierarchical)
397 // ========================================================================
398 /// Ancestor of (@>).
399 AncestorOf,
400 /// Descendant of (<@).
401 DescendantOf,
402 /// Matches lquery (~).
403 MatchesLquery,
404 /// Matches ltxtquery (@) - Boolean query syntax.
405 MatchesLtxtquery,
406 /// Matches any lquery (?).
407 MatchesAnyLquery,
408 /// Depth equal (nlevel() =).
409 DepthEq,
410 /// Depth not equal (nlevel() !=).
411 DepthNeq,
412 /// Depth greater than (nlevel() >).
413 DepthGt,
414 /// Depth greater than or equal (nlevel() >=).
415 DepthGte,
416 /// Depth less than (nlevel() <).
417 DepthLt,
418 /// Depth less than or equal (nlevel() <=).
419 DepthLte,
420 /// Lowest common ancestor (lca()).
421 Lca,
422
423 // ========================================================================
424 // LTree ID-Based Operators (resolve path from entity UUID)
425 // ========================================================================
426 /// Descendant of entity by ID: `path <@ (SELECT path FROM t WHERE id = $1)`.
427 DescendantOfId,
428 /// Ancestor of entity by ID: `path @> (SELECT path FROM t WHERE id = $1)`.
429 AncestorOfId,
430
431 // ========================================================================
432 // Extended Operators (Rich Type Filters)
433 // ========================================================================
434 /// Extended operator for rich scalar types (Email, VIN, CountryCode, etc.)
435 /// These operators are specialized filters enabled via feature flags.
436 /// See `fraiseql_core::filters::ExtendedOperator` for available operators.
437 #[serde(skip)]
438 Extended(crate::filters::ExtendedOperator),
439}
440
441impl WhereOperator {
442 /// Parse operator from string (GraphQL input).
443 ///
444 /// # Errors
445 ///
446 /// Returns `FraiseQLError::Validation` if operator name is unknown.
447 #[allow(clippy::should_implement_trait)] // Reason: intentionally not implementing `FromStr` because this returns `FraiseQLError`, not `<Self as FromStr>::Err`.
448 pub fn from_str(s: &str) -> Result<Self> {
449 if let Some(op) = Self::match_exact(s) {
450 return Ok(op);
451 }
452
453 // If the name has no underscores and contains uppercase letters, it may
454 // be a camelCase form of a registered snake_case operator. Convert and
455 // retry. This avoids allocation when the first match succeeds.
456 if !s.contains('_') && s.chars().any(char::is_uppercase) {
457 let snake = crate::utils::to_snake_case(s);
458 if let Some(op) = Self::match_exact(&snake) {
459 return Ok(op);
460 }
461 }
462
463 Err(FraiseQLError::validation(format!("Unknown WHERE operator: {s}")))
464 }
465
466 /// Match an operator name exactly against the known set.
467 fn match_exact(s: &str) -> Option<Self> {
468 match s {
469 "eq" => Some(Self::Eq),
470 "neq" => Some(Self::Neq),
471 "gt" => Some(Self::Gt),
472 "gte" => Some(Self::Gte),
473 "lt" => Some(Self::Lt),
474 "lte" => Some(Self::Lte),
475 "in" => Some(Self::In),
476 "nin" | "notin" => Some(Self::Nin),
477 "contains" => Some(Self::Contains),
478 "icontains" => Some(Self::Icontains),
479 "startswith" => Some(Self::Startswith),
480 "istartswith" => Some(Self::Istartswith),
481 "endswith" => Some(Self::Endswith),
482 "iendswith" => Some(Self::Iendswith),
483 "like" => Some(Self::Like),
484 "ilike" => Some(Self::Ilike),
485 "nlike" => Some(Self::Nlike),
486 "nilike" => Some(Self::Nilike),
487 "regex" => Some(Self::Regex),
488 "iregex" | "imatches" => Some(Self::Iregex),
489 "nregex" | "not_matches" => Some(Self::Nregex),
490 "niregex" => Some(Self::Niregex),
491 "isnull" => Some(Self::IsNull),
492 "array_contains" => Some(Self::ArrayContains),
493 "array_contained_by" => Some(Self::ArrayContainedBy),
494 "array_overlaps" => Some(Self::ArrayOverlaps),
495 "len_eq" => Some(Self::LenEq),
496 "len_gt" => Some(Self::LenGt),
497 "len_lt" => Some(Self::LenLt),
498 "len_gte" => Some(Self::LenGte),
499 "len_lte" => Some(Self::LenLte),
500 "len_neq" => Some(Self::LenNeq),
501 "cosine_distance" => Some(Self::CosineDistance),
502 "l2_distance" => Some(Self::L2Distance),
503 "l1_distance" => Some(Self::L1Distance),
504 "hamming_distance" => Some(Self::HammingDistance),
505 "inner_product" => Some(Self::InnerProduct),
506 "jaccard_distance" => Some(Self::JaccardDistance),
507 "matches" => Some(Self::Matches),
508 "plain_query" => Some(Self::PlainQuery),
509 "phrase_query" => Some(Self::PhraseQuery),
510 "websearch_query" => Some(Self::WebsearchQuery),
511 "is_ipv4" => Some(Self::IsIPv4),
512 "is_ipv6" => Some(Self::IsIPv6),
513 "is_private" => Some(Self::IsPrivate),
514 "is_loopback" => Some(Self::IsLoopback),
515 "is_multicast" => Some(Self::IsMulticast),
516 "is_link_local" => Some(Self::IsLinkLocal),
517 "is_documentation" => Some(Self::IsDocumentation),
518 "is_carrier_grade" => Some(Self::IsCarrierGrade),
519 "in_subnet" | "inrange" => Some(Self::InSubnet),
520 "contains_subnet" => Some(Self::ContainsSubnet),
521 "contains_ip" => Some(Self::ContainsIP),
522 "overlaps" => Some(Self::Overlaps),
523 "strictly_contains" => Some(Self::StrictlyContains),
524 "ancestor_of" => Some(Self::AncestorOf),
525 "descendant_of" => Some(Self::DescendantOf),
526 "matches_lquery" => Some(Self::MatchesLquery),
527 "matches_ltxtquery" => Some(Self::MatchesLtxtquery),
528 "matches_any_lquery" => Some(Self::MatchesAnyLquery),
529 "depth_eq" => Some(Self::DepthEq),
530 "depth_neq" => Some(Self::DepthNeq),
531 "depth_gt" => Some(Self::DepthGt),
532 "depth_gte" => Some(Self::DepthGte),
533 "depth_lt" => Some(Self::DepthLt),
534 "depth_lte" => Some(Self::DepthLte),
535 "lca" => Some(Self::Lca),
536 "descendant_of_id" => Some(Self::DescendantOfId),
537 "ancestor_of_id" => Some(Self::AncestorOfId),
538 _ => None,
539 }
540 }
541
542 /// Check if operator requires array value.
543 #[must_use]
544 pub const fn expects_array(&self) -> bool {
545 matches!(self, Self::In | Self::Nin)
546 }
547
548 /// Check if operator is case-insensitive.
549 #[must_use]
550 pub const fn is_case_insensitive(&self) -> bool {
551 matches!(
552 self,
553 Self::Icontains
554 | Self::Istartswith
555 | Self::Iendswith
556 | Self::Ilike
557 | Self::Nilike
558 | Self::Iregex
559 | Self::Niregex
560 )
561 }
562
563 /// Check if operator works with strings.
564 #[must_use]
565 pub const fn is_string_operator(&self) -> bool {
566 matches!(
567 self,
568 Self::Contains
569 | Self::Icontains
570 | Self::Startswith
571 | Self::Istartswith
572 | Self::Endswith
573 | Self::Iendswith
574 | Self::Like
575 | Self::Ilike
576 | Self::Nlike
577 | Self::Nilike
578 | Self::Regex
579 | Self::Iregex
580 | Self::Nregex
581 | Self::Niregex
582 )
583 }
584}
585
586/// HAVING clause abstract syntax tree.
587///
588/// HAVING filters aggregated results after GROUP BY, while WHERE filters rows before aggregation.
589///
590/// # Example
591///
592/// ```rust
593/// use fraiseql_db::{HavingClause, WhereOperator};
594/// use serde_json::json;
595///
596/// // Simple condition: COUNT(*) > 10
597/// let having_clause = HavingClause::Aggregate {
598/// aggregate: "count".to_string(),
599/// operator: WhereOperator::Gt,
600/// value: json!(10),
601/// };
602///
603/// // Complex condition: (COUNT(*) > 10) AND (SUM(revenue) >= 1000)
604/// let having_clause = HavingClause::And(vec![
605/// HavingClause::Aggregate {
606/// aggregate: "count".to_string(),
607/// operator: WhereOperator::Gt,
608/// value: json!(10),
609/// },
610/// HavingClause::Aggregate {
611/// aggregate: "revenue_sum".to_string(),
612/// operator: WhereOperator::Gte,
613/// value: json!(1000),
614/// },
615/// ]);
616/// ```
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618#[non_exhaustive]
619pub enum HavingClause {
620 /// Aggregate field condition (e.g., count_gt, revenue_sum_gte).
621 Aggregate {
622 /// Aggregate name: "count" or "field_function" (e.g., "revenue_sum").
623 aggregate: String,
624 /// Comparison operator.
625 operator: WhereOperator,
626 /// Value to compare against.
627 value: serde_json::Value,
628 },
629
630 /// Logical AND of multiple conditions.
631 And(Vec<HavingClause>),
632
633 /// Logical OR of multiple conditions.
634 Or(Vec<HavingClause>),
635
636 /// Logical NOT of a condition.
637 Not(Box<HavingClause>),
638}
639
640impl HavingClause {
641 /// Check if HAVING clause is empty.
642 #[must_use]
643 pub const fn is_empty(&self) -> bool {
644 match self {
645 Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
646 Self::Not(_) | Self::Aggregate { .. } => false,
647 }
648 }
649}
650
651#[cfg(test)]
652mod tests;