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