Skip to main content

fraiseql_core/validation/
validators.rs

1//! Basic field validators for input validation.
2//!
3//! Provides simple validators for patterns, lengths, numeric ranges, and enums.
4//! These validators are combined to create comprehensive input validation rules.
5
6use regex::Regex;
7
8use super::rules::ValidationRule;
9use crate::error::{FraiseQLError, Result, ValidationFieldError};
10
11/// Basic validator trait for field validation.
12pub trait Validator {
13    /// Validate a value and return an error if validation fails.
14    ///
15    /// # Errors
16    ///
17    /// Returns `FraiseQLError::Validation` if the value does not pass the validation check.
18    fn validate(&self, value: &str, field: &str) -> Result<()>;
19}
20
21/// Pattern validator using regular expressions.
22pub struct PatternValidator {
23    regex:   Regex,
24    message: String,
25}
26
27impl PatternValidator {
28    /// Create a new pattern validator.
29    ///
30    /// # Errors
31    /// Returns error if the regex pattern is invalid.
32    pub fn new(pattern: impl Into<String>, message: impl Into<String>) -> Result<Self> {
33        let pattern_str = pattern.into();
34        let regex = Regex::new(&pattern_str)
35            .map_err(|e| FraiseQLError::validation(format!("Invalid regex pattern: {}", e)))?;
36        Ok(Self {
37            regex,
38            message: message.into(),
39        })
40    }
41
42    /// Create a new pattern validator with default message.
43    ///
44    /// # Errors
45    ///
46    /// Returns `FraiseQLError::Validation` if the regex pattern is invalid.
47    pub fn new_default_message(pattern: impl Into<String>) -> Result<Self> {
48        let pattern_str = pattern.into();
49        Self::new(pattern_str.clone(), format!("Value must match pattern: {}", pattern_str))
50    }
51
52    /// Validate that a value matches the pattern.
53    pub fn validate_pattern(&self, value: &str) -> bool {
54        self.regex.is_match(value)
55    }
56}
57
58impl Validator for PatternValidator {
59    fn validate(&self, value: &str, field: &str) -> Result<()> {
60        if self.validate_pattern(value) {
61            Ok(())
62        } else {
63            Err(FraiseQLError::Validation {
64                message: format!(
65                    "Field validation failed: {}",
66                    ValidationFieldError::new(field, "pattern", &self.message)
67                ),
68                path:    Some(field.to_string()),
69            })
70        }
71    }
72}
73
74/// String length validator.
75pub struct LengthValidator {
76    min: Option<usize>,
77    max: Option<usize>,
78}
79
80impl LengthValidator {
81    /// Create a new length validator.
82    pub const fn new(min: Option<usize>, max: Option<usize>) -> Self {
83        Self { min, max }
84    }
85
86    /// Validate that a string is within the specified length bounds.
87    pub const fn validate_length(&self, value: &str) -> bool {
88        let len = value.len();
89        if let Some(min) = self.min {
90            if len < min {
91                return false;
92            }
93        }
94        if let Some(max) = self.max {
95            if len > max {
96                return false;
97            }
98        }
99        true
100    }
101
102    /// Get a descriptive error message for length validation failure.
103    pub fn error_message(&self) -> String {
104        match (self.min, self.max) {
105            (Some(m), Some(x)) => format!("Length must be between {} and {}", m, x),
106            (Some(m), None) => format!("Length must be at least {}", m),
107            (None, Some(x)) => format!("Length must be at most {}", x),
108            (None, None) => "Length validation failed".to_string(),
109        }
110    }
111}
112
113impl Validator for LengthValidator {
114    fn validate(&self, value: &str, field: &str) -> Result<()> {
115        if self.validate_length(value) {
116            Ok(())
117        } else {
118            Err(FraiseQLError::Validation {
119                message: format!(
120                    "Field validation failed: {}",
121                    ValidationFieldError::new(field, "length", self.error_message())
122                ),
123                path:    Some(field.to_string()),
124            })
125        }
126    }
127}
128
129/// Numeric range validator.
130pub struct RangeValidator {
131    min: Option<i64>,
132    max: Option<i64>,
133}
134
135impl RangeValidator {
136    /// Create a new range validator.
137    pub const fn new(min: Option<i64>, max: Option<i64>) -> Self {
138        Self { min, max }
139    }
140
141    /// Validate that a number is within the specified range.
142    pub const fn validate_range(&self, value: i64) -> bool {
143        if let Some(min) = self.min {
144            if value < min {
145                return false;
146            }
147        }
148        if let Some(max) = self.max {
149            if value > max {
150                return false;
151            }
152        }
153        true
154    }
155
156    /// Get a descriptive error message for range validation failure.
157    pub fn error_message(&self) -> String {
158        match (self.min, self.max) {
159            (Some(m), Some(x)) => format!("Value must be between {} and {}", m, x),
160            (Some(m), None) => format!("Value must be at least {}", m),
161            (None, Some(x)) => format!("Value must be at most {}", x),
162            (None, None) => "Range validation failed".to_string(),
163        }
164    }
165}
166
167/// Enum validator - allows only specified values.
168pub struct EnumValidator {
169    allowed_values: std::collections::HashSet<String>,
170}
171
172impl EnumValidator {
173    /// Create a new enum validator.
174    pub fn new(values: Vec<String>) -> Self {
175        Self {
176            allowed_values: values.into_iter().collect(),
177        }
178    }
179
180    /// Validate that a value is in the allowed set.
181    pub fn validate_enum(&self, value: &str) -> bool {
182        self.allowed_values.contains(value)
183    }
184
185    /// Get the list of allowed values.
186    pub fn allowed_values(&self) -> Vec<&str> {
187        self.allowed_values.iter().map(|s| s.as_str()).collect()
188    }
189}
190
191impl Validator for EnumValidator {
192    fn validate(&self, value: &str, field: &str) -> Result<()> {
193        if self.validate_enum(value) {
194            Ok(())
195        } else {
196            let mut allowed_vec: Vec<_> = self.allowed_values.iter().cloned().collect();
197            allowed_vec.sort();
198            let allowed = allowed_vec.join(", ");
199            Err(FraiseQLError::Validation {
200                message: format!(
201                    "Field validation failed: {}",
202                    ValidationFieldError::new(
203                        field,
204                        "enum",
205                        format!("Must be one of: {}", allowed)
206                    )
207                ),
208                path:    Some(field.to_string()),
209            })
210        }
211    }
212}
213
214/// Required field validator.
215pub struct RequiredValidator;
216
217impl Validator for RequiredValidator {
218    fn validate(&self, value: &str, field: &str) -> Result<()> {
219        if value.is_empty() {
220            Err(FraiseQLError::Validation {
221                message: format!(
222                    "Field validation failed: {}",
223                    ValidationFieldError::new(field, "required", "Field is required")
224                ),
225                path:    Some(field.to_string()),
226            })
227        } else {
228            Ok(())
229        }
230    }
231}
232
233/// RFC 5321 practical email regex, shared with `async_validators`.
234const EMAIL_PATTERN: &str = r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$";
235
236/// E.164 international phone number regex, shared with `async_validators`.
237const PHONE_E164_PATTERN: &str = r"^\+[1-9]\d{6,14}$";
238
239/// Create a validator from a `ValidationRule`.
240///
241/// Returns `None` for rule types that are handled elsewhere (e.g. cross-field,
242/// composite, or async validators). Logs a warning if a `Pattern` rule
243/// contains an invalid regex instead of silently discarding the validator.
244pub fn create_validator_from_rule(rule: &ValidationRule) -> Option<Box<dyn Validator>> {
245    match rule {
246        ValidationRule::Pattern { pattern, message } => {
247            let msg = message.clone().unwrap_or_else(|| "Pattern mismatch".to_string());
248            match PatternValidator::new(pattern.clone(), msg) {
249                Ok(v) => Some(Box::new(v) as Box<dyn Validator>),
250                Err(e) => {
251                    tracing::warn!(
252                        pattern = %pattern,
253                        error = %e,
254                        "Invalid regex in ValidationRule::Pattern — validator skipped"
255                    );
256                    None
257                },
258            }
259        },
260        ValidationRule::Length { min, max } => {
261            Some(Box::new(LengthValidator::new(*min, *max)) as Box<dyn Validator>)
262        },
263        ValidationRule::Enum { values } => {
264            Some(Box::new(EnumValidator::new(values.clone())) as Box<dyn Validator>)
265        },
266        ValidationRule::Required => Some(Box::new(RequiredValidator) as Box<dyn Validator>),
267        ValidationRule::Email => {
268            // Reuse the same regex as EmailFormatValidator in async_validators.
269            PatternValidator::new(EMAIL_PATTERN, "Invalid email address format")
270                .ok()
271                .map(|v| Box::new(v) as Box<dyn Validator>)
272        },
273        ValidationRule::Phone => {
274            // Reuse the same regex as PhoneE164Validator in async_validators.
275            PatternValidator::new(
276                PHONE_E164_PATTERN,
277                "Invalid E.164 phone number (expected +<country><number>)",
278            )
279            .ok()
280            .map(|v| Box::new(v) as Box<dyn Validator>)
281        },
282        _ => None, // Other validators handled separately (cross-field, composite, async)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
289
290    use super::*;
291
292    #[test]
293    fn test_pattern_validator() {
294        let validator = PatternValidator::new_default_message("^[a-z]+$").unwrap();
295        assert!(validator.validate_pattern("hello"));
296        assert!(!validator.validate_pattern("Hello"));
297        assert!(!validator.validate_pattern("hello123"));
298    }
299
300    #[test]
301    fn test_pattern_validator_validation() {
302        let validator = PatternValidator::new_default_message("^[a-z]+$").unwrap();
303        validator
304            .validate("hello", "name")
305            .unwrap_or_else(|e| panic!("lowercase-only string should pass pattern: {e}"));
306        assert!(
307            matches!(validator.validate("Hello", "name"), Err(FraiseQLError::Validation { .. })),
308            "mixed-case string should fail pattern with Validation error"
309        );
310    }
311
312    #[test]
313    fn test_length_validator() {
314        let validator = LengthValidator::new(Some(3), Some(10));
315        assert!(validator.validate_length("hello"));
316        assert!(!validator.validate_length("ab"));
317        assert!(!validator.validate_length("this is too long"));
318    }
319
320    #[test]
321    fn test_length_validator_error_message() {
322        let validator = LengthValidator::new(Some(5), Some(10));
323        let msg = validator.error_message();
324        assert!(msg.contains('5'));
325        assert!(msg.contains("10"));
326    }
327
328    #[test]
329    fn test_range_validator() {
330        let validator = RangeValidator::new(Some(0), Some(100));
331        assert!(validator.validate_range(50));
332        assert!(!validator.validate_range(-1));
333        assert!(!validator.validate_range(101));
334    }
335
336    #[test]
337    fn test_enum_validator() {
338        let validator = EnumValidator::new(vec![
339            "active".to_string(),
340            "inactive".to_string(),
341            "pending".to_string(),
342        ]);
343        assert!(validator.validate_enum("active"));
344        assert!(!validator.validate_enum("unknown"));
345    }
346
347    #[test]
348    fn test_required_validator() {
349        let validator = RequiredValidator;
350        validator
351            .validate("hello", "name")
352            .unwrap_or_else(|e| panic!("non-empty string should pass required validator: {e}"));
353        assert!(
354            matches!(validator.validate("", "name"), Err(FraiseQLError::Validation { .. })),
355            "empty string should fail required validator with Validation error"
356        );
357    }
358
359    #[test]
360    fn test_create_validator_from_rule() {
361        let rule = ValidationRule::Pattern {
362            pattern: "^test".to_string(),
363            message: None,
364        };
365        let validator = create_validator_from_rule(&rule);
366        assert!(validator.is_some());
367    }
368}