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)] // Reason: returns Option<Self> (unrecognized operators yield None), not a FromStr-compatible Result
51    pub fn from_str(s: &str) -> Option<Self> {
52        match s {
53            "<" | "lt" => Some(Self::LessThan),
54            "<=" | "lte" => Some(Self::LessEqual),
55            ">" | "gt" => Some(Self::GreaterThan),
56            ">=" | "gte" => Some(Self::GreaterEqual),
57            "==" | "eq" => Some(Self::Equal),
58            "!=" | "neq" => Some(Self::NotEqual),
59            _ => None,
60        }
61    }
62
63    /// Get the symbol for display.
64    pub const fn symbol(&self) -> &'static str {
65        match self {
66            Self::LessThan => "<",
67            Self::LessEqual => "<=",
68            Self::GreaterThan => ">",
69            Self::GreaterEqual => ">=",
70            Self::Equal => "==",
71            Self::NotEqual => "!=",
72        }
73    }
74
75    /// Get the long name for error messages.
76    pub const fn name(&self) -> &'static str {
77        match self {
78            Self::LessThan => "less than",
79            Self::LessEqual => "less than or equal to",
80            Self::GreaterThan => "greater than",
81            Self::GreaterEqual => "greater than or equal to",
82            Self::Equal => "equal to",
83            Self::NotEqual => "not equal to",
84        }
85    }
86}
87
88/// Validates a cross-field comparison between two fields.
89///
90/// Compares `left_field` with `right_field` using the given operator.
91///
92/// # Arguments
93///
94/// * `input` - The input object containing both fields
95/// * `left_field` - The name of the left field to compare
96/// * `operator` - The comparison operator
97/// * `right_field` - The name of the right field to compare against
98/// * `context_path` - Optional field path for error reporting
99///
100/// # Errors
101///
102/// Returns an error if:
103/// - Either field is missing from the input
104/// - The fields have incompatible types
105/// - The comparison fails
106pub fn validate_cross_field_comparison(
107    input: &Value,
108    left_field: &str,
109    operator: ComparisonOperator,
110    right_field: &str,
111    context_path: Option<&str>,
112) -> Result<()> {
113    let field_path = context_path.unwrap_or("input");
114
115    if let Value::Object(obj) = input {
116        let left_val = obj.get(left_field).ok_or_else(|| FraiseQLError::Validation {
117            message: format!("Field '{}' not found in input", left_field),
118            path:    Some(field_path.to_string()),
119        })?;
120
121        let right_val = obj.get(right_field).ok_or_else(|| FraiseQLError::Validation {
122            message: format!("Field '{}' not found in input", right_field),
123            path:    Some(field_path.to_string()),
124        })?;
125
126        // Skip validation if either field is null
127        if matches!(left_val, Value::Null) || matches!(right_val, Value::Null) {
128            return Ok(());
129        }
130
131        compare_values(left_val, right_val, left_field, operator, right_field, field_path)
132    } else {
133        Err(FraiseQLError::Validation {
134            message: "Input is not an object".to_string(),
135            path:    Some(field_path.to_string()),
136        })
137    }
138}
139
140/// Compare two JSON values and return result based on operator.
141fn compare_values(
142    left: &Value,
143    right: &Value,
144    left_field: &str,
145    operator: ComparisonOperator,
146    right_field: &str,
147    context_path: &str,
148) -> Result<()> {
149    let ordering = match (left, right) {
150        // Both are numbers
151        (Value::Number(l), Value::Number(r)) => {
152            let l_val = l.as_f64().unwrap_or(0.0);
153            let r_val = r.as_f64().unwrap_or(0.0);
154            if l_val < r_val {
155                Ordering::Less
156            } else if l_val > r_val {
157                Ordering::Greater
158            } else {
159                Ordering::Equal
160            }
161        },
162        // Both are strings (lexicographic comparison)
163        (Value::String(l), Value::String(r)) => l.cmp(r),
164        // Type mismatch
165        _ => {
166            return Err(FraiseQLError::Validation {
167                message: format!(
168                    "Cannot compare '{}' ({}) with '{}' ({})",
169                    left_field,
170                    value_type_name(left),
171                    right_field,
172                    value_type_name(right)
173                ),
174                path:    Some(context_path.to_string()),
175            });
176        },
177    };
178
179    let result = match operator {
180        ComparisonOperator::LessThan => matches!(ordering, Ordering::Less),
181        ComparisonOperator::LessEqual => !matches!(ordering, Ordering::Greater),
182        ComparisonOperator::GreaterThan => matches!(ordering, Ordering::Greater),
183        ComparisonOperator::GreaterEqual => !matches!(ordering, Ordering::Less),
184        ComparisonOperator::Equal => matches!(ordering, Ordering::Equal),
185        ComparisonOperator::NotEqual => !matches!(ordering, Ordering::Equal),
186    };
187
188    if !result {
189        return Err(FraiseQLError::Validation {
190            message: format!(
191                "'{}' ({}) must be {} '{}' ({})",
192                left_field,
193                value_to_string(left),
194                operator.name(),
195                right_field,
196                value_to_string(right)
197            ),
198            path:    Some(context_path.to_string()),
199        });
200    }
201
202    Ok(())
203}
204
205/// Get the type name of a JSON value.
206const fn value_type_name(val: &Value) -> &'static str {
207    match val {
208        Value::Null => "null",
209        Value::Bool(_) => "boolean",
210        Value::Number(_) => "number",
211        Value::String(_) => "string",
212        Value::Array(_) => "array",
213        Value::Object(_) => "object",
214    }
215}
216
217/// Convert a JSON value to a string for display in error messages.
218fn value_to_string(val: &Value) -> String {
219    match val {
220        Value::String(s) => format!("\"{}\"", s),
221        Value::Number(n) => n.to_string(),
222        Value::Bool(b) => b.to_string(),
223        Value::Null => "null".to_string(),
224        _ => val.to_string(),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use serde_json::json;
231
232    use super::*;
233
234    #[test]
235    fn test_operator_parsing() {
236        assert_eq!(ComparisonOperator::from_str("<"), Some(ComparisonOperator::LessThan));
237        assert_eq!(ComparisonOperator::from_str("lt"), Some(ComparisonOperator::LessThan));
238        assert_eq!(ComparisonOperator::from_str("<="), Some(ComparisonOperator::LessEqual));
239        assert_eq!(ComparisonOperator::from_str("lte"), Some(ComparisonOperator::LessEqual));
240        assert_eq!(ComparisonOperator::from_str(">"), Some(ComparisonOperator::GreaterThan));
241        assert_eq!(ComparisonOperator::from_str("gt"), Some(ComparisonOperator::GreaterThan));
242        assert_eq!(ComparisonOperator::from_str(">="), Some(ComparisonOperator::GreaterEqual));
243        assert_eq!(ComparisonOperator::from_str("gte"), Some(ComparisonOperator::GreaterEqual));
244        assert_eq!(ComparisonOperator::from_str("=="), Some(ComparisonOperator::Equal));
245        assert_eq!(ComparisonOperator::from_str("eq"), Some(ComparisonOperator::Equal));
246        assert_eq!(ComparisonOperator::from_str("!="), Some(ComparisonOperator::NotEqual));
247        assert_eq!(ComparisonOperator::from_str("neq"), Some(ComparisonOperator::NotEqual));
248        assert_eq!(ComparisonOperator::from_str("invalid"), None);
249    }
250
251    #[test]
252    fn test_numeric_less_than() {
253        let input = json!({
254            "start": 10,
255            "end": 20
256        });
257        let result = validate_cross_field_comparison(
258            &input,
259            "start",
260            ComparisonOperator::LessThan,
261            "end",
262            None,
263        );
264        result.unwrap_or_else(|e| panic!("expected 10 < 20 to pass: {e}"));
265    }
266
267    #[test]
268    fn test_numeric_less_than_fails() {
269        let input = json!({
270            "start": 30,
271            "end": 20
272        });
273        let result = validate_cross_field_comparison(
274            &input,
275            "start",
276            ComparisonOperator::LessThan,
277            "end",
278            None,
279        );
280        assert!(
281            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
282            "expected Validation error for 30 < 20, got: {result:?}"
283        );
284    }
285
286    #[test]
287    fn test_numeric_equal() {
288        let input = json!({
289            "a": 42,
290            "b": 42
291        });
292        let result =
293            validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
294        result.unwrap_or_else(|e| panic!("expected 42 == 42 to pass: {e}"));
295    }
296
297    #[test]
298    fn test_numeric_not_equal() {
299        let input = json!({
300            "a": 10,
301            "b": 20
302        });
303        let result =
304            validate_cross_field_comparison(&input, "a", ComparisonOperator::NotEqual, "b", None);
305        result.unwrap_or_else(|e| panic!("expected 10 != 20 to pass: {e}"));
306    }
307
308    #[test]
309    fn test_numeric_greater_than_or_equal() {
310        let input = json!({
311            "min": 10,
312            "max": 10
313        });
314        let result = validate_cross_field_comparison(
315            &input,
316            "max",
317            ComparisonOperator::GreaterEqual,
318            "min",
319            None,
320        );
321        result.unwrap_or_else(|e| panic!("expected 10 >= 10 to pass: {e}"));
322    }
323
324    #[test]
325    fn test_string_comparison() {
326        let input = json!({
327            "start_name": "alice",
328            "end_name": "zoe"
329        });
330        let result = validate_cross_field_comparison(
331            &input,
332            "start_name",
333            ComparisonOperator::LessThan,
334            "end_name",
335            None,
336        );
337        result.unwrap_or_else(|e| panic!("expected 'alice' < 'zoe' to pass: {e}"));
338    }
339
340    #[test]
341    fn test_string_comparison_fails() {
342        let input = json!({
343            "start_name": "zoe",
344            "end_name": "alice"
345        });
346        let result = validate_cross_field_comparison(
347            &input,
348            "start_name",
349            ComparisonOperator::LessThan,
350            "end_name",
351            None,
352        );
353        assert!(
354            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
355            "expected Validation error for 'zoe' < 'alice', got: {result:?}"
356        );
357    }
358
359    #[test]
360    fn test_date_string_comparison() {
361        let input = json!({
362            "start_date": "2024-01-01",
363            "end_date": "2024-12-31"
364        });
365        let result = validate_cross_field_comparison(
366            &input,
367            "start_date",
368            ComparisonOperator::LessThan,
369            "end_date",
370            None,
371        );
372        result.unwrap_or_else(|e| panic!("expected date string comparison to pass: {e}"));
373    }
374
375    #[test]
376    fn test_float_comparison() {
377        let input = json!({
378            "price": 19.99,
379            "budget": 25.50
380        });
381        let result = validate_cross_field_comparison(
382            &input,
383            "price",
384            ComparisonOperator::LessThan,
385            "budget",
386            None,
387        );
388        result.unwrap_or_else(|e| panic!("expected 19.99 < 25.50 to pass: {e}"));
389    }
390
391    #[test]
392    fn test_missing_left_field() {
393        let input = json!({
394            "end": 20
395        });
396        let result = validate_cross_field_comparison(
397            &input,
398            "start",
399            ComparisonOperator::LessThan,
400            "end",
401            None,
402        );
403        assert!(
404            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
405            "expected Validation error for missing left field, got: {result:?}"
406        );
407    }
408
409    #[test]
410    fn test_missing_right_field() {
411        let input = json!({
412            "start": 10
413        });
414        let result = validate_cross_field_comparison(
415            &input,
416            "start",
417            ComparisonOperator::LessThan,
418            "end",
419            None,
420        );
421        assert!(
422            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
423            "expected Validation error for missing right field, got: {result:?}"
424        );
425    }
426
427    #[test]
428    fn test_null_fields_skipped() {
429        let input = json!({
430            "start": null,
431            "end": 20
432        });
433        let result = validate_cross_field_comparison(
434            &input,
435            "start",
436            ComparisonOperator::LessThan,
437            "end",
438            None,
439        );
440        result.unwrap_or_else(|e| panic!("expected null field to be skipped: {e}"));
441    }
442
443    #[test]
444    fn test_both_null_fields_skipped() {
445        let input = json!({
446            "start": null,
447            "end": null
448        });
449        let result = validate_cross_field_comparison(
450            &input,
451            "start",
452            ComparisonOperator::LessThan,
453            "end",
454            None,
455        );
456        result.unwrap_or_else(|e| panic!("expected both null fields to be skipped: {e}"));
457    }
458
459    #[test]
460    fn test_type_mismatch_error() {
461        let input = json!({
462            "start": 10,
463            "end": "twenty"
464        });
465        let result = validate_cross_field_comparison(
466            &input,
467            "start",
468            ComparisonOperator::LessThan,
469            "end",
470            None,
471        );
472        assert!(
473            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Cannot compare")),
474            "expected Validation error for type mismatch, got: {result:?}"
475        );
476    }
477
478    #[test]
479    fn test_error_includes_context_path() {
480        let input = json!({
481            "start": 30,
482            "end": 20
483        });
484        let result = validate_cross_field_comparison(
485            &input,
486            "start",
487            ComparisonOperator::LessThan,
488            "end",
489            Some("dateRange"),
490        );
491        assert!(
492            matches!(result, Err(FraiseQLError::Validation { ref path, .. }) if *path == Some("dateRange".to_string())),
493            "expected Validation error with path 'dateRange', got: {result:?}"
494        );
495    }
496
497    #[test]
498    fn test_error_message_includes_values() {
499        let input = json!({
500            "price": 100,
501            "max_price": 50
502        });
503        let result = validate_cross_field_comparison(
504            &input,
505            "price",
506            ComparisonOperator::LessThan,
507            "max_price",
508            None,
509        );
510        assert!(
511            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("price") && message.contains("max_price") && message.contains("100") && message.contains("50")),
512            "expected Validation error with field names and values, got: {result:?}"
513        );
514    }
515
516    #[test]
517    fn test_all_operators() {
518        let test_cases = vec![
519            (10, 20, ComparisonOperator::LessThan, true),
520            (10, 10, ComparisonOperator::LessEqual, true),
521            (20, 10, ComparisonOperator::GreaterThan, true),
522            (10, 10, ComparisonOperator::GreaterEqual, true),
523            (42, 42, ComparisonOperator::Equal, true),
524            (10, 20, ComparisonOperator::NotEqual, true),
525            (20, 10, ComparisonOperator::LessThan, false),
526            (10, 20, ComparisonOperator::GreaterThan, false),
527        ];
528
529        for (left, right, op, should_pass) in test_cases {
530            let input = json!({ "a": left, "b": right });
531            let result = validate_cross_field_comparison(&input, "a", op, "b", None);
532            assert_eq!(
533                result.is_ok(),
534                should_pass,
535                "Failed for {} {} {}",
536                left,
537                op.symbol(),
538                right
539            );
540        }
541    }
542
543    #[test]
544    fn test_non_object_input() {
545        let input = json!([1, 2, 3]);
546        let result =
547            validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
548        assert!(
549            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not an object")),
550            "expected Validation error for non-object input, got: {result:?}"
551        );
552    }
553
554    #[test]
555    fn test_empty_object() {
556        let input = json!({});
557        let result = validate_cross_field_comparison(
558            &input,
559            "start",
560            ComparisonOperator::LessThan,
561            "end",
562            None,
563        );
564        assert!(
565            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
566            "expected Validation error for empty object, got: {result:?}"
567        );
568    }
569
570    #[test]
571    fn test_zero_comparison() {
572        let input = json!({
573            "a": 0,
574            "b": 0
575        });
576        let result =
577            validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
578        result.unwrap_or_else(|e| panic!("expected 0 == 0 to pass: {e}"));
579    }
580
581    #[test]
582    fn test_negative_number_comparison() {
583        let input = json!({
584            "a": -10,
585            "b": 5
586        });
587        let result =
588            validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
589        result.unwrap_or_else(|e| panic!("expected -10 < 5 to pass: {e}"));
590    }
591
592    #[test]
593    fn test_empty_string_comparison() {
594        let input = json!({
595            "a": "",
596            "b": "text"
597        });
598        let result =
599            validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
600        result.unwrap_or_else(|e| panic!("expected '' < 'text' to pass: {e}"));
601    }
602}