Skip to main content

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}