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::{CompiledPattern, ValidationRule};
13//!
14//! // All validators must pass: required AND pattern
15//! let _rule = ValidationRule::All(vec![
16//! ValidationRule::Required,
17//! ValidationRule::Pattern {
18//! pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
19//! message: None,
20//! },
21//! ]);
22//!
23//! // At least one must pass: alphanumeric short word OR long password
24//! // (Note: the `regex` crate does not support look-ahead, so we use a plain
25//! // anchored pattern instead of the `(?=.*[A-Z])(?=.*[0-9])` form.)
26//! let _rule = ValidationRule::Any(vec![
27//! ValidationRule::Pattern {
28//! pattern: CompiledPattern::new(r"^[A-Za-z0-9]+$").expect("valid regex"),
29//! message: None,
30//! },
31//! ValidationRule::Length { min: Some(20), max: None },
32//! ]);
33//! ```
34
35use std::fmt;
36
37use crate::{
38 error::{FraiseQLError, Result},
39 validation::rules::ValidationRule,
40};
41
42/// Composite validation error that aggregates multiple validation errors.
43#[derive(Debug, Clone)]
44pub struct CompositeError {
45 /// The operator being applied (all, any, not, optional)
46 pub operator: CompositeOperator,
47 /// Individual validation errors
48 pub errors: Vec<String>,
49 /// The field being validated
50 pub field: String,
51}
52
53impl fmt::Display for CompositeError {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(f, "{}", self.operator)?;
56 if !self.errors.is_empty() {
57 write!(f, ": {}", self.errors.join("; "))?;
58 }
59 Ok(())
60 }
61}
62
63/// Composite validation operators.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum CompositeOperator {
67 /// All validators must pass
68 All,
69 /// At least one validator must pass
70 Any,
71 /// Validator must fail (negation)
72 Not,
73 /// Validator only applies if field is present
74 Optional,
75}
76
77impl fmt::Display for CompositeOperator {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Self::All => write!(f, "All validators must pass"),
81 Self::Any => write!(f, "At least one validator must pass"),
82 Self::Not => write!(f, "Validator must fail"),
83 Self::Optional => write!(f, "Optional validation"),
84 }
85 }
86}
87
88/// Validates that all rules pass (logical AND).
89///
90/// All validators in the list must pass successfully. If any validator fails,
91/// the entire validation fails with aggregated error messages.
92///
93/// # Arguments
94/// * `rules` - List of validation rules to apply (all must pass)
95/// * `field_value` - The value being validated
96/// * `field_name` - Name of the field for error reporting
97/// * `is_present` - Whether the field is present/non-null
98///
99/// # Errors
100///
101/// Returns `FraiseQLError::Validation` if any rule fails, with all failure messages combined.
102pub fn validate_all(
103 rules: &[ValidationRule],
104 field_value: &str,
105 field_name: &str,
106 is_present: bool,
107) -> Result<()> {
108 let mut errors = Vec::new();
109
110 for rule in rules {
111 if let Err(e) = validate_single_rule(rule, field_value, field_name, is_present) {
112 errors.push(format!("{}", e));
113 }
114 }
115
116 if !errors.is_empty() {
117 return Err(FraiseQLError::Validation {
118 message: format!(
119 "All validators must pass for '{}': {}",
120 field_name,
121 errors.join("; ")
122 ),
123 path: Some(field_name.to_string()),
124 });
125 }
126
127 Ok(())
128}
129
130/// Validates that at least one rule passes (logical OR).
131///
132/// At least one validator in the list must pass. Only if all validators fail
133/// is the entire validation considered failed.
134///
135/// # Arguments
136/// * `rules` - List of validation rules (at least one must pass)
137/// * `field_value` - The value being validated
138/// * `field_name` - Name of the field for error reporting
139/// * `is_present` - Whether the field is present/non-null
140///
141/// # Errors
142///
143/// Returns `FraiseQLError::Validation` if all rules fail, with all failure messages combined.
144pub fn validate_any(
145 rules: &[ValidationRule],
146 field_value: &str,
147 field_name: &str,
148 is_present: bool,
149) -> Result<()> {
150 let mut errors = Vec::new();
151 let mut passed_count = 0;
152
153 for rule in rules {
154 match validate_single_rule(rule, field_value, field_name, is_present) {
155 Ok(()) => {
156 passed_count += 1;
157 },
158 Err(e) => {
159 errors.push(format!("{}", e));
160 },
161 }
162 }
163
164 if passed_count == 0 {
165 return Err(FraiseQLError::Validation {
166 message: format!(
167 "At least one validator must pass for '{}': {}",
168 field_name,
169 errors.join("; ")
170 ),
171 path: Some(field_name.to_string()),
172 });
173 }
174
175 Ok(())
176}
177
178/// Validates that a rule fails (logical NOT/negation).
179///
180/// The validator is inverted - it passes if the rule would normally fail,
181/// and fails if the rule would normally pass.
182///
183/// # Arguments
184/// * `rule` - The validation rule to negate
185/// * `field_value` - The value being validated
186/// * `field_name` - Name of the field for error reporting
187/// * `is_present` - Whether the field is present/non-null
188///
189/// # Errors
190///
191/// Returns `FraiseQLError::Validation` if the negated rule passes (when it should fail).
192pub fn validate_not(
193 rule: &ValidationRule,
194 field_value: &str,
195 field_name: &str,
196 is_present: bool,
197) -> Result<()> {
198 match validate_single_rule(rule, field_value, field_name, is_present) {
199 Ok(()) => Err(FraiseQLError::Validation {
200 message: format!("Validator for '{}' must fail but passed", field_name),
201 path: Some(field_name.to_string()),
202 }),
203 Err(_) => Ok(()), // Validator failed as expected
204 }
205}
206
207/// Validates a rule only if the field is present.
208///
209/// If the field is absent/null, validation is skipped (passes).
210/// If the field is present, the rule is applied normally.
211///
212/// # Arguments
213/// * `rule` - The validation rule to conditionally apply
214/// * `field_value` - The value being validated
215/// * `field_name` - Name of the field for error reporting
216/// * `is_present` - Whether the field is present/non-null
217///
218/// # Errors
219///
220/// Returns `FraiseQLError::Validation` if the field is present and the rule fails.
221pub fn validate_optional(
222 rule: &ValidationRule,
223 field_value: &str,
224 field_name: &str,
225 is_present: bool,
226) -> Result<()> {
227 if !is_present {
228 return Ok(());
229 }
230
231 validate_single_rule(rule, field_value, field_name, is_present)
232}
233
234/// Validates a single rule against a field value.
235///
236/// This is a helper function that applies a basic validation rule.
237/// For complex rules, this would dispatch to specialized validators.
238pub(crate) fn validate_single_rule(
239 rule: &ValidationRule,
240 field_value: &str,
241 field_name: &str,
242 _is_present: bool,
243) -> Result<()> {
244 match rule {
245 ValidationRule::Required => {
246 if field_value.is_empty() {
247 return Err(FraiseQLError::Validation {
248 message: format!("Field '{}' is required", field_name),
249 path: Some(field_name.to_string()),
250 });
251 }
252 Ok(())
253 },
254 ValidationRule::Pattern { pattern, message } => {
255 if !pattern.is_match(field_value) {
256 return Err(FraiseQLError::Validation {
257 message: message.clone().unwrap_or_else(|| {
258 format!("'{}' must match pattern: {}", field_name, pattern)
259 }),
260 path: Some(field_name.to_string()),
261 });
262 }
263 Ok(())
264 },
265 ValidationRule::Length { min, max } => {
266 let len = field_value.len();
267 if let Some(min_len) = min {
268 if len < *min_len {
269 return Err(FraiseQLError::Validation {
270 message: format!(
271 "'{}' must be at least {} characters",
272 field_name, min_len
273 ),
274 path: Some(field_name.to_string()),
275 });
276 }
277 }
278 if let Some(max_len) = max {
279 if len > *max_len {
280 return Err(FraiseQLError::Validation {
281 message: format!("'{}' must be at most {} characters", field_name, max_len),
282 path: Some(field_name.to_string()),
283 });
284 }
285 }
286 Ok(())
287 },
288 ValidationRule::Enum { values } => {
289 if !values.contains(&field_value.to_string()) {
290 return Err(FraiseQLError::Validation {
291 message: format!("'{}' must be one of: {}", field_name, values.join(", ")),
292 path: Some(field_name.to_string()),
293 });
294 }
295 Ok(())
296 },
297 // For other rule types, we skip validation in this basic implementation
298 _ => Ok(()),
299 }
300}