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).
369 IsPrivate,
370 /// Is public IP (not private).
371 IsPublic,
372 /// Is loopback address (127.0.0.0/8 or ::1).
373 IsLoopback,
374 /// In subnet (<<) - IP is contained within subnet.
375 InSubnet,
376 /// Contains subnet (>>) - subnet contains another subnet.
377 ContainsSubnet,
378 /// Contains IP (>>) - subnet contains an IP address.
379 ContainsIP,
380 /// Overlaps (&&) - subnets overlap.
381 Overlaps,
382
383 // ========================================================================
384 // JSONB Operators
385 // ========================================================================
386 /// Strictly contains (@>).
387 StrictlyContains,
388
389 // ========================================================================
390 // LTree Operators (Hierarchical)
391 // ========================================================================
392 /// Ancestor of (@>).
393 AncestorOf,
394 /// Descendant of (<@).
395 DescendantOf,
396 /// Matches lquery (~).
397 MatchesLquery,
398 /// Matches ltxtquery (@) - Boolean query syntax.
399 MatchesLtxtquery,
400 /// Matches any lquery (?).
401 MatchesAnyLquery,
402 /// Depth equal (nlevel() =).
403 DepthEq,
404 /// Depth not equal (nlevel() !=).
405 DepthNeq,
406 /// Depth greater than (nlevel() >).
407 DepthGt,
408 /// Depth greater than or equal (nlevel() >=).
409 DepthGte,
410 /// Depth less than (nlevel() <).
411 DepthLt,
412 /// Depth less than or equal (nlevel() <=).
413 DepthLte,
414 /// Lowest common ancestor (lca()).
415 Lca,
416
417 // ========================================================================
418 // Extended Operators (Rich Type Filters)
419 // ========================================================================
420 /// Extended operator for rich scalar types (Email, VIN, CountryCode, etc.)
421 /// These operators are specialized filters enabled via feature flags.
422 /// See `fraiseql_core::filters::ExtendedOperator` for available operators.
423 #[serde(skip)]
424 Extended(crate::filters::ExtendedOperator),
425}
426
427impl WhereOperator {
428 /// Parse operator from string (GraphQL input).
429 ///
430 /// # Errors
431 ///
432 /// Returns `FraiseQLError::Validation` if operator name is unknown.
433 #[allow(clippy::should_implement_trait)] // Reason: intentionally not implementing `FromStr` because this returns `FraiseQLError`, not `<Self as FromStr>::Err`.
434 pub fn from_str(s: &str) -> Result<Self> {
435 match s {
436 "eq" => Ok(Self::Eq),
437 "neq" => Ok(Self::Neq),
438 "gt" => Ok(Self::Gt),
439 "gte" => Ok(Self::Gte),
440 "lt" => Ok(Self::Lt),
441 "lte" => Ok(Self::Lte),
442 "in" => Ok(Self::In),
443 "nin" | "notin" => Ok(Self::Nin),
444 "contains" => Ok(Self::Contains),
445 "icontains" => Ok(Self::Icontains),
446 "startswith" => Ok(Self::Startswith),
447 "istartswith" => Ok(Self::Istartswith),
448 "endswith" => Ok(Self::Endswith),
449 "iendswith" => Ok(Self::Iendswith),
450 "like" => Ok(Self::Like),
451 "ilike" => Ok(Self::Ilike),
452 "nlike" => Ok(Self::Nlike),
453 "nilike" => Ok(Self::Nilike),
454 "regex" => Ok(Self::Regex),
455 "iregex" | "imatches" => Ok(Self::Iregex),
456 "nregex" | "not_matches" => Ok(Self::Nregex),
457 "niregex" => Ok(Self::Niregex),
458 "isnull" => Ok(Self::IsNull),
459 "array_contains" => Ok(Self::ArrayContains),
460 "array_contained_by" => Ok(Self::ArrayContainedBy),
461 "array_overlaps" => Ok(Self::ArrayOverlaps),
462 "len_eq" => Ok(Self::LenEq),
463 "len_gt" => Ok(Self::LenGt),
464 "len_lt" => Ok(Self::LenLt),
465 "len_gte" => Ok(Self::LenGte),
466 "len_lte" => Ok(Self::LenLte),
467 "len_neq" => Ok(Self::LenNeq),
468 "cosine_distance" => Ok(Self::CosineDistance),
469 "l2_distance" => Ok(Self::L2Distance),
470 "l1_distance" => Ok(Self::L1Distance),
471 "hamming_distance" => Ok(Self::HammingDistance),
472 "inner_product" => Ok(Self::InnerProduct),
473 "jaccard_distance" => Ok(Self::JaccardDistance),
474 "matches" => Ok(Self::Matches),
475 "plain_query" => Ok(Self::PlainQuery),
476 "phrase_query" => Ok(Self::PhraseQuery),
477 "websearch_query" => Ok(Self::WebsearchQuery),
478 "is_ipv4" => Ok(Self::IsIPv4),
479 "is_ipv6" => Ok(Self::IsIPv6),
480 "is_private" => Ok(Self::IsPrivate),
481 "is_public" => Ok(Self::IsPublic),
482 "is_loopback" => Ok(Self::IsLoopback),
483 "in_subnet" | "inrange" => Ok(Self::InSubnet),
484 "contains_subnet" => Ok(Self::ContainsSubnet),
485 "contains_ip" => Ok(Self::ContainsIP),
486 "overlaps" => Ok(Self::Overlaps),
487 "strictly_contains" => Ok(Self::StrictlyContains),
488 "ancestor_of" => Ok(Self::AncestorOf),
489 "descendant_of" => Ok(Self::DescendantOf),
490 "matches_lquery" => Ok(Self::MatchesLquery),
491 "matches_ltxtquery" => Ok(Self::MatchesLtxtquery),
492 "matches_any_lquery" => Ok(Self::MatchesAnyLquery),
493 "depth_eq" => Ok(Self::DepthEq),
494 "depth_neq" => Ok(Self::DepthNeq),
495 "depth_gt" => Ok(Self::DepthGt),
496 "depth_gte" => Ok(Self::DepthGte),
497 "depth_lt" => Ok(Self::DepthLt),
498 "depth_lte" => Ok(Self::DepthLte),
499 "lca" => Ok(Self::Lca),
500 _ => Err(FraiseQLError::validation(format!("Unknown WHERE operator: {s}"))),
501 }
502 }
503
504 /// Check if operator requires array value.
505 #[must_use]
506 pub const fn expects_array(&self) -> bool {
507 matches!(self, Self::In | Self::Nin)
508 }
509
510 /// Check if operator is case-insensitive.
511 #[must_use]
512 pub const fn is_case_insensitive(&self) -> bool {
513 matches!(
514 self,
515 Self::Icontains
516 | Self::Istartswith
517 | Self::Iendswith
518 | Self::Ilike
519 | Self::Nilike
520 | Self::Iregex
521 | Self::Niregex
522 )
523 }
524
525 /// Check if operator works with strings.
526 #[must_use]
527 pub const fn is_string_operator(&self) -> bool {
528 matches!(
529 self,
530 Self::Contains
531 | Self::Icontains
532 | Self::Startswith
533 | Self::Istartswith
534 | Self::Endswith
535 | Self::Iendswith
536 | Self::Like
537 | Self::Ilike
538 | Self::Nlike
539 | Self::Nilike
540 | Self::Regex
541 | Self::Iregex
542 | Self::Nregex
543 | Self::Niregex
544 )
545 }
546}
547
548/// HAVING clause abstract syntax tree.
549///
550/// HAVING filters aggregated results after GROUP BY, while WHERE filters rows before aggregation.
551///
552/// # Example
553///
554/// ```rust
555/// use fraiseql_db::{HavingClause, WhereOperator};
556/// use serde_json::json;
557///
558/// // Simple condition: COUNT(*) > 10
559/// let having_clause = HavingClause::Aggregate {
560/// aggregate: "count".to_string(),
561/// operator: WhereOperator::Gt,
562/// value: json!(10),
563/// };
564///
565/// // Complex condition: (COUNT(*) > 10) AND (SUM(revenue) >= 1000)
566/// let having_clause = HavingClause::And(vec![
567/// HavingClause::Aggregate {
568/// aggregate: "count".to_string(),
569/// operator: WhereOperator::Gt,
570/// value: json!(10),
571/// },
572/// HavingClause::Aggregate {
573/// aggregate: "revenue_sum".to_string(),
574/// operator: WhereOperator::Gte,
575/// value: json!(1000),
576/// },
577/// ]);
578/// ```
579#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
580#[non_exhaustive]
581pub enum HavingClause {
582 /// Aggregate field condition (e.g., count_gt, revenue_sum_gte).
583 Aggregate {
584 /// Aggregate name: "count" or "field_function" (e.g., "revenue_sum").
585 aggregate: String,
586 /// Comparison operator.
587 operator: WhereOperator,
588 /// Value to compare against.
589 value: serde_json::Value,
590 },
591
592 /// Logical AND of multiple conditions.
593 And(Vec<HavingClause>),
594
595 /// Logical OR of multiple conditions.
596 Or(Vec<HavingClause>),
597
598 /// Logical NOT of a condition.
599 Not(Box<HavingClause>),
600}
601
602impl HavingClause {
603 /// Check if HAVING clause is empty.
604 #[must_use]
605 pub const fn is_empty(&self) -> bool {
606 match self {
607 Self::And(clauses) | Self::Or(clauses) => clauses.is_empty(),
608 Self::Not(_) | Self::Aggregate { .. } => false,
609 }
610 }
611}
612
613#[cfg(test)]
614#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
615mod tests {
616 use serde_json::json;
617
618 use super::*;
619
620 #[test]
621 fn test_where_operator_from_str() {
622 assert_eq!(WhereOperator::from_str("eq").unwrap(), WhereOperator::Eq);
623 assert_eq!(WhereOperator::from_str("icontains").unwrap(), WhereOperator::Icontains);
624 assert_eq!(WhereOperator::from_str("gte").unwrap(), WhereOperator::Gte);
625 assert!(
626 matches!(WhereOperator::from_str("unknown"), Err(FraiseQLError::Validation { .. })),
627 "expected Validation error for unknown operator"
628 );
629 }
630
631 #[test]
632 fn test_where_operator_expects_array() {
633 assert!(WhereOperator::In.expects_array());
634 assert!(WhereOperator::Nin.expects_array());
635 assert!(!WhereOperator::Eq.expects_array());
636 }
637
638 #[test]
639 fn test_where_operator_is_case_insensitive() {
640 assert!(WhereOperator::Icontains.is_case_insensitive());
641 assert!(WhereOperator::Ilike.is_case_insensitive());
642 assert!(!WhereOperator::Contains.is_case_insensitive());
643 }
644
645 #[test]
646 fn test_where_clause_simple() {
647 let clause = WhereClause::Field {
648 path: vec!["email".to_string()],
649 operator: WhereOperator::Eq,
650 value: json!("test@example.com"),
651 };
652
653 assert!(!clause.is_empty());
654 }
655
656 #[test]
657 fn test_where_clause_and() {
658 let clause = WhereClause::And(vec![
659 WhereClause::Field {
660 path: vec!["published".to_string()],
661 operator: WhereOperator::Eq,
662 value: json!(true),
663 },
664 WhereClause::Field {
665 path: vec!["views".to_string()],
666 operator: WhereOperator::Gte,
667 value: json!(100),
668 },
669 ]);
670
671 assert!(!clause.is_empty());
672 }
673
674 #[test]
675 fn test_where_clause_empty() {
676 let clause = WhereClause::And(vec![]);
677 assert!(clause.is_empty());
678 }
679
680 #[test]
681 fn test_from_graphql_json_simple_field() {
682 let json = json!({ "status": { "eq": "active" } });
683 let clause = WhereClause::from_graphql_json(&json).unwrap();
684 assert_eq!(
685 clause,
686 WhereClause::Field {
687 path: vec!["status".to_string()],
688 operator: WhereOperator::Eq,
689 value: json!("active"),
690 }
691 );
692 }
693
694 #[test]
695 fn test_from_graphql_json_camelcase_field_normalized_to_snake_case() {
696 let json = json!({ "ipAddress": { "eq": "10.0.0.1" } });
697 let clause = WhereClause::from_graphql_json(&json).unwrap();
698 assert_eq!(
699 clause,
700 WhereClause::Field {
701 path: vec!["ip_address".to_string()],
702 operator: WhereOperator::Eq,
703 value: json!("10.0.0.1"),
704 }
705 );
706 }
707
708 #[test]
709 fn test_from_graphql_json_snake_case_field_unchanged() {
710 let json = json!({ "ip_address": { "eq": "10.0.0.1" } });
711 let clause = WhereClause::from_graphql_json(&json).unwrap();
712 assert_eq!(
713 clause,
714 WhereClause::Field {
715 path: vec!["ip_address".to_string()],
716 operator: WhereOperator::Eq,
717 value: json!("10.0.0.1"),
718 }
719 );
720 }
721
722 #[test]
723 fn test_from_graphql_json_multiple_fields() {
724 let json = json!({
725 "status": { "eq": "active" },
726 "age": { "gte": 18 }
727 });
728 let clause = WhereClause::from_graphql_json(&json).unwrap();
729 match clause {
730 WhereClause::And(conditions) => assert_eq!(conditions.len(), 2),
731 _ => panic!("expected And"),
732 }
733 }
734
735 #[test]
736 fn test_from_graphql_json_logical_combinators() {
737 let json = json!({
738 "_or": [
739 { "role": { "eq": "admin" } },
740 { "role": { "eq": "superadmin" } }
741 ]
742 });
743 let clause = WhereClause::from_graphql_json(&json).unwrap();
744 match clause {
745 WhereClause::Or(conditions) => assert_eq!(conditions.len(), 2),
746 _ => panic!("expected Or"),
747 }
748 }
749
750 #[test]
751 fn test_from_graphql_json_not() {
752 let json = json!({ "_not": { "deleted": { "eq": true } } });
753 let clause = WhereClause::from_graphql_json(&json).unwrap();
754 assert!(matches!(clause, WhereClause::Not(_)));
755 }
756
757 #[test]
758 fn test_from_graphql_json_invalid_operator() {
759 let json = json!({ "field": { "nonexistent_op": 42 } });
760 let result = WhereClause::from_graphql_json(&json);
761 assert!(
762 matches!(result, Err(FraiseQLError::Validation { .. })),
763 "expected Validation error, got: {result:?}"
764 );
765 }
766
767 // ── Nested relation WHERE tests (issue #196) ─────────────────────────────
768
769 #[test]
770 fn test_nested_relation_where_builds_path() {
771 let json = json!({ "machine": { "id": { "eq": "abc" } } });
772 let clause = WhereClause::from_graphql_json(&json).unwrap();
773 assert_eq!(
774 clause,
775 WhereClause::Field {
776 path: vec!["machine".to_string(), "id".to_string()],
777 operator: WhereOperator::Eq,
778 value: json!("abc"),
779 }
780 );
781 }
782
783 #[test]
784 fn test_nested_relation_where_camelcase_normalized() {
785 let json = json!({ "machineGroup": { "ipAddress": { "eq": "10.0.0.1" } } });
786 let clause = WhereClause::from_graphql_json(&json).unwrap();
787 assert_eq!(
788 clause,
789 WhereClause::Field {
790 path: vec!["machine_group".to_string(), "ip_address".to_string()],
791 operator: WhereOperator::Eq,
792 value: json!("10.0.0.1"),
793 }
794 );
795 }
796
797 #[test]
798 fn test_nested_relation_where_multiple_operators() {
799 let json =
800 json!({ "machine": { "id": { "eq": "abc" } , "name": { "icontains": "test" } } });
801 let clause = WhereClause::from_graphql_json(&json).unwrap();
802 // Two nested fields → AND combination
803 match clause {
804 WhereClause::And(conditions) => {
805 assert_eq!(conditions.len(), 2);
806 // Both should have path ["machine", ...]
807 for cond in &conditions {
808 match cond {
809 WhereClause::Field { path, .. } => {
810 assert_eq!(path[0], "machine");
811 },
812 other => panic!("expected Field, got {other:?}"),
813 }
814 }
815 },
816 _ => panic!("expected And for multiple nested conditions"),
817 }
818 }
819
820 #[test]
821 fn test_unknown_operator_still_errors() {
822 // "bogus" is neither a known operator nor a valid nested field (its value is
823 // a plain string, not an object), so the recursion hits the "must be an
824 // object" validation.
825 let json = json!({ "name": { "bogus": "value" } });
826 assert!(WhereClause::from_graphql_json(&json).is_err());
827 }
828
829 #[test]
830 fn test_new_string_operators_from_str() {
831 assert_eq!(WhereOperator::from_str("nlike").unwrap(), WhereOperator::Nlike);
832 assert_eq!(WhereOperator::from_str("nilike").unwrap(), WhereOperator::Nilike);
833 assert_eq!(WhereOperator::from_str("regex").unwrap(), WhereOperator::Regex);
834 assert_eq!(WhereOperator::from_str("iregex").unwrap(), WhereOperator::Iregex);
835 assert_eq!(WhereOperator::from_str("nregex").unwrap(), WhereOperator::Nregex);
836 assert_eq!(WhereOperator::from_str("niregex").unwrap(), WhereOperator::Niregex);
837 }
838
839 #[test]
840 fn test_v1_aliases_from_str() {
841 // notin → Nin
842 assert_eq!(WhereOperator::from_str("notin").unwrap(), WhereOperator::Nin);
843 // inrange → InSubnet
844 assert_eq!(WhereOperator::from_str("inrange").unwrap(), WhereOperator::InSubnet);
845 // imatches → Iregex
846 assert_eq!(WhereOperator::from_str("imatches").unwrap(), WhereOperator::Iregex);
847 // not_matches → Nregex
848 assert_eq!(WhereOperator::from_str("not_matches").unwrap(), WhereOperator::Nregex);
849 }
850
851 #[test]
852 fn test_new_operators_case_insensitive_flag() {
853 assert!(WhereOperator::Nilike.is_case_insensitive());
854 assert!(WhereOperator::Iregex.is_case_insensitive());
855 assert!(WhereOperator::Niregex.is_case_insensitive());
856 assert!(!WhereOperator::Nlike.is_case_insensitive());
857 assert!(!WhereOperator::Regex.is_case_insensitive());
858 assert!(!WhereOperator::Nregex.is_case_insensitive());
859 }
860
861 #[test]
862 fn test_nested_relation_filter_builds_multi_segment_path() {
863 // where: { machine: { id: { eq: "some-uuid" } } }
864 let json = json!({ "machine": { "id": { "eq": "some-uuid" } } });
865 let clause = WhereClause::from_graphql_json(&json).unwrap();
866 assert_eq!(
867 clause,
868 WhereClause::Field {
869 path: vec!["machine".to_string(), "id".to_string()],
870 operator: WhereOperator::Eq,
871 value: json!("some-uuid"),
872 }
873 );
874 }
875
876 #[test]
877 fn test_nested_relation_filter_multiple_fields() {
878 // where: { machine: { id: { eq: "uuid" }, name: { contains: "x" } } }
879 let json = json!({ "machine": { "id": { "eq": "uuid" }, "name": { "contains": "x" } } });
880 let clause = WhereClause::from_graphql_json(&json).unwrap();
881 match clause {
882 WhereClause::And(conditions) => {
883 assert_eq!(conditions.len(), 2);
884 assert!(
885 conditions.iter().all(|c| matches!(c, WhereClause::Field { .. })),
886 "all conditions should be Field with multi-segment paths"
887 );
888 },
889 other => panic!("expected And of Fields, got: {other:?}"),
890 }
891 }
892
893 #[test]
894 fn test_deeply_nested_filter_builds_three_segment_path() {
895 // where: { items: { product: { category: { eq: "electronics" } } } }
896 let json = json!({ "items": { "product": { "category": { "eq": "electronics" } } } });
897 let clause = WhereClause::from_graphql_json(&json).unwrap();
898 assert_eq!(
899 clause,
900 WhereClause::Field {
901 path: vec![
902 "items".to_string(),
903 "product".to_string(),
904 "category".to_string(),
905 ],
906 operator: WhereOperator::Eq,
907 value: json!("electronics"),
908 }
909 );
910 }
911
912 #[test]
913 fn test_unknown_operator_scalar_value_still_errors() {
914 // A truly unknown operator with a scalar value should still give the
915 // original "Unknown WHERE operator" error, not the nested relation hint.
916 let json = json!({ "field": { "nonexistent_op": 42 } });
917 let result = WhereClause::from_graphql_json(&json);
918 match result {
919 Err(FraiseQLError::Validation { message, .. }) => {
920 assert!(
921 message.contains("Unknown WHERE operator"),
922 "expected unknown operator error, got: {message}"
923 );
924 },
925 other => panic!("expected Validation error, got: {other:?}"),
926 }
927 }
928
929 #[test]
930 fn test_new_operators_are_string_operators() {
931 assert!(WhereOperator::Nlike.is_string_operator());
932 assert!(WhereOperator::Nilike.is_string_operator());
933 assert!(WhereOperator::Regex.is_string_operator());
934 assert!(WhereOperator::Iregex.is_string_operator());
935 assert!(WhereOperator::Nregex.is_string_operator());
936 assert!(WhereOperator::Niregex.is_string_operator());
937 }
938}