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 serde::{Deserialize, Serialize};
7
8/// A validation rule that can be applied to a field.
9///
10/// Rules define constraints on field values and are evaluated during input validation.
11/// Multiple rules can be combined on a single field.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "type", content = "value")]
14#[non_exhaustive]
15pub enum ValidationRule {
16    /// Field is required (non-null) and must have a value.
17    #[serde(rename = "required")]
18    Required,
19
20    /// Field value must match a regular expression pattern.
21    #[serde(rename = "pattern")]
22    Pattern {
23        /// The regex pattern to match.
24        pattern: String,
25        /// Optional error message for when pattern doesn't match.
26        message: Option<String>,
27    },
28
29    /// String field length constraints.
30    #[serde(rename = "length")]
31    Length {
32        /// Minimum length (inclusive).
33        min: Option<usize>,
34        /// Maximum length (inclusive).
35        max: Option<usize>,
36    },
37
38    /// Numeric field range constraints.
39    #[serde(rename = "range")]
40    Range {
41        /// Minimum value (inclusive).
42        min: Option<i64>,
43        /// Maximum value (inclusive).
44        max: Option<i64>,
45    },
46
47    /// Field value must be one of allowed enum values.
48    #[serde(rename = "enum")]
49    Enum {
50        /// List of allowed values.
51        values: Vec<String>,
52    },
53
54    /// Checksum validation for structured data.
55    #[serde(rename = "checksum")]
56    Checksum {
57        /// Algorithm to use (e.g., "luhn", "mod97").
58        algorithm: String,
59    },
60
61    /// Cross-field validation rule.
62    #[serde(rename = "cross_field")]
63    CrossField {
64        /// Reference to another field to compare against.
65        field:    String,
66        /// Comparison operator ("lt", "lte", "eq", "gte", "gt").
67        operator: String,
68    },
69
70    /// Conditional validation - only validate if condition is met.
71    #[serde(rename = "conditional")]
72    Conditional {
73        /// The condition expression.
74        condition:  String,
75        /// Rules to apply if condition is true.
76        then_rules: Vec<Box<ValidationRule>>,
77    },
78
79    /// Composite rule - all rules must pass.
80    #[serde(rename = "all")]
81    All(Vec<ValidationRule>),
82
83    /// Composite rule - at least one rule must pass.
84    #[serde(rename = "any")]
85    Any(Vec<ValidationRule>),
86
87    /// Exactly one field from the set must be provided (mutually exclusive).
88    ///
89    /// Useful for "create or reference" patterns where you must provide EITHER
90    /// an ID to reference an existing entity OR the fields to create a new one,
91    /// but not both.
92    ///
93    /// # Example
94    /// ```
95    /// use fraiseql_core::validation::ValidationRule;
96    /// // Either provide entityId OR (name + description), but not both
97    /// let _rule = ValidationRule::OneOf { fields: vec!["name".to_string(), "description".to_string()] };
98    /// ```
99    #[serde(rename = "one_of")]
100    OneOf {
101        /// List of field names - exactly one must be provided
102        fields: Vec<String>,
103    },
104
105    /// At least one field from the set must be provided.
106    ///
107    /// Useful for optional but not-all-empty patterns.
108    ///
109    /// # Example
110    /// ```
111    /// use fraiseql_core::validation::ValidationRule;
112    /// // Provide at least one of: email, phone, address
113    /// let _rule = ValidationRule::AnyOf { fields: vec!["email".to_string(), "phone".to_string(), "address".to_string()] };
114    /// ```
115    #[serde(rename = "any_of")]
116    AnyOf {
117        /// List of field names - at least one must be provided
118        fields: Vec<String>,
119    },
120
121    /// If a field is present, then other fields are required.
122    ///
123    /// Used for conditional requirements based on presence of another field.
124    ///
125    /// # Example
126    /// ```
127    /// use fraiseql_core::validation::ValidationRule;
128    /// // If entityId is provided, then createdAt is required
129    /// let _rule = ValidationRule::ConditionalRequired {
130    ///     if_field_present: "entityId".to_string(),
131    ///     then_required: vec!["createdAt".to_string()],
132    /// };
133    /// ```
134    #[serde(rename = "conditional_required")]
135    ConditionalRequired {
136        /// If this field is present (not null/missing)
137        if_field_present: String,
138        /// Then these fields are required
139        then_required:    Vec<String>,
140    },
141
142    /// If a field is absent/null, then other fields are required.
143    ///
144    /// Used for "provide this OR that" patterns at the object level.
145    ///
146    /// # Example
147    /// ```
148    /// use fraiseql_core::validation::ValidationRule;
149    /// // If addressId is missing, then street+city+zip are required
150    /// let _rule = ValidationRule::RequiredIfAbsent {
151    ///     absent_field: "addressId".to_string(),
152    ///     then_required: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
153    /// };
154    /// ```
155    #[serde(rename = "required_if_absent")]
156    RequiredIfAbsent {
157        /// If this field is absent/null
158        absent_field:  String,
159        /// Then these fields are required
160        then_required: Vec<String>,
161    },
162
163    /// Field must be a valid email address (RFC 5321 practical subset).
164    #[serde(rename = "email")]
165    Email,
166
167    /// Field must be a valid E.164 international phone number (e.g. `+14155552671`).
168    #[serde(rename = "phone")]
169    Phone,
170}
171
172impl ValidationRule {
173    /// Check if this is a required field validation.
174    pub const fn is_required(&self) -> bool {
175        matches!(self, Self::Required)
176    }
177
178    /// Get a human-readable description of this rule.
179    pub fn description(&self) -> String {
180        match self {
181            Self::Required => "Field is required".to_string(),
182            Self::Pattern { message, .. } => {
183                message.clone().unwrap_or_else(|| "Must match pattern".to_string())
184            },
185            Self::Length { min, max } => match (min, max) {
186                (Some(m), Some(max_val)) => format!("Length between {} and {}", m, max_val),
187                (Some(m), None) => format!("Length at least {}", m),
188                (None, Some(max_val)) => format!("Length at most {}", max_val),
189                (None, None) => "Length constraint".to_string(),
190            },
191            Self::Range { min, max } => match (min, max) {
192                (Some(m), Some(max_val)) => format!("Value between {} and {}", m, max_val),
193                (Some(m), None) => format!("Value at least {}", m),
194                (None, Some(max_val)) => format!("Value at most {}", max_val),
195                (None, None) => "Range constraint".to_string(),
196            },
197            Self::Enum { values } => format!("Must be one of: {}", values.join(", ")),
198            Self::Checksum { algorithm } => format!("Invalid {}", algorithm),
199            Self::CrossField { field, operator } => format!("Must be {} {}", operator, field),
200            Self::Conditional { .. } => "Conditional validation".to_string(),
201            Self::All(_) => "All rules must pass".to_string(),
202            Self::Any(_) => "At least one rule must pass".to_string(),
203            Self::OneOf { fields } => {
204                format!("Exactly one of these must be provided: {}", fields.join(", "))
205            },
206            Self::AnyOf { fields } => {
207                format!("At least one of these must be provided: {}", fields.join(", "))
208            },
209            Self::ConditionalRequired {
210                if_field_present,
211                then_required,
212            } => {
213                format!(
214                    "If '{}' is provided, then {} must be provided",
215                    if_field_present,
216                    then_required.join(", ")
217                )
218            },
219            Self::RequiredIfAbsent {
220                absent_field,
221                then_required,
222            } => {
223                format!(
224                    "If '{}' is absent, then {} must be provided",
225                    absent_field,
226                    then_required.join(", ")
227                )
228            },
229            Self::Email => "Must be a valid email address".to_string(),
230            Self::Phone => "Must be a valid E.164 phone number (e.g. +14155552671)".to_string(),
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_required_rule() {
241        let rule = ValidationRule::Required;
242        assert!(rule.is_required());
243    }
244
245    #[test]
246    fn test_pattern_rule() {
247        let rule = ValidationRule::Pattern {
248            pattern: "^[a-z]+$".to_string(),
249            message: Some("Only lowercase letters allowed".to_string()),
250        };
251        assert!(!rule.is_required());
252        let desc = rule.description();
253        assert_eq!(desc, "Only lowercase letters allowed");
254    }
255
256    #[test]
257    fn test_length_rule() {
258        let rule = ValidationRule::Length {
259            min: Some(5),
260            max: Some(10),
261        };
262        let desc = rule.description();
263        assert!(desc.contains('5'));
264        assert!(desc.contains("10"));
265    }
266
267    #[test]
268    fn test_rule_serialization() {
269        let rule = ValidationRule::Enum {
270            values: vec!["active".to_string(), "inactive".to_string()],
271        };
272        let json = serde_json::to_string(&rule).expect("serialization failed");
273        let deserialized: ValidationRule =
274            serde_json::from_str(&json).expect("deserialization failed");
275        assert!(matches!(deserialized, ValidationRule::Enum { .. }));
276    }
277
278    #[test]
279    fn test_composite_all_rule() {
280        let rule = ValidationRule::All(vec![
281            ValidationRule::Required,
282            ValidationRule::Pattern {
283                pattern: "^[a-z]+$".to_string(),
284                message: None,
285            },
286        ]);
287        let desc = rule.description();
288        assert!(desc.contains("All rules"));
289    }
290
291    #[test]
292    fn test_one_of_rule() {
293        let rule = ValidationRule::OneOf {
294            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
295        };
296        assert!(!rule.is_required());
297        let desc = rule.description();
298        assert!(desc.contains("Exactly one"));
299        assert!(desc.contains("entityId"));
300        assert!(desc.contains("entityPayload"));
301    }
302
303    #[test]
304    fn test_any_of_rule() {
305        let rule = ValidationRule::AnyOf {
306            fields: vec![
307                "email".to_string(),
308                "phone".to_string(),
309                "address".to_string(),
310            ],
311        };
312        let desc = rule.description();
313        assert!(desc.contains("At least one"));
314        assert!(desc.contains("email"));
315        assert!(desc.contains("phone"));
316        assert!(desc.contains("address"));
317    }
318
319    #[test]
320    fn test_conditional_required_rule() {
321        let rule = ValidationRule::ConditionalRequired {
322            if_field_present: "entityId".to_string(),
323            then_required:    vec!["createdAt".to_string(), "updatedAt".to_string()],
324        };
325        let desc = rule.description();
326        assert!(desc.contains("If"));
327        assert!(desc.contains("entityId"));
328        assert!(desc.contains("createdAt"));
329        assert!(desc.contains("updatedAt"));
330    }
331
332    #[test]
333    fn test_required_if_absent_rule() {
334        let rule = ValidationRule::RequiredIfAbsent {
335            absent_field:  "addressId".to_string(),
336            then_required: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
337        };
338        let desc = rule.description();
339        assert!(desc.contains("If"));
340        assert!(desc.contains("addressId"));
341        assert!(desc.contains("absent"));
342        assert!(desc.contains("street"));
343    }
344
345    #[test]
346    fn test_one_of_serialization() {
347        let rule = ValidationRule::OneOf {
348            fields: vec!["id".to_string(), "payload".to_string()],
349        };
350        let json = serde_json::to_string(&rule).expect("serialization failed");
351        let deserialized: ValidationRule =
352            serde_json::from_str(&json).expect("deserialization failed");
353        assert!(matches!(deserialized, ValidationRule::OneOf { .. }));
354    }
355
356    #[test]
357    fn test_conditional_required_serialization() {
358        let rule = ValidationRule::ConditionalRequired {
359            if_field_present: "isPremium".to_string(),
360            then_required:    vec!["paymentMethod".to_string()],
361        };
362        let json = serde_json::to_string(&rule).expect("serialization failed");
363        let deserialized: ValidationRule =
364            serde_json::from_str(&json).expect("deserialization failed");
365        assert!(matches!(deserialized, ValidationRule::ConditionalRequired { .. }));
366    }
367
368    #[test]
369    fn test_required_if_absent_serialization() {
370        let rule = ValidationRule::RequiredIfAbsent {
371            absent_field:  "presetId".to_string(),
372            then_required: vec!["settings".to_string()],
373        };
374        let json = serde_json::to_string(&rule).expect("serialization failed");
375        let deserialized: ValidationRule =
376            serde_json::from_str(&json).expect("deserialization failed");
377        assert!(matches!(deserialized, ValidationRule::RequiredIfAbsent { .. }));
378    }
379}