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}