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