Skip to main content

fraiseql_core/validation/
cross_field.rs

1//! Cross-field comparison validators.
2//!
3//! This module provides validators for comparing values between two fields in an input object.
4//! Supports operators: <, <=, >, >=, ==, !=
5//!
6//! # Examples
7//!
8//! ```
9//! use fraiseql_core::validation::ValidationRule;
10//!
11//! // Date range validation: start_date < end_date
12//! let _rule = ValidationRule::CrossField {
13//!     field: "end_date".to_string(),
14//!     operator: "gt".to_string(),
15//! };
16//!
17//! // Numeric range: min < max
18//! let _rule = ValidationRule::CrossField {
19//!     field: "max_value".to_string(),
20//!     operator: "lt".to_string(),
21//! };
22//! ```
23
24use std::cmp::Ordering;
25
26use serde_json::Value;
27
28use crate::error::{FraiseQLError, Result};
29
30/// Operators supported for cross-field comparison.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum ComparisonOperator {
34    /// Less than (<)
35    LessThan,
36    /// Less than or equal (<=)
37    LessEqual,
38    /// Greater than (>)
39    GreaterThan,
40    /// Greater than or equal (>=)
41    GreaterEqual,
42    /// Equal (==)
43    Equal,
44    /// Not equal (!=)
45    NotEqual,
46}
47
48impl ComparisonOperator {
49    /// Parse operator from string representation.
50    #[allow(clippy::should_implement_trait)]
51    // Reason: returns Option<Self> (unrecognized operators yield None), not a FromStr-compatible
52    // Result
53    #[must_use]
54    pub fn from_str(s: &str) -> Option<Self> {
55        match s {
56            "<" | "lt" => Some(Self::LessThan),
57            "<=" | "lte" => Some(Self::LessEqual),
58            ">" | "gt" => Some(Self::GreaterThan),
59            ">=" | "gte" => Some(Self::GreaterEqual),
60            "==" | "eq" => Some(Self::Equal),
61            "!=" | "neq" => Some(Self::NotEqual),
62            _ => None,
63        }
64    }
65
66    /// Get the symbol for display.
67    #[must_use]
68    pub const fn symbol(&self) -> &'static str {
69        match self {
70            Self::LessThan => "<",
71            Self::LessEqual => "<=",
72            Self::GreaterThan => ">",
73            Self::GreaterEqual => ">=",
74            Self::Equal => "==",
75            Self::NotEqual => "!=",
76        }
77    }
78
79    /// Get the long name for error messages.
80    #[must_use]
81    pub const fn name(&self) -> &'static str {
82        match self {
83            Self::LessThan => "less than",
84            Self::LessEqual => "less than or equal to",
85            Self::GreaterThan => "greater than",
86            Self::GreaterEqual => "greater than or equal to",
87            Self::Equal => "equal to",
88            Self::NotEqual => "not equal to",
89        }
90    }
91}
92
93/// Validates a cross-field comparison between two fields.
94///
95/// Compares `left_field` with `right_field` using the given operator.
96///
97/// # Arguments
98///
99/// * `input` - The input object containing both fields
100/// * `left_field` - The name of the left field to compare
101/// * `operator` - The comparison operator
102/// * `right_field` - The name of the right field to compare against
103/// * `context_path` - Optional field path for error reporting
104///
105/// # Errors
106///
107/// Returns an error if:
108/// - Either field is missing from the input
109/// - The fields have incompatible types
110/// - The comparison fails
111pub fn validate_cross_field_comparison(
112    input: &Value,
113    left_field: &str,
114    operator: ComparisonOperator,
115    right_field: &str,
116    context_path: Option<&str>,
117) -> Result<()> {
118    let field_path = context_path.unwrap_or("input");
119
120    if let Value::Object(obj) = input {
121        let left_val = obj.get(left_field).ok_or_else(|| FraiseQLError::Validation {
122            message: format!("Field '{}' not found in input", left_field),
123            path:    Some(field_path.to_string()),
124        })?;
125
126        let right_val = obj.get(right_field).ok_or_else(|| FraiseQLError::Validation {
127            message: format!("Field '{}' not found in input", right_field),
128            path:    Some(field_path.to_string()),
129        })?;
130
131        // Skip validation if either field is null
132        if matches!(left_val, Value::Null) || matches!(right_val, Value::Null) {
133            return Ok(());
134        }
135
136        compare_values(left_val, right_val, left_field, operator, right_field, field_path)
137    } else {
138        Err(FraiseQLError::Validation {
139            message: "Input is not an object".to_string(),
140            path:    Some(field_path.to_string()),
141        })
142    }
143}
144
145/// Compare two JSON values and return result based on operator.
146fn compare_values(
147    left: &Value,
148    right: &Value,
149    left_field: &str,
150    operator: ComparisonOperator,
151    right_field: &str,
152    context_path: &str,
153) -> Result<()> {
154    let ordering = match (left, right) {
155        // Both are numbers
156        (Value::Number(l), Value::Number(r)) => {
157            let l_val = l.as_f64().unwrap_or(0.0);
158            let r_val = r.as_f64().unwrap_or(0.0);
159            if l_val < r_val {
160                Ordering::Less
161            } else if l_val > r_val {
162                Ordering::Greater
163            } else {
164                Ordering::Equal
165            }
166        },
167        // Both are strings (lexicographic comparison)
168        (Value::String(l), Value::String(r)) => l.cmp(r),
169        // Type mismatch
170        _ => {
171            return Err(FraiseQLError::Validation {
172                message: format!(
173                    "Cannot compare '{}' ({}) with '{}' ({})",
174                    left_field,
175                    value_type_name(left),
176                    right_field,
177                    value_type_name(right)
178                ),
179                path:    Some(context_path.to_string()),
180            });
181        },
182    };
183
184    let result = match operator {
185        ComparisonOperator::LessThan => matches!(ordering, Ordering::Less),
186        ComparisonOperator::LessEqual => !matches!(ordering, Ordering::Greater),
187        ComparisonOperator::GreaterThan => matches!(ordering, Ordering::Greater),
188        ComparisonOperator::GreaterEqual => !matches!(ordering, Ordering::Less),
189        ComparisonOperator::Equal => matches!(ordering, Ordering::Equal),
190        ComparisonOperator::NotEqual => !matches!(ordering, Ordering::Equal),
191    };
192
193    if !result {
194        return Err(FraiseQLError::Validation {
195            message: format!(
196                "'{}' ({}) must be {} '{}' ({})",
197                left_field,
198                value_to_string(left),
199                operator.name(),
200                right_field,
201                value_to_string(right)
202            ),
203            path:    Some(context_path.to_string()),
204        });
205    }
206
207    Ok(())
208}
209
210/// Get the type name of a JSON value.
211const fn value_type_name(val: &Value) -> &'static str {
212    match val {
213        Value::Null => "null",
214        Value::Bool(_) => "boolean",
215        Value::Number(_) => "number",
216        Value::String(_) => "string",
217        Value::Array(_) => "array",
218        Value::Object(_) => "object",
219    }
220}
221
222/// Convert a JSON value to a string for display in error messages.
223fn value_to_string(val: &Value) -> String {
224    match val {
225        Value::String(s) => format!("\"{}\"", s),
226        Value::Number(n) => n.to_string(),
227        Value::Bool(b) => b.to_string(),
228        Value::Null => "null".to_string(),
229        _ => val.to_string(),
230    }
231}