Skip to main content

fraiseql_core/validation/
composite.rs

1//! Composite validation rules combining multiple validators.
2//!
3//! This module provides combinators for composing validators:
4//! - `All`: All validators must pass
5//! - `Any`: At least one validator must pass
6//! - `Not`: Validator must fail (negation)
7//! - `Optional`: Validator only applies if field is present
8//!
9//! # Examples
10//!
11//! ```
12//! use fraiseql_core::validation::ValidationRule;
13//!
14//! // All validators must pass: required AND pattern
15//! let _rule = ValidationRule::All(vec![
16//!     ValidationRule::Required,
17//!     ValidationRule::Pattern { pattern: "^[a-z]+$".to_string(), message: None },
18//! ]);
19//!
20//! // At least one must pass: complex password OR long password
21//! let _rule = ValidationRule::Any(vec![
22//!     ValidationRule::Pattern { pattern: r"^(?=.*[A-Z])(?=.*[0-9]).+$".to_string(), message: None },
23//!     ValidationRule::Length { min: Some(20), max: None },
24//! ]);
25//! ```
26
27use std::fmt;
28
29use crate::{
30    error::{FraiseQLError, Result},
31    validation::rules::ValidationRule,
32};
33
34/// Composite validation error that aggregates multiple validation errors.
35#[derive(Debug, Clone)]
36pub struct CompositeError {
37    /// The operator being applied (all, any, not, optional)
38    pub operator: CompositeOperator,
39    /// Individual validation errors
40    pub errors:   Vec<String>,
41    /// The field being validated
42    pub field:    String,
43}
44
45impl fmt::Display for CompositeError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "{}", self.operator)?;
48        if !self.errors.is_empty() {
49            write!(f, ": {}", self.errors.join("; "))?;
50        }
51        Ok(())
52    }
53}
54
55/// Composite validation operators.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum CompositeOperator {
59    /// All validators must pass
60    All,
61    /// At least one validator must pass
62    Any,
63    /// Validator must fail (negation)
64    Not,
65    /// Validator only applies if field is present
66    Optional,
67}
68
69impl fmt::Display for CompositeOperator {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::All => write!(f, "All validators must pass"),
73            Self::Any => write!(f, "At least one validator must pass"),
74            Self::Not => write!(f, "Validator must fail"),
75            Self::Optional => write!(f, "Optional validation"),
76        }
77    }
78}
79
80/// Validates that all rules pass (logical AND).
81///
82/// All validators in the list must pass successfully. If any validator fails,
83/// the entire validation fails with aggregated error messages.
84///
85/// # Arguments
86/// * `rules` - List of validation rules to apply (all must pass)
87/// * `field_value` - The value being validated
88/// * `field_name` - Name of the field for error reporting
89/// * `is_present` - Whether the field is present/non-null
90///
91/// # Errors
92///
93/// Returns `FraiseQLError::Validation` if any rule fails, with all failure messages combined.
94pub fn validate_all(
95    rules: &[ValidationRule],
96    field_value: &str,
97    field_name: &str,
98    is_present: bool,
99) -> Result<()> {
100    let mut errors = Vec::new();
101
102    for rule in rules {
103        if let Err(e) = validate_single_rule(rule, field_value, field_name, is_present) {
104            errors.push(format!("{}", e));
105        }
106    }
107
108    if !errors.is_empty() {
109        return Err(FraiseQLError::Validation {
110            message: format!(
111                "All validators must pass for '{}': {}",
112                field_name,
113                errors.join("; ")
114            ),
115            path:    Some(field_name.to_string()),
116        });
117    }
118
119    Ok(())
120}
121
122/// Validates that at least one rule passes (logical OR).
123///
124/// At least one validator in the list must pass. Only if all validators fail
125/// is the entire validation considered failed.
126///
127/// # Arguments
128/// * `rules` - List of validation rules (at least one must pass)
129/// * `field_value` - The value being validated
130/// * `field_name` - Name of the field for error reporting
131/// * `is_present` - Whether the field is present/non-null
132///
133/// # Errors
134///
135/// Returns `FraiseQLError::Validation` if all rules fail, with all failure messages combined.
136pub fn validate_any(
137    rules: &[ValidationRule],
138    field_value: &str,
139    field_name: &str,
140    is_present: bool,
141) -> Result<()> {
142    let mut errors = Vec::new();
143    let mut passed_count = 0;
144
145    for rule in rules {
146        match validate_single_rule(rule, field_value, field_name, is_present) {
147            Ok(()) => {
148                passed_count += 1;
149            },
150            Err(e) => {
151                errors.push(format!("{}", e));
152            },
153        }
154    }
155
156    if passed_count == 0 {
157        return Err(FraiseQLError::Validation {
158            message: format!(
159                "At least one validator must pass for '{}': {}",
160                field_name,
161                errors.join("; ")
162            ),
163            path:    Some(field_name.to_string()),
164        });
165    }
166
167    Ok(())
168}
169
170/// Validates that a rule fails (logical NOT/negation).
171///
172/// The validator is inverted - it passes if the rule would normally fail,
173/// and fails if the rule would normally pass.
174///
175/// # Arguments
176/// * `rule` - The validation rule to negate
177/// * `field_value` - The value being validated
178/// * `field_name` - Name of the field for error reporting
179/// * `is_present` - Whether the field is present/non-null
180///
181/// # Errors
182///
183/// Returns `FraiseQLError::Validation` if the negated rule passes (when it should fail).
184pub fn validate_not(
185    rule: &ValidationRule,
186    field_value: &str,
187    field_name: &str,
188    is_present: bool,
189) -> Result<()> {
190    match validate_single_rule(rule, field_value, field_name, is_present) {
191        Ok(()) => Err(FraiseQLError::Validation {
192            message: format!("Validator for '{}' must fail but passed", field_name),
193            path:    Some(field_name.to_string()),
194        }),
195        Err(_) => Ok(()), // Validator failed as expected
196    }
197}
198
199/// Validates a rule only if the field is present.
200///
201/// If the field is absent/null, validation is skipped (passes).
202/// If the field is present, the rule is applied normally.
203///
204/// # Arguments
205/// * `rule` - The validation rule to conditionally apply
206/// * `field_value` - The value being validated
207/// * `field_name` - Name of the field for error reporting
208/// * `is_present` - Whether the field is present/non-null
209///
210/// # Errors
211///
212/// Returns `FraiseQLError::Validation` if the field is present and the rule fails.
213pub fn validate_optional(
214    rule: &ValidationRule,
215    field_value: &str,
216    field_name: &str,
217    is_present: bool,
218) -> Result<()> {
219    if !is_present {
220        return Ok(());
221    }
222
223    validate_single_rule(rule, field_value, field_name, is_present)
224}
225
226/// Validates a single rule against a field value.
227///
228/// This is a helper function that applies a basic validation rule.
229/// For complex rules, this would dispatch to specialized validators.
230fn validate_single_rule(
231    rule: &ValidationRule,
232    field_value: &str,
233    field_name: &str,
234    _is_present: bool,
235) -> Result<()> {
236    match rule {
237        ValidationRule::Required => {
238            if field_value.is_empty() {
239                return Err(FraiseQLError::Validation {
240                    message: format!("Field '{}' is required", field_name),
241                    path:    Some(field_name.to_string()),
242                });
243            }
244            Ok(())
245        },
246        ValidationRule::Pattern { pattern, message } => {
247            if let Ok(regex) = regex::Regex::new(pattern) {
248                if !regex.is_match(field_value) {
249                    return Err(FraiseQLError::Validation {
250                        message: message.clone().unwrap_or_else(|| {
251                            format!("'{}' must match pattern: {}", field_name, pattern)
252                        }),
253                        path:    Some(field_name.to_string()),
254                    });
255                }
256            }
257            Ok(())
258        },
259        ValidationRule::Length { min, max } => {
260            let len = field_value.len();
261            if let Some(min_len) = min {
262                if len < *min_len {
263                    return Err(FraiseQLError::Validation {
264                        message: format!(
265                            "'{}' must be at least {} characters",
266                            field_name, min_len
267                        ),
268                        path:    Some(field_name.to_string()),
269                    });
270                }
271            }
272            if let Some(max_len) = max {
273                if len > *max_len {
274                    return Err(FraiseQLError::Validation {
275                        message: format!("'{}' must be at most {} characters", field_name, max_len),
276                        path:    Some(field_name.to_string()),
277                    });
278                }
279            }
280            Ok(())
281        },
282        ValidationRule::Enum { values } => {
283            if !values.contains(&field_value.to_string()) {
284                return Err(FraiseQLError::Validation {
285                    message: format!("'{}' must be one of: {}", field_name, values.join(", ")),
286                    path:    Some(field_name.to_string()),
287                });
288            }
289            Ok(())
290        },
291        // For other rule types, we skip validation in this basic implementation
292        _ => Ok(()),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::validation::rules::ValidationRule;
300
301    #[test]
302    fn test_validate_all_passes() {
303        let rules = vec![
304            ValidationRule::Required,
305            ValidationRule::Length {
306                min: Some(5),
307                max: None,
308            },
309        ];
310        let result = validate_all(&rules, "hello123", "password", true);
311        result.unwrap_or_else(|e| panic!("expected all validators to pass: {e}"));
312    }
313
314    #[test]
315    fn test_validate_all_fails_first() {
316        let rules = vec![
317            ValidationRule::Required,
318            ValidationRule::Length {
319                min: Some(10),
320                max: None,
321            },
322        ];
323        let result = validate_all(&rules, "short", "password", true);
324        assert!(
325            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
326            "expected Validation error with 'All validators must pass', got: {result:?}"
327        );
328    }
329
330    #[test]
331    fn test_validate_all_fails_second() {
332        let rules = vec![
333            ValidationRule::Required,
334            ValidationRule::Pattern {
335                pattern: "^[a-z]+$".to_string(),
336                message: None,
337            },
338        ];
339        let result = validate_all(&rules, "Hello123", "username", true);
340        assert!(
341            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
342            "expected Validation error with 'All validators must pass', got: {result:?}"
343        );
344    }
345
346    #[test]
347    fn test_validate_all_multiple_failures() {
348        let rules = vec![
349            ValidationRule::Required,
350            ValidationRule::Length {
351                min: Some(10),
352                max: None,
353            },
354            ValidationRule::Pattern {
355                pattern: "^[a-z]+$".to_string(),
356                message: None,
357            },
358        ];
359        let result = validate_all(&rules, "Hi", "field", true);
360        assert!(
361            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
362            "expected Validation error with 'All validators must pass', got: {result:?}"
363        );
364    }
365
366    #[test]
367    fn test_validate_any_passes_first() {
368        let rules = vec![
369            ValidationRule::Pattern {
370                pattern: "^[a-z]+$".to_string(),
371                message: None,
372            },
373            ValidationRule::Length {
374                min: Some(20),
375                max: None,
376            },
377        ];
378        let result = validate_any(&rules, "abc", "field", true);
379        result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
380    }
381
382    #[test]
383    fn test_validate_any_passes_second() {
384        let rules = vec![
385            ValidationRule::Pattern {
386                pattern: "^[a-z]+$".to_string(),
387                message: None,
388            },
389            ValidationRule::Length {
390                min: Some(2),
391                max: None,
392            },
393        ];
394        let result = validate_any(&rules, "Hi", "field", true);
395        result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
396    }
397
398    #[test]
399    fn test_validate_any_fails_all() {
400        let rules = vec![
401            ValidationRule::Pattern {
402                pattern: "^[a-z]+$".to_string(),
403                message: None,
404            },
405            ValidationRule::Length {
406                min: Some(20),
407                max: None,
408            },
409        ];
410        let result = validate_any(&rules, "Hi", "field", true);
411        assert!(
412            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one validator must pass")),
413            "expected Validation error with 'At least one validator must pass', got: {result:?}"
414        );
415    }
416
417    #[test]
418    fn test_validate_any_multiple_passes() {
419        let rules = vec![
420            ValidationRule::Pattern {
421                pattern: "^[a-z]+$".to_string(),
422                message: None,
423            },
424            ValidationRule::Length {
425                min: Some(2),
426                max: None,
427            },
428            ValidationRule::Enum {
429                values: vec!["hello".to_string(), "world".to_string()],
430            },
431        ];
432        let result = validate_any(&rules, "hello", "field", true);
433        result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
434    }
435
436    #[test]
437    fn test_validate_not_passes_when_rule_fails() {
438        let rule = ValidationRule::Pattern {
439            pattern: "^[0-9]+$".to_string(),
440            message: None,
441        };
442        let result = validate_not(&rule, "abc", "field", true);
443        result.unwrap_or_else(|e| panic!("expected not-validator to pass when rule fails: {e}"));
444    }
445
446    #[test]
447    fn test_validate_not_fails_when_rule_passes() {
448        let rule = ValidationRule::Pattern {
449            pattern: "^[a-z]+$".to_string(),
450            message: None,
451        };
452        let result = validate_not(&rule, "abc", "field", true);
453        assert!(
454            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must fail but passed")),
455            "expected Validation error with 'must fail but passed', got: {result:?}"
456        );
457    }
458
459    #[test]
460    fn test_validate_optional_skips_absent() {
461        let rule = ValidationRule::Length {
462            min: Some(100),
463            max: None,
464        };
465        let result = validate_optional(&rule, "", "field", false);
466        result.unwrap_or_else(|e| panic!("expected optional to skip absent field: {e}"));
467    }
468
469    #[test]
470    fn test_validate_optional_applies_present() {
471        let rule = ValidationRule::Length {
472            min: Some(5),
473            max: None,
474        };
475        let result = validate_optional(&rule, "hello", "field", true);
476        result.unwrap_or_else(|e| panic!("expected optional to pass for valid present field: {e}"));
477    }
478
479    #[test]
480    fn test_validate_optional_fails_present() {
481        let rule = ValidationRule::Length {
482            min: Some(10),
483            max: None,
484        };
485        let result = validate_optional(&rule, "hi", "field", true);
486        assert!(
487            matches!(result, Err(FraiseQLError::Validation { .. })),
488            "expected Validation error for present field failing rule, got: {result:?}"
489        );
490    }
491
492    #[test]
493    fn test_composite_operator_display() {
494        assert_eq!(CompositeOperator::All.to_string(), "All validators must pass");
495        assert_eq!(CompositeOperator::Any.to_string(), "At least one validator must pass");
496        assert_eq!(CompositeOperator::Not.to_string(), "Validator must fail");
497        assert_eq!(CompositeOperator::Optional.to_string(), "Optional validation");
498    }
499
500    #[test]
501    fn test_nested_all_and_pattern() {
502        let rules = vec![
503            ValidationRule::Required,
504            ValidationRule::Length {
505                min: Some(8),
506                max: Some(20),
507            },
508            ValidationRule::Pattern {
509                pattern: "^[A-Za-z0-9]+$".to_string(),
510                message: Some("Username must be alphanumeric".to_string()),
511            },
512        ];
513        let result = validate_all(&rules, "User1234", "username", true);
514        result.unwrap_or_else(|e| panic!("expected nested all+pattern to pass: {e}"));
515    }
516
517    #[test]
518    fn test_nested_all_fails_on_length() {
519        let rules = vec![
520            ValidationRule::Required,
521            ValidationRule::Length {
522                min: Some(8),
523                max: Some(20),
524            },
525            ValidationRule::Pattern {
526                pattern: "^[A-Za-z0-9]+$".to_string(),
527                message: Some("Username must be alphanumeric".to_string()),
528            },
529        ];
530        let result = validate_all(&rules, "Hi", "username", true);
531        assert!(
532            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
533            "expected Validation error for length failure, got: {result:?}"
534        );
535    }
536
537    #[test]
538    fn test_strong_password_pattern_all() {
539        // Strong password: at least 1 uppercase, 1 lowercase, 1 digit
540        let rules = vec![
541            ValidationRule::Required,
542            ValidationRule::Length {
543                min: Some(8),
544                max: None,
545            },
546            ValidationRule::Pattern {
547                pattern: "^(?=.*[A-Z])".to_string(), // Lookahead for uppercase
548                message: Some("Must contain at least one uppercase letter".to_string()),
549            },
550        ];
551        let result = validate_all(&rules, "Password123", "password", true);
552        result.unwrap_or_else(|e| panic!("expected strong password to pass: {e}"));
553    }
554
555    #[test]
556    fn test_enum_or_pattern_any() {
557        let rules = vec![
558            ValidationRule::Enum {
559                values: vec!["admin".to_string(), "user".to_string()],
560            },
561            ValidationRule::Pattern {
562                pattern: "^guest_[0-9]+$".to_string(),
563                message: None,
564            },
565        ];
566        let result = validate_any(&rules, "guest_123", "role", true);
567        result.unwrap_or_else(|e| panic!("expected enum-or-pattern any to pass: {e}"));
568    }
569
570    #[test]
571    fn test_not_numeric_for_string_field() {
572        let rule = ValidationRule::Pattern {
573            pattern: "^[0-9]+$".to_string(),
574            message: None,
575        };
576        let result = validate_not(&rule, "abc123", "code", true);
577        // Should pass because the regex doesn't match the whole string
578        result.unwrap_or_else(|e| panic!("expected not-numeric to pass for mixed string: {e}"));
579    }
580
581    #[test]
582    fn test_composite_error_display() {
583        let error = CompositeError {
584            operator: CompositeOperator::All,
585            errors:   vec!["error1".to_string(), "error2".to_string()],
586            field:    "field".to_string(),
587        };
588        let display_str = error.to_string();
589        assert!(display_str.contains("All validators must pass"));
590        assert!(display_str.contains("error1"));
591        assert!(display_str.contains("error2"));
592    }
593
594    #[test]
595    fn test_multiple_validators_with_required() {
596        let rules = vec![ValidationRule::Required];
597        let result = validate_all(&rules, "test", "field", true);
598        result.unwrap_or_else(|e| panic!("expected required validator to pass: {e}"));
599    }
600
601    #[test]
602    fn test_empty_rules_all() {
603        let rules: Vec<ValidationRule> = vec![];
604        let result = validate_all(&rules, "test", "field", true);
605        result.unwrap_or_else(|e| panic!("expected empty all-rules to pass vacuously: {e}"));
606    }
607
608    #[test]
609    fn test_empty_rules_any() {
610        let rules: Vec<ValidationRule> = vec![];
611        let result = validate_any(&rules, "test", "field", true);
612        // Any with no rules vacuously fails (nothing passed)
613        assert!(
614            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one validator must pass")),
615            "expected Validation error for empty any-rules, got: {result:?}"
616        );
617    }
618
619    #[test]
620    fn test_length_min_max() {
621        let rule = ValidationRule::Length {
622            min: Some(5),
623            max: Some(10),
624        };
625        let result = validate_single_rule(&rule, "hello", "password", true);
626        result.unwrap_or_else(|e| panic!("expected length check to pass for 'hello': {e}"));
627
628        let result = validate_single_rule(&rule, "hi", "password", true);
629        assert!(
630            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("at least")),
631            "expected Validation error for too-short value, got: {result:?}"
632        );
633
634        let result = validate_single_rule(&rule, "this_is_too_long", "password", true);
635        assert!(
636            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("at most")),
637            "expected Validation error for too-long value, got: {result:?}"
638        );
639    }
640}