Skip to main content

fraiseql_core/validation/
input_object.rs

1//! Input object-level validation.
2//!
3//! This module provides validation capabilities at the input object level,
4//! applying cross-field rules and aggregating errors from multiple validators.
5//!
6//! # Examples
7//!
8//! ```
9//! use fraiseql_core::validation::{InputObjectRule, validate_input_object};
10//! use serde_json::json;
11//!
12//! // Validate entire input object
13//! let input = json!({
14//!     "name": "John",
15//!     "email": "john@example.com",
16//!     "phone": null
17//! });
18//!
19//! let validators = vec![
20//!     InputObjectRule::AnyOf { fields: vec!["email".to_string(), "phone".to_string()] },
21//!     InputObjectRule::ConditionalRequired {
22//!         if_field: "name".to_string(),
23//!         then_fields: vec!["email".to_string()],
24//!     },
25//! ];
26//!
27//! validate_input_object(&input, &validators, None).unwrap();
28//! ```
29
30use serde_json::Value;
31
32use crate::error::{FraiseQLError, Result};
33
34/// Rules that apply at the input object level.
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub enum InputObjectRule {
38    /// At least one field from the set must be provided
39    AnyOf {
40        /// Field names of which at least one must be present.
41        fields: Vec<String>,
42    },
43    /// Exactly one field from the set must be provided
44    OneOf {
45        /// Field names of which exactly one must be present.
46        fields: Vec<String>,
47    },
48    /// If one field is present, others must be present
49    ConditionalRequired {
50        /// The trigger field whose presence activates the requirement.
51        if_field:    String,
52        /// Fields that must be present when `if_field` is provided.
53        then_fields: Vec<String>,
54    },
55    /// If one field is absent, others must be present
56    RequiredIfAbsent {
57        /// The field whose absence activates the requirement.
58        absent_field: String,
59        /// Fields that must be present when `absent_field` is missing.
60        then_fields:  Vec<String>,
61    },
62    /// Custom validator function name to invoke
63    Custom {
64        /// Name of the registered custom validator function.
65        name: String,
66    },
67}
68
69/// Result of validating an input object, aggregating multiple errors.
70#[derive(Debug, Clone, Default)]
71pub struct InputObjectValidationResult {
72    /// All validation errors
73    pub errors:      Vec<String>,
74    /// Count of errors
75    pub error_count: usize,
76}
77
78impl InputObjectValidationResult {
79    /// Create a new empty result.
80    pub const fn new() -> Self {
81        Self {
82            errors:      Vec::new(),
83            error_count: 0,
84        }
85    }
86
87    /// Add an error to the result.
88    pub fn add_error(&mut self, error: String) {
89        self.errors.push(error);
90        self.error_count += 1;
91    }
92
93    /// Add multiple errors at once.
94    pub fn add_errors(&mut self, errors: Vec<String>) {
95        self.error_count += errors.len();
96        self.errors.extend(errors);
97    }
98
99    /// Check if there are any errors.
100    pub const fn has_errors(&self) -> bool {
101        !self.errors.is_empty()
102    }
103
104    /// Convert to a Result, failing if there are errors.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`FraiseQLError::Validation`] if any validation errors have been
109    /// accumulated via [`add_error`][Self::add_error].
110    pub fn into_result(self) -> Result<()> {
111        self.into_result_with_path("input")
112    }
113
114    /// Convert to a Result with a custom path, failing if there are errors.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`FraiseQLError::Validation`] with the given `path` if any
119    /// validation errors have been accumulated.
120    pub fn into_result_with_path(self, path: &str) -> Result<()> {
121        if self.has_errors() {
122            Err(FraiseQLError::Validation {
123                message: format!("Input object validation failed: {}", self.errors.join("; ")),
124                path:    Some(path.to_string()),
125            })
126        } else {
127            Ok(())
128        }
129    }
130}
131
132/// Validate an input object against a set of rules.
133///
134/// Applies all rules to the input object and aggregates errors.
135///
136/// # Arguments
137/// * `input` - The input object to validate
138/// * `rules` - Rules to apply at the object level
139/// * `object_path` - Optional path to the object for error reporting
140///
141/// # Errors
142///
143/// Returns `FraiseQLError::Validation` if any input object rule fails.
144pub fn validate_input_object(
145    input: &Value,
146    rules: &[InputObjectRule],
147    object_path: Option<&str>,
148) -> Result<()> {
149    let mut result = InputObjectValidationResult::new();
150    let path = object_path.unwrap_or("input");
151
152    if !matches!(input, Value::Object(_)) {
153        return Err(FraiseQLError::Validation {
154            message: "Input must be an object".to_string(),
155            path:    Some(path.to_string()),
156        });
157    }
158
159    for rule in rules {
160        if let Err(FraiseQLError::Validation { message, .. }) = validate_rule(input, rule, path) {
161            result.add_error(message);
162        }
163    }
164
165    result.into_result_with_path(path)
166}
167
168/// Validate a single input object rule.
169fn validate_rule(input: &Value, rule: &InputObjectRule, path: &str) -> Result<()> {
170    match rule {
171        InputObjectRule::AnyOf { fields } => validate_any_of(input, fields, path),
172        InputObjectRule::OneOf { fields } => validate_one_of(input, fields, path),
173        InputObjectRule::ConditionalRequired {
174            if_field,
175            then_fields,
176        } => validate_conditional_required(input, if_field, then_fields, path),
177        InputObjectRule::RequiredIfAbsent {
178            absent_field,
179            then_fields,
180        } => validate_required_if_absent(input, absent_field, then_fields, path),
181        InputObjectRule::Custom { name } => Err(FraiseQLError::Validation {
182            message: format!(
183                "Custom validator '{name}' is not registered. \
184                 Register validators via InputValidatorRegistry before executing queries."
185            ),
186            path:    Some(path.to_string()),
187        }),
188    }
189}
190
191/// Validate that at least one field from the set is present.
192fn validate_any_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
193    if let Value::Object(obj) = input {
194        let has_any = fields
195            .iter()
196            .any(|name| obj.get(name).is_some_and(|v| !matches!(v, Value::Null)));
197
198        if !has_any {
199            return Err(FraiseQLError::Validation {
200                message: format!("At least one of [{}] must be provided", fields.join(", ")),
201                path:    Some(path.to_string()),
202            });
203        }
204    }
205
206    Ok(())
207}
208
209/// Validate that exactly one field from the set is present.
210fn validate_one_of(input: &Value, fields: &[String], path: &str) -> Result<()> {
211    if let Value::Object(obj) = input {
212        let present_count = fields
213            .iter()
214            .filter(|name| obj.get(*name).is_some_and(|v| !matches!(v, Value::Null)))
215            .count();
216
217        if present_count != 1 {
218            return Err(FraiseQLError::Validation {
219                message: format!(
220                    "Exactly one of [{}] must be provided, but {} {} provided",
221                    fields.join(", "),
222                    present_count,
223                    if present_count == 1 { "was" } else { "were" }
224                ),
225                path:    Some(path.to_string()),
226            });
227        }
228    }
229
230    Ok(())
231}
232
233/// Validate conditional requirement: if one field is present, others must be too.
234fn validate_conditional_required(
235    input: &Value,
236    if_field: &str,
237    then_fields: &[String],
238    path: &str,
239) -> Result<()> {
240    if let Value::Object(obj) = input {
241        let condition_met = obj.get(if_field).is_some_and(|v| !matches!(v, Value::Null));
242
243        if condition_met {
244            let missing_fields: Vec<&String> = then_fields
245                .iter()
246                .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
247                .collect();
248
249            if !missing_fields.is_empty() {
250                return Err(FraiseQLError::Validation {
251                    message: format!(
252                        "Since '{}' is provided, {} must also be provided",
253                        if_field,
254                        missing_fields
255                            .iter()
256                            .map(|s| format!("'{}'", s))
257                            .collect::<Vec<_>>()
258                            .join(", ")
259                    ),
260                    path:    Some(path.to_string()),
261                });
262            }
263        }
264    }
265
266    Ok(())
267}
268
269/// Validate requirement based on absence: if one field is missing, others must be provided.
270fn validate_required_if_absent(
271    input: &Value,
272    absent_field: &str,
273    then_fields: &[String],
274    path: &str,
275) -> Result<()> {
276    if let Value::Object(obj) = input {
277        let field_absent = obj.get(absent_field).is_none_or(|v| matches!(v, Value::Null));
278
279        if field_absent {
280            let missing_fields: Vec<&String> = then_fields
281                .iter()
282                .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
283                .collect();
284
285            if !missing_fields.is_empty() {
286                return Err(FraiseQLError::Validation {
287                    message: format!(
288                        "Since '{}' is not provided, {} must be provided",
289                        absent_field,
290                        missing_fields
291                            .iter()
292                            .map(|s| format!("'{}'", s))
293                            .collect::<Vec<_>>()
294                            .join(", ")
295                    ),
296                    path:    Some(path.to_string()),
297                });
298            }
299        }
300    }
301
302    Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
308
309    use serde_json::json;
310
311    use super::*;
312
313    #[test]
314    fn test_any_of_passes() {
315        let input = json!({
316            "email": "user@example.com",
317            "phone": null,
318            "address": null
319        });
320        let rules = vec![InputObjectRule::AnyOf {
321            fields: vec![
322                "email".to_string(),
323                "phone".to_string(),
324                "address".to_string(),
325            ],
326        }];
327        let result = validate_input_object(&input, &rules, None);
328        result.unwrap_or_else(|e| panic!("any_of should pass when email is present: {e}"));
329    }
330
331    #[test]
332    fn test_any_of_fails() {
333        let input = json!({
334            "email": null,
335            "phone": null,
336            "address": null
337        });
338        let rules = vec![InputObjectRule::AnyOf {
339            fields: vec![
340                "email".to_string(),
341                "phone".to_string(),
342                "address".to_string(),
343            ],
344        }];
345        let result = validate_input_object(&input, &rules, None);
346        assert!(
347            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one of")),
348            "expected Validation error about missing fields, got: {result:?}"
349        );
350    }
351
352    #[test]
353    fn test_one_of_passes() {
354        let input = json!({
355            "entityId": "123",
356            "entityPayload": null
357        });
358        let rules = vec![InputObjectRule::OneOf {
359            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
360        }];
361        let result = validate_input_object(&input, &rules, None);
362        result.unwrap_or_else(|e| {
363            panic!("one_of should pass when exactly one field is present: {e}")
364        });
365    }
366
367    #[test]
368    fn test_one_of_fails_both_present() {
369        let input = json!({
370            "entityId": "123",
371            "entityPayload": { "name": "test" }
372        });
373        let rules = vec![InputObjectRule::OneOf {
374            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
375        }];
376        let result = validate_input_object(&input, &rules, None);
377        assert!(
378            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
379            "expected Validation error about exactly one field, got: {result:?}"
380        );
381    }
382
383    #[test]
384    fn test_one_of_fails_neither_present() {
385        let input = json!({
386            "entityId": null,
387            "entityPayload": null
388        });
389        let rules = vec![InputObjectRule::OneOf {
390            fields: vec!["entityId".to_string(), "entityPayload".to_string()],
391        }];
392        let result = validate_input_object(&input, &rules, None);
393        assert!(
394            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
395            "expected Validation error about exactly one field, got: {result:?}"
396        );
397    }
398
399    #[test]
400    fn test_conditional_required_passes() {
401        let input = json!({
402            "isPremium": true,
403            "paymentMethod": "credit_card"
404        });
405        let rules = vec![InputObjectRule::ConditionalRequired {
406            if_field:    "isPremium".to_string(),
407            then_fields: vec!["paymentMethod".to_string()],
408        }];
409        let result = validate_input_object(&input, &rules, None);
410        result.unwrap_or_else(|e| {
411            panic!("conditional_required should pass when condition is met: {e}")
412        });
413    }
414
415    #[test]
416    fn test_conditional_required_fails() {
417        let input = json!({
418            "isPremium": true,
419            "paymentMethod": null
420        });
421        let rules = vec![InputObjectRule::ConditionalRequired {
422            if_field:    "isPremium".to_string(),
423            then_fields: vec!["paymentMethod".to_string()],
424        }];
425        let result = validate_input_object(&input, &rules, None);
426        assert!(
427            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
428            "expected Validation error about missing conditional fields, got: {result:?}"
429        );
430    }
431
432    #[test]
433    fn test_conditional_required_skips_when_condition_false() {
434        let input = json!({
435            "isPremium": null,
436            "paymentMethod": null
437        });
438        let rules = vec![InputObjectRule::ConditionalRequired {
439            if_field:    "isPremium".to_string(),
440            then_fields: vec!["paymentMethod".to_string()],
441        }];
442        let result = validate_input_object(&input, &rules, None);
443        result.unwrap_or_else(|e| {
444            panic!("conditional_required should skip when condition field is null: {e}")
445        });
446    }
447
448    #[test]
449    fn test_required_if_absent_passes() {
450        let input = json!({
451            "addressId": null,
452            "street": "123 Main St",
453            "city": "Springfield",
454            "zip": "12345"
455        });
456        let rules = vec![InputObjectRule::RequiredIfAbsent {
457            absent_field: "addressId".to_string(),
458            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
459        }];
460        let result = validate_input_object(&input, &rules, None);
461        result.unwrap_or_else(|e| {
462            panic!("required_if_absent should pass when all then_fields are provided: {e}")
463        });
464    }
465
466    #[test]
467    fn test_required_if_absent_fails() {
468        let input = json!({
469            "addressId": null,
470            "street": "123 Main St",
471            "city": null,
472            "zip": "12345"
473        });
474        let rules = vec![InputObjectRule::RequiredIfAbsent {
475            absent_field: "addressId".to_string(),
476            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
477        }];
478        let result = validate_input_object(&input, &rules, None);
479        assert!(
480            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be provided")),
481            "expected Validation error about missing required fields, got: {result:?}"
482        );
483    }
484
485    #[test]
486    fn test_required_if_absent_skips_when_field_present() {
487        let input = json!({
488            "addressId": "addr_123",
489            "street": null,
490            "city": null,
491            "zip": null
492        });
493        let rules = vec![InputObjectRule::RequiredIfAbsent {
494            absent_field: "addressId".to_string(),
495            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
496        }];
497        let result = validate_input_object(&input, &rules, None);
498        result.unwrap_or_else(|e| {
499            panic!("required_if_absent should skip when absent_field is present: {e}")
500        });
501    }
502
503    #[test]
504    fn test_multiple_rules_all_pass() {
505        let input = json!({
506            "entityId": "123",
507            "entityPayload": null,
508            "isPremium": true,
509            "paymentMethod": "credit_card"
510        });
511        let rules = vec![
512            InputObjectRule::OneOf {
513                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
514            },
515            InputObjectRule::ConditionalRequired {
516                if_field:    "isPremium".to_string(),
517                then_fields: vec!["paymentMethod".to_string()],
518            },
519        ];
520        let result = validate_input_object(&input, &rules, None);
521        result.unwrap_or_else(|e| panic!("multiple rules should all pass: {e}"));
522    }
523
524    #[test]
525    fn test_multiple_rules_one_fails() {
526        let input = json!({
527            "entityId": "123",
528            "entityPayload": null,
529            "isPremium": true,
530            "paymentMethod": null
531        });
532        let rules = vec![
533            InputObjectRule::OneOf {
534                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
535            },
536            InputObjectRule::ConditionalRequired {
537                if_field:    "isPremium".to_string(),
538                then_fields: vec!["paymentMethod".to_string()],
539            },
540        ];
541        let result = validate_input_object(&input, &rules, None);
542        assert!(
543            matches!(result, Err(FraiseQLError::Validation { .. })),
544            "expected Validation error when one rule fails, got: {result:?}"
545        );
546    }
547
548    #[test]
549    fn test_multiple_rules_both_fail() {
550        let input = json!({
551            "entityId": "123",
552            "entityPayload": { "name": "test" },
553            "isPremium": true,
554            "paymentMethod": null
555        });
556        let rules = vec![
557            InputObjectRule::OneOf {
558                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
559            },
560            InputObjectRule::ConditionalRequired {
561                if_field:    "isPremium".to_string(),
562                then_fields: vec!["paymentMethod".to_string()],
563            },
564        ];
565        let result = validate_input_object(&input, &rules, None);
566        assert!(
567            matches!(result, Err(FraiseQLError::Validation { ref message, .. })
568                if message.contains("Exactly one") || message.contains("must also be provided")),
569            "expected aggregated Validation error with both failures, got: {result:?}"
570        );
571    }
572
573    #[test]
574    fn test_error_aggregation() {
575        let input = json!({
576            "entityId": "123",
577            "entityPayload": { "name": "test" },
578            "isPremium": true,
579            "paymentMethod": null
580        });
581        let rules = vec![
582            InputObjectRule::OneOf {
583                fields: vec!["entityId".to_string(), "entityPayload".to_string()],
584            },
585            InputObjectRule::ConditionalRequired {
586                if_field:    "isPremium".to_string(),
587                then_fields: vec!["paymentMethod".to_string()],
588            },
589        ];
590
591        let result = validate_input_object(&input, &rules, Some("createInput"));
592        match result {
593            Err(FraiseQLError::Validation {
594                ref message,
595                ref path,
596            }) => {
597                assert_eq!(*path, Some("createInput".to_string()));
598                assert!(message.contains("failed"), "expected 'failed' in message, got: {message}");
599            },
600            other => panic!("expected Validation error with custom path, got: {other:?}"),
601        }
602    }
603
604    #[test]
605    fn test_conditional_required_multiple_fields() {
606        let input = json!({
607            "isInternational": true,
608            "customsCode": "ABC123",
609            "importDuties": "50.00"
610        });
611        let rules = vec![InputObjectRule::ConditionalRequired {
612            if_field:    "isInternational".to_string(),
613            then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
614        }];
615        let result = validate_input_object(&input, &rules, None);
616        result.unwrap_or_else(|e| {
617            panic!("conditional_required with multiple fields should pass: {e}")
618        });
619    }
620
621    #[test]
622    fn test_conditional_required_multiple_fields_one_missing() {
623        let input = json!({
624            "isInternational": true,
625            "customsCode": "ABC123",
626            "importDuties": null
627        });
628        let rules = vec![InputObjectRule::ConditionalRequired {
629            if_field:    "isInternational".to_string(),
630            then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
631        }];
632        let result = validate_input_object(&input, &rules, None);
633        assert!(
634            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
635            "expected Validation error about missing conditional field, got: {result:?}"
636        );
637    }
638
639    #[test]
640    fn test_validation_result_aggregation() {
641        let mut result = InputObjectValidationResult::new();
642        assert!(!result.has_errors());
643        assert_eq!(result.error_count, 0);
644
645        result.add_error("Error 1".to_string());
646        assert!(result.has_errors());
647        assert_eq!(result.error_count, 1);
648
649        result.add_errors(vec!["Error 2".to_string(), "Error 3".to_string()]);
650        assert_eq!(result.error_count, 3);
651    }
652
653    #[test]
654    fn test_validation_result_into_result_success() {
655        let result = InputObjectValidationResult::new();
656        result
657            .into_result()
658            .unwrap_or_else(|e| panic!("empty result should be Ok: {e}"));
659    }
660
661    #[test]
662    fn test_validation_result_into_result_failure() {
663        let mut result = InputObjectValidationResult::new();
664        result.add_error("Test error".to_string());
665        let outcome = result.into_result();
666        assert!(
667            matches!(outcome, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Test error")),
668            "expected Validation error containing 'Test error', got: {outcome:?}"
669        );
670    }
671
672    #[test]
673    fn test_non_object_input() {
674        let input = json!([1, 2, 3]);
675        let rules = vec![InputObjectRule::AnyOf {
676            fields: vec!["field".to_string()],
677        }];
678        let result = validate_input_object(&input, &rules, None);
679        assert!(
680            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be an object")),
681            "expected Validation error about non-object input, got: {result:?}"
682        );
683    }
684
685    #[test]
686    fn test_empty_rules() {
687        let input = json!({"field": "value"});
688        let rules: Vec<InputObjectRule> = vec![];
689        let result = validate_input_object(&input, &rules, None);
690        result.unwrap_or_else(|e| panic!("empty rules should always pass: {e}"));
691    }
692
693    #[test]
694    fn test_custom_validator_not_implemented() {
695        let input = json!({"field": "value"});
696        let rules = vec![InputObjectRule::Custom {
697            name: "myValidator".to_string(),
698        }];
699        let result = validate_input_object(&input, &rules, None);
700        match result {
701            Err(FraiseQLError::Validation { ref message, .. }) => {
702                assert!(
703                    message.contains("myValidator"),
704                    "expected 'myValidator' in message, got: {message}"
705                );
706                assert!(
707                    message.contains("InputValidatorRegistry"),
708                    "expected 'InputValidatorRegistry' in message, got: {message}"
709                );
710            },
711            other => {
712                panic!("expected Validation error about unregistered validator, got: {other:?}")
713            },
714        }
715    }
716
717    #[test]
718    fn test_complex_create_or_reference_pattern() {
719        // Either provide entityId OR provide (name + description), but not both
720        let input = json!({
721            "entityId": "123",
722            "name": null,
723            "description": null
724        });
725        let rules = vec![InputObjectRule::OneOf {
726            fields: vec!["entityId".to_string(), "name".to_string()],
727        }];
728        let result = validate_input_object(&input, &rules, None);
729        result.unwrap_or_else(|e| {
730            panic!("create_or_reference pattern should pass with entityId: {e}")
731        });
732    }
733
734    #[test]
735    fn test_complex_address_pattern() {
736        // Either provide addressId OR provide all of (street, city, zip)
737        let input = json!({
738            "addressId": null,
739            "street": "123 Main St",
740            "city": "Springfield",
741            "zip": "12345"
742        });
743        let rules = vec![InputObjectRule::RequiredIfAbsent {
744            absent_field: "addressId".to_string(),
745            then_fields:  vec!["street".to_string(), "city".to_string(), "zip".to_string()],
746        }];
747        let result = validate_input_object(&input, &rules, None);
748        result.unwrap_or_else(|e| {
749            panic!("address pattern should pass with all fields provided: {e}")
750        });
751    }
752}