Skip to main content

fraiseql_core/validation/
rules.rs

1//! Validation rule types and definitions.
2//!
3//! This module defines the validation rules that can be applied to input fields
4//! in a GraphQL schema. Rules are serializable and can be embedded in the compiled schema.
5
6use std::fmt;
7
8use regex::Regex;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11/// A regex pattern compiled once at construction or deserialisation time.
12///
13/// Stores both the compiled [`Regex`] and the source string so that the rule
14/// remains [`Clone`] / [`PartialEq`] / [`Serialize`] without needing to
15/// recompile on every match. Compilation errors surface at the construction
16/// site (e.g. schema load) rather than per-request on the validation hot path.
17#[derive(Debug, Clone)]
18pub struct CompiledPattern {
19    /// Original pattern source — preserved for serde round-trip and diagnostics.
20    source: String,
21    /// Pre-compiled regex matcher.
22    regex:  Regex,
23}
24
25impl CompiledPattern {
26    /// Compile a regex pattern.
27    ///
28    /// # Errors
29    ///
30    /// Returns the underlying [`regex::Error`] if `pattern` is not a valid
31    /// regular expression.
32    pub fn new(pattern: impl Into<String>) -> Result<Self, regex::Error> {
33        let source = pattern.into();
34        let regex = Regex::new(&source)?;
35        Ok(Self { source, regex })
36    }
37
38    /// Borrow the compiled regex for matching.
39    #[must_use]
40    pub const fn regex(&self) -> &Regex {
41        &self.regex
42    }
43
44    /// Borrow the original pattern source.
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.source
48    }
49
50    /// Test whether the input string matches this pattern.
51    #[must_use]
52    pub fn is_match(&self, value: &str) -> bool {
53        self.regex.is_match(value)
54    }
55}
56
57impl fmt::Display for CompiledPattern {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(&self.source)
60    }
61}
62
63impl PartialEq for CompiledPattern {
64    fn eq(&self, other: &Self) -> bool {
65        self.source == other.source
66    }
67}
68
69impl Eq for CompiledPattern {}
70
71impl Serialize for CompiledPattern {
72    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73        serializer.serialize_str(&self.source)
74    }
75}
76
77impl<'de> Deserialize<'de> for CompiledPattern {
78    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
79        use serde::de::Error;
80        let source = String::deserialize(deserializer)?;
81        Self::new(source).map_err(D::Error::custom)
82    }
83}
84
85impl TryFrom<String> for CompiledPattern {
86    type Error = regex::Error;
87
88    fn try_from(value: String) -> Result<Self, Self::Error> {
89        Self::new(value)
90    }
91}
92
93impl TryFrom<&str> for CompiledPattern {
94    type Error = regex::Error;
95
96    fn try_from(value: &str) -> Result<Self, Self::Error> {
97        Self::new(value)
98    }
99}
100
101/// A validation rule that can be applied to a field.
102///
103/// Rules define constraints on field values and are evaluated during input validation.
104/// Multiple rules can be combined on a single field.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(tag = "type", content = "value")]
107#[non_exhaustive]
108pub enum ValidationRule {
109    /// Field is required (non-null) and must have a value.
110    #[serde(rename = "required")]
111    Required,
112
113    /// Field value must match a regular expression pattern.
114    ///
115    /// The `pattern` is compiled once when the rule is constructed (or
116    /// deserialised from the compiled schema), so the regex engine is not
117    /// re-invoked per request.
118    #[serde(rename = "pattern")]
119    Pattern {
120        /// The regex pattern to match — compiled at construction.
121        pattern: CompiledPattern,
122        /// Optional error message for when pattern doesn't match.
123        message: Option<String>,
124    },
125
126    /// String field length constraints.
127    #[serde(rename = "length")]
128    Length {
129        /// Minimum length (inclusive).
130        min: Option<usize>,
131        /// Maximum length (inclusive).
132        max: Option<usize>,
133    },
134
135    /// Numeric field range constraints.
136    #[serde(rename = "range")]
137    Range {
138        /// Minimum value (inclusive).
139        min: Option<i64>,
140        /// Maximum value (inclusive).
141        max: Option<i64>,
142    },
143
144    /// Field value must be one of allowed enum values.
145    #[serde(rename = "enum")]
146    Enum {
147        /// List of allowed values.
148        values: Vec<String>,
149    },
150
151    /// Checksum validation for structured data.
152    #[serde(rename = "checksum")]
153    Checksum {
154        /// Algorithm to use (e.g., "luhn", "mod97").
155        algorithm: String,
156    },
157
158    /// Cross-field validation rule.
159    #[serde(rename = "cross_field")]
160    CrossField {
161        /// Reference to another field to compare against.
162        field:    String,
163        /// Comparison operator ("lt", "lte", "eq", "gte", "gt").
164        operator: String,
165    },
166
167    /// Conditional validation - only validate if condition is met.
168    #[serde(rename = "conditional")]
169    Conditional {
170        /// The condition expression.
171        condition:  String,
172        /// Rules to apply if condition is true.
173        then_rules: Vec<Box<ValidationRule>>,
174    },
175
176    /// Composite rule - all rules must pass.
177    #[serde(rename = "all")]
178    All(Vec<ValidationRule>),
179
180    /// Composite rule - at least one rule must pass.
181    #[serde(rename = "any")]
182    Any(Vec<ValidationRule>),
183
184    /// Exactly one field from the set must be provided (mutually exclusive).
185    ///
186    /// Useful for "create or reference" patterns where you must provide EITHER
187    /// an ID to reference an existing entity OR the fields to create a new one,
188    /// but not both.
189    ///
190    /// # Example
191    /// ```
192    /// use fraiseql_core::validation::ValidationRule;
193    /// // Either provide entityId OR (name + description), but not both
194    /// let _rule = ValidationRule::OneOf { fields: vec!["name".to_string(), "description".to_string()] };
195    /// ```
196    #[serde(rename = "one_of")]
197    OneOf {
198        /// List of field names - exactly one must be provided
199        fields: Vec<String>,
200    },
201
202    /// At least one field from the set must be provided.
203    ///
204    /// Useful for optional but not-all-empty patterns.
205    ///
206    /// # Example
207    /// ```
208    /// use fraiseql_core::validation::ValidationRule;
209    /// // Provide at least one of: email, phone, address
210    /// let _rule = ValidationRule::AnyOf { fields: vec!["email".to_string(), "phone".to_string(), "address".to_string()] };
211    /// ```
212    #[serde(rename = "any_of")]
213    AnyOf {
214        /// List of field names - at least one must be provided
215        fields: Vec<String>,
216    },
217
218    /// If a field is present, then other fields are required.
219    ///
220    /// Used for conditional requirements based on presence of another field.
221    ///
222    /// # Example
223    /// ```
224    /// use fraiseql_core::validation::ValidationRule;
225    /// // If entityId is provided, then createdAt is required
226    /// let _rule = ValidationRule::ConditionalRequired {
227    ///     if_field_present: "entityId".to_string(),
228    ///     then_required: vec!["createdAt".to_string()],
229    /// };
230    /// ```
231    #[serde(rename = "conditional_required")]
232    ConditionalRequired {
233        /// If this field is present (not null/missing)
234        if_field_present: String,
235        /// Then these fields are required
236        then_required:    Vec<String>,
237    },
238
239    /// If a field is absent/null, then other fields are required.
240    ///
241    /// Used for "provide this OR that" patterns at the object level.
242    ///
243    /// # Example
244    /// ```
245    /// use fraiseql_core::validation::ValidationRule;
246    /// // If addressId is missing, then street+city+zip are required
247    /// let _rule = ValidationRule::RequiredIfAbsent {
248    ///     absent_field: "addressId".to_string(),
249    ///     then_required: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
250    /// };
251    /// ```
252    #[serde(rename = "required_if_absent")]
253    RequiredIfAbsent {
254        /// If this field is absent/null
255        absent_field:  String,
256        /// Then these fields are required
257        then_required: Vec<String>,
258    },
259
260    /// Field must be a valid email address (RFC 5321 practical subset).
261    #[serde(rename = "email")]
262    Email,
263
264    /// Field must be a valid E.164 international phone number (e.g. `+14155552671`).
265    #[serde(rename = "phone")]
266    Phone,
267}
268
269impl ValidationRule {
270    /// Check if this is a required field validation.
271    #[must_use]
272    pub const fn is_required(&self) -> bool {
273        matches!(self, Self::Required)
274    }
275
276    /// Get a human-readable description of this rule.
277    #[must_use]
278    pub fn description(&self) -> String {
279        match self {
280            Self::Required => "Field is required".to_string(),
281            Self::Pattern { message, .. } => {
282                message.clone().unwrap_or_else(|| "Must match pattern".to_string())
283            },
284            Self::Length { min, max } => match (min, max) {
285                (Some(m), Some(max_val)) => format!("Length between {} and {}", m, max_val),
286                (Some(m), None) => format!("Length at least {}", m),
287                (None, Some(max_val)) => format!("Length at most {}", max_val),
288                (None, None) => "Length constraint".to_string(),
289            },
290            Self::Range { min, max } => match (min, max) {
291                (Some(m), Some(max_val)) => format!("Value between {} and {}", m, max_val),
292                (Some(m), None) => format!("Value at least {}", m),
293                (None, Some(max_val)) => format!("Value at most {}", max_val),
294                (None, None) => "Range constraint".to_string(),
295            },
296            Self::Enum { values } => format!("Must be one of: {}", values.join(", ")),
297            Self::Checksum { algorithm } => format!("Invalid {}", algorithm),
298            Self::CrossField { field, operator } => format!("Must be {} {}", operator, field),
299            Self::Conditional { .. } => "Conditional validation".to_string(),
300            Self::All(_) => "All rules must pass".to_string(),
301            Self::Any(_) => "At least one rule must pass".to_string(),
302            Self::OneOf { fields } => {
303                format!("Exactly one of these must be provided: {}", fields.join(", "))
304            },
305            Self::AnyOf { fields } => {
306                format!("At least one of these must be provided: {}", fields.join(", "))
307            },
308            Self::ConditionalRequired {
309                if_field_present,
310                then_required,
311            } => {
312                format!(
313                    "If '{}' is provided, then {} must be provided",
314                    if_field_present,
315                    then_required.join(", ")
316                )
317            },
318            Self::RequiredIfAbsent {
319                absent_field,
320                then_required,
321            } => {
322                format!(
323                    "If '{}' is absent, then {} must be provided",
324                    absent_field,
325                    then_required.join(", ")
326                )
327            },
328            Self::Email => "Must be a valid email address".to_string(),
329            Self::Phone => "Must be a valid E.164 phone number (e.g. +14155552671)".to_string(),
330        }
331    }
332}