fraiseql_core/validation/
rules.rs1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "type", content = "value")]
14#[non_exhaustive]
15pub enum ValidationRule {
16 #[serde(rename = "required")]
18 Required,
19
20 #[serde(rename = "pattern")]
22 Pattern {
23 pattern: String,
25 message: Option<String>,
27 },
28
29 #[serde(rename = "length")]
31 Length {
32 min: Option<usize>,
34 max: Option<usize>,
36 },
37
38 #[serde(rename = "range")]
40 Range {
41 min: Option<i64>,
43 max: Option<i64>,
45 },
46
47 #[serde(rename = "enum")]
49 Enum {
50 values: Vec<String>,
52 },
53
54 #[serde(rename = "checksum")]
56 Checksum {
57 algorithm: String,
59 },
60
61 #[serde(rename = "cross_field")]
63 CrossField {
64 field: String,
66 operator: String,
68 },
69
70 #[serde(rename = "conditional")]
72 Conditional {
73 condition: String,
75 then_rules: Vec<Box<ValidationRule>>,
77 },
78
79 #[serde(rename = "all")]
81 All(Vec<ValidationRule>),
82
83 #[serde(rename = "any")]
85 Any(Vec<ValidationRule>),
86
87 #[serde(rename = "one_of")]
100 OneOf {
101 fields: Vec<String>,
103 },
104
105 #[serde(rename = "any_of")]
116 AnyOf {
117 fields: Vec<String>,
119 },
120
121 #[serde(rename = "conditional_required")]
135 ConditionalRequired {
136 if_field_present: String,
138 then_required: Vec<String>,
140 },
141
142 #[serde(rename = "required_if_absent")]
156 RequiredIfAbsent {
157 absent_field: String,
159 then_required: Vec<String>,
161 },
162
163 #[serde(rename = "email")]
165 Email,
166
167 #[serde(rename = "phone")]
169 Phone,
170}
171
172impl ValidationRule {
173 pub const fn is_required(&self) -> bool {
175 matches!(self, Self::Required)
176 }
177
178 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}