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, counted in characters (see
127 /// [`check_length`]).
128 #[serde(rename = "length")]
129 Length {
130 /// Minimum length (inclusive).
131 min: Option<usize>,
132 /// Maximum length (inclusive).
133 max: Option<usize>,
134 },
135
136 /// Numeric field range constraints.
137 #[serde(rename = "range")]
138 Range {
139 /// Minimum value (inclusive).
140 min: Option<i64>,
141 /// Maximum value (inclusive).
142 max: Option<i64>,
143 },
144
145 /// Field value must be one of allowed enum values.
146 #[serde(rename = "enum")]
147 Enum {
148 /// List of allowed values.
149 values: Vec<String>,
150 },
151
152 /// Checksum validation for structured data.
153 #[serde(rename = "checksum")]
154 Checksum {
155 /// Algorithm to use (e.g., "luhn", "mod97").
156 algorithm: String,
157 },
158
159 /// Cross-field validation rule.
160 #[serde(rename = "cross_field")]
161 CrossField {
162 /// Reference to another field to compare against.
163 field: String,
164 /// Comparison operator ("lt", "lte", "eq", "gte", "gt").
165 operator: String,
166 },
167
168 /// Conditional validation - only validate if condition is met.
169 #[serde(rename = "conditional")]
170 Conditional {
171 /// The condition expression.
172 condition: String,
173 /// Rules to apply if condition is true.
174 then_rules: Vec<Box<ValidationRule>>,
175 },
176
177 /// Composite rule - all rules must pass.
178 #[serde(rename = "all")]
179 All(Vec<ValidationRule>),
180
181 /// Composite rule - at least one rule must pass.
182 #[serde(rename = "any")]
183 Any(Vec<ValidationRule>),
184
185 /// Exactly one field from the set must be provided (mutually exclusive).
186 ///
187 /// Useful for "create or reference" patterns where you must provide EITHER
188 /// an ID to reference an existing entity OR the fields to create a new one,
189 /// but not both.
190 ///
191 /// # Example
192 /// ```
193 /// use fraiseql_core::validation::ValidationRule;
194 /// // Either provide entityId OR (name + description), but not both
195 /// let _rule = ValidationRule::OneOf { fields: vec!["name".to_string(), "description".to_string()] };
196 /// ```
197 #[serde(rename = "one_of")]
198 OneOf {
199 /// List of field names - exactly one must be provided
200 fields: Vec<String>,
201 },
202
203 /// At least one field from the set must be provided.
204 ///
205 /// Useful for optional but not-all-empty patterns.
206 ///
207 /// # Example
208 /// ```
209 /// use fraiseql_core::validation::ValidationRule;
210 /// // Provide at least one of: email, phone, address
211 /// let _rule = ValidationRule::AnyOf { fields: vec!["email".to_string(), "phone".to_string(), "address".to_string()] };
212 /// ```
213 #[serde(rename = "any_of")]
214 AnyOf {
215 /// List of field names - at least one must be provided
216 fields: Vec<String>,
217 },
218
219 /// If a field is present, then other fields are required.
220 ///
221 /// Used for conditional requirements based on presence of another field.
222 ///
223 /// # Example
224 /// ```
225 /// use fraiseql_core::validation::ValidationRule;
226 /// // If entityId is provided, then createdAt is required
227 /// let _rule = ValidationRule::ConditionalRequired {
228 /// if_field_present: "entityId".to_string(),
229 /// then_required: vec!["createdAt".to_string()],
230 /// };
231 /// ```
232 #[serde(rename = "conditional_required")]
233 ConditionalRequired {
234 /// If this field is present (not null/missing)
235 if_field_present: String,
236 /// Then these fields are required
237 then_required: Vec<String>,
238 },
239
240 /// If a field is absent/null, then other fields are required.
241 ///
242 /// Used for "provide this OR that" patterns at the object level.
243 ///
244 /// # Example
245 /// ```
246 /// use fraiseql_core::validation::ValidationRule;
247 /// // If addressId is missing, then street+city+zip are required
248 /// let _rule = ValidationRule::RequiredIfAbsent {
249 /// absent_field: "addressId".to_string(),
250 /// then_required: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
251 /// };
252 /// ```
253 #[serde(rename = "required_if_absent")]
254 RequiredIfAbsent {
255 /// If this field is absent/null
256 absent_field: String,
257 /// Then these fields are required
258 then_required: Vec<String>,
259 },
260
261 /// Field must be a valid email address (RFC 5321 practical subset).
262 #[serde(rename = "email")]
263 Email,
264
265 /// Field must be a valid E.164 international phone number (e.g. `+14155552671`).
266 #[serde(rename = "phone")]
267 Phone,
268}
269
270impl ValidationRule {
271 /// Check if this is a required field validation.
272 #[must_use]
273 pub const fn is_required(&self) -> bool {
274 matches!(self, Self::Required)
275 }
276
277 /// Get a human-readable description of this rule.
278 #[must_use]
279 pub fn description(&self) -> String {
280 match self {
281 Self::Required => "Field is required".to_string(),
282 Self::Pattern { message, .. } => {
283 message.clone().unwrap_or_else(|| "Must match pattern".to_string())
284 },
285 Self::Length { min, max } => match (min, max) {
286 (Some(m), Some(max_val)) => format!("Length between {} and {}", m, max_val),
287 (Some(m), None) => format!("Length at least {}", m),
288 (None, Some(max_val)) => format!("Length at most {}", max_val),
289 (None, None) => "Length constraint".to_string(),
290 },
291 Self::Range { min, max } => match (min, max) {
292 (Some(m), Some(max_val)) => format!("Value between {} and {}", m, max_val),
293 (Some(m), None) => format!("Value at least {}", m),
294 (None, Some(max_val)) => format!("Value at most {}", max_val),
295 (None, None) => "Range constraint".to_string(),
296 },
297 Self::Enum { values } => format!("Must be one of: {}", values.join(", ")),
298 Self::Checksum { algorithm } => format!("Invalid {}", algorithm),
299 Self::CrossField { field, operator } => format!("Must be {} {}", operator, field),
300 Self::Conditional { .. } => "Conditional validation".to_string(),
301 Self::All(_) => "All rules must pass".to_string(),
302 Self::Any(_) => "At least one rule must pass".to_string(),
303 Self::OneOf { fields } => {
304 format!("Exactly one of these must be provided: {}", fields.join(", "))
305 },
306 Self::AnyOf { fields } => {
307 format!("At least one of these must be provided: {}", fields.join(", "))
308 },
309 Self::ConditionalRequired {
310 if_field_present,
311 then_required,
312 } => {
313 format!(
314 "If '{}' is provided, then {} must be provided",
315 if_field_present,
316 then_required.join(", ")
317 )
318 },
319 Self::RequiredIfAbsent {
320 absent_field,
321 then_required,
322 } => {
323 format!(
324 "If '{}' is absent, then {} must be provided",
325 absent_field,
326 then_required.join(", ")
327 )
328 },
329 Self::Email => "Must be a valid email address".to_string(),
330 Self::Phone => "Must be a valid E.164 phone number (e.g. +14155552671)".to_string(),
331 }
332 }
333}
334
335/// Outcome of a [`ValidationRule::Length`] check.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum LengthCheck {
338 /// The value is within bounds.
339 Ok,
340 /// Shorter than `min`.
341 TooShort {
342 /// The configured minimum.
343 min: usize,
344 /// The value's actual length, in characters.
345 actual: usize,
346 },
347 /// Longer than `max`.
348 TooLong {
349 /// The configured maximum.
350 max: usize,
351 /// The value's actual length, in characters.
352 actual: usize,
353 },
354}
355
356/// Evaluate a `Length` rule against `value`.
357///
358/// **Length is a count of characters, not bytes.** Four call sites evaluated
359/// this rule independently and all four used `str::len()`, so `min: 3` accepted
360/// `"é!"` (three bytes, two characters) and `max: 3` rejected `"éàü"` (six
361/// bytes) — while every one of their error messages said "characters" (#720).
362/// Routing all four through this function is what makes the messages true.
363#[must_use]
364pub fn check_length(value: &str, min: Option<usize>, max: Option<usize>) -> LengthCheck {
365 let actual = value.chars().count();
366 if let Some(min) = min {
367 if actual < min {
368 return LengthCheck::TooShort { min, actual };
369 }
370 }
371 if let Some(max) = max {
372 if actual > max {
373 return LengthCheck::TooLong { max, actual };
374 }
375 }
376 LengthCheck::Ok
377}