Skip to main content

fraiseql_core/validation/
mutual_exclusivity.rs

1//! Mutual exclusivity and conditional requirement validators.
2//!
3//! This module provides validators for complex field-relationship rules:
4//! - `OneOf`: Exactly one field from a set must be provided
5//! - `AnyOf`: At least one field from a set must be provided
6//! - `ConditionalRequired`: If one field is present, others must be too
7//! - `RequiredIfAbsent`: If one field is missing, others must be provided
8
9use serde_json::Value;
10
11use crate::error::{FraiseQLError, Result};
12
13/// Validates that exactly one field from the specified set is provided.
14///
15/// # Example
16/// ```
17/// use fraiseql_core::validation::mutual_exclusivity::OneOfValidator;
18/// use serde_json::json;
19/// // Either entityId OR entityPayload, but not both
20/// let input = json!({ "entityId": "123", "entityPayload": null });
21/// assert!(
22///     OneOfValidator::validate(&input, &["entityId".to_string(), "entityPayload".to_string()], None).is_ok(),
23///     "one-of constraint satisfied when only entityId is present"
24/// );
25/// ```
26pub struct OneOfValidator;
27
28impl OneOfValidator {
29    /// Validate that exactly one field from the set is present and non-null.
30    ///
31    /// # Errors
32    ///
33    /// Returns `FraiseQLError::Validation` if zero or more than one field is present.
34    pub fn validate(
35        input: &Value,
36        field_names: &[String],
37        context_path: Option<&str>,
38    ) -> Result<()> {
39        let field_path = context_path.unwrap_or("input");
40
41        let present_count = field_names
42            .iter()
43            .filter(|name| {
44                if let Value::Object(obj) = input {
45                    obj.get(*name).is_some_and(|v| !matches!(v, Value::Null))
46                } else {
47                    false
48                }
49            })
50            .count();
51
52        if present_count != 1 {
53            return Err(FraiseQLError::Validation {
54                message: format!(
55                    "Exactly one of [{}] must be provided, but {} {} provided",
56                    field_names.join(", "),
57                    present_count,
58                    if present_count == 1 { "was" } else { "were" }
59                ),
60                path:    Some(field_path.to_string()),
61            });
62        }
63
64        Ok(())
65    }
66}
67
68/// Validates that at least one field from the specified set is provided.
69///
70/// # Example
71/// ```
72/// use fraiseql_core::validation::mutual_exclusivity::AnyOfValidator;
73/// use serde_json::json;
74/// // At least one of: email, phone, address must be present
75/// let input = json!({ "email": "user@example.com", "phone": null, "address": null });
76/// assert!(
77///     AnyOfValidator::validate(&input, &["email".to_string(), "phone".to_string(), "address".to_string()], None).is_ok(),
78///     "any-of constraint satisfied when at least one field is present"
79/// );
80/// ```
81pub struct AnyOfValidator;
82
83impl AnyOfValidator {
84    /// Validate that at least one field from the set is present and non-null.
85    ///
86    /// # Errors
87    ///
88    /// Returns `FraiseQLError::Validation` if none of the specified fields are present.
89    pub fn validate(
90        input: &Value,
91        field_names: &[String],
92        context_path: Option<&str>,
93    ) -> Result<()> {
94        let field_path = context_path.unwrap_or("input");
95
96        let has_any = field_names.iter().any(|name| {
97            if let Value::Object(obj) = input {
98                obj.get(name).is_some_and(|v| !matches!(v, Value::Null))
99            } else {
100                false
101            }
102        });
103
104        if !has_any {
105            return Err(FraiseQLError::Validation {
106                message: format!("At least one of [{}] must be provided", field_names.join(", ")),
107                path:    Some(field_path.to_string()),
108            });
109        }
110
111        Ok(())
112    }
113}
114
115/// Validates conditional requirement: if one field is present, others must be too.
116///
117/// # Example
118/// ```
119/// use fraiseql_core::validation::mutual_exclusivity::ConditionalRequiredValidator;
120/// use serde_json::json;
121/// // If isPremium is true, then paymentMethod is required
122/// let input = json!({ "isPremium": true, "paymentMethod": "credit_card" });
123/// assert!(
124///     ConditionalRequiredValidator::validate(&input, "isPremium", &["paymentMethod".to_string()], None).is_ok(),
125///     "conditional requirement satisfied when condition field is true and required field present"
126/// );
127/// ```
128pub struct ConditionalRequiredValidator;
129
130impl ConditionalRequiredValidator {
131    /// Validate that if `if_field_present` is present, all `then_required` fields must be too.
132    ///
133    /// # Errors
134    ///
135    /// Returns `FraiseQLError::Validation` if the condition field is present but any
136    /// required field is missing.
137    pub fn validate(
138        input: &Value,
139        if_field_present: &str,
140        then_required: &[String],
141        context_path: Option<&str>,
142    ) -> Result<()> {
143        let field_path = context_path.unwrap_or("input");
144
145        if let Value::Object(obj) = input {
146            // Check if the condition field is present and non-null
147            let condition_met =
148                obj.get(if_field_present).is_some_and(|v| !matches!(v, Value::Null));
149
150            if condition_met {
151                // If condition is met, check that all required fields are present
152                let missing_fields: Vec<&String> = then_required
153                    .iter()
154                    .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
155                    .collect();
156
157                if !missing_fields.is_empty() {
158                    return Err(FraiseQLError::Validation {
159                        message: format!(
160                            "Since '{}' is provided, {} must also be provided",
161                            if_field_present,
162                            missing_fields
163                                .iter()
164                                .map(|s| format!("'{}'", s))
165                                .collect::<Vec<_>>()
166                                .join(", ")
167                        ),
168                        path:    Some(field_path.to_string()),
169                    });
170                }
171            }
172        }
173
174        Ok(())
175    }
176}
177
178/// Validates conditional requirement based on absence: if one field is missing, others must be
179/// provided.
180///
181/// # Example
182/// ```
183/// use fraiseql_core::validation::mutual_exclusivity::RequiredIfAbsentValidator;
184/// use serde_json::json;
185/// // If addressId is not provided, then street, city, zip must all be provided
186/// let input = json!({ "addressId": null, "street": "123 Main St", "city": "Springfield", "zip": "12345" });
187/// assert!(
188///     RequiredIfAbsentValidator::validate(&input, "addressId", &["street".to_string(), "city".to_string(), "zip".to_string()], None).is_ok(),
189///     "required-if-absent constraint satisfied when absent field is null and all required fields present"
190/// );
191/// ```
192pub struct RequiredIfAbsentValidator;
193
194impl RequiredIfAbsentValidator {
195    /// Validate that if `absent_field` is absent/null, all `then_required` fields must be provided.
196    ///
197    /// # Errors
198    ///
199    /// Returns `FraiseQLError::Validation` if the condition field is absent and any
200    /// required field is also missing.
201    pub fn validate(
202        input: &Value,
203        absent_field: &str,
204        then_required: &[String],
205        context_path: Option<&str>,
206    ) -> Result<()> {
207        let field_path = context_path.unwrap_or("input");
208
209        if let Value::Object(obj) = input {
210            // Check if the condition field is absent or null
211            let field_absent = obj.get(absent_field).is_none_or(|v| matches!(v, Value::Null));
212
213            if field_absent {
214                // If field is absent, check that all required fields are present
215                let missing_fields: Vec<&String> = then_required
216                    .iter()
217                    .filter(|name| obj.get(*name).is_none_or(|v| matches!(v, Value::Null)))
218                    .collect();
219
220                if !missing_fields.is_empty() {
221                    return Err(FraiseQLError::Validation {
222                        message: format!(
223                            "Since '{}' is not provided, {} must be provided",
224                            absent_field,
225                            missing_fields
226                                .iter()
227                                .map(|s| format!("'{}'", s))
228                                .collect::<Vec<_>>()
229                                .join(", ")
230                        ),
231                        path:    Some(field_path.to_string()),
232                    });
233                }
234            }
235        }
236
237        Ok(())
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use serde_json::json;
244
245    use super::*;
246
247    #[test]
248    fn test_one_of_validator_exactly_one_present() {
249        let input = json!({
250            "entityId": "123",
251            "entityPayload": null
252        });
253        let result = OneOfValidator::validate(
254            &input,
255            &["entityId".to_string(), "entityPayload".to_string()],
256            None,
257        );
258        result.unwrap_or_else(|e| panic!("expected exactly-one to pass with one present: {e}"));
259    }
260
261    #[test]
262    fn test_one_of_validator_both_present() {
263        let input = json!({
264            "entityId": "123",
265            "entityPayload": { "name": "test" }
266        });
267        let result = OneOfValidator::validate(
268            &input,
269            &["entityId".to_string(), "entityPayload".to_string()],
270            None,
271        );
272        assert!(
273            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
274            "expected Validation error for both fields present, got: {result:?}"
275        );
276    }
277
278    #[test]
279    fn test_one_of_validator_neither_present() {
280        let input = json!({
281            "entityId": null,
282            "entityPayload": null
283        });
284        let result = OneOfValidator::validate(
285            &input,
286            &["entityId".to_string(), "entityPayload".to_string()],
287            None,
288        );
289        assert!(
290            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
291            "expected Validation error for neither field present, got: {result:?}"
292        );
293    }
294
295    #[test]
296    fn test_one_of_validator_missing_field() {
297        let input = json!({
298            "entityId": "123"
299        });
300        let result = OneOfValidator::validate(
301            &input,
302            &["entityId".to_string(), "entityPayload".to_string()],
303            None,
304        );
305        result.unwrap_or_else(|e| {
306            panic!("expected exactly-one to pass with one field missing from object: {e}")
307        });
308    }
309
310    #[test]
311    fn test_any_of_validator_one_present() {
312        let input = json!({
313            "email": "user@example.com",
314            "phone": null,
315            "address": null
316        });
317        let result = AnyOfValidator::validate(
318            &input,
319            &[
320                "email".to_string(),
321                "phone".to_string(),
322                "address".to_string(),
323            ],
324            None,
325        );
326        result.unwrap_or_else(|e| panic!("expected any-of to pass with one present: {e}"));
327    }
328
329    #[test]
330    fn test_any_of_validator_multiple_present() {
331        let input = json!({
332            "email": "user@example.com",
333            "phone": "+1234567890",
334            "address": null
335        });
336        let result = AnyOfValidator::validate(
337            &input,
338            &[
339                "email".to_string(),
340                "phone".to_string(),
341                "address".to_string(),
342            ],
343            None,
344        );
345        result.unwrap_or_else(|e| panic!("expected any-of to pass with multiple present: {e}"));
346    }
347
348    #[test]
349    fn test_any_of_validator_none_present() {
350        let input = json!({
351            "email": null,
352            "phone": null,
353            "address": null
354        });
355        let result = AnyOfValidator::validate(
356            &input,
357            &[
358                "email".to_string(),
359                "phone".to_string(),
360                "address".to_string(),
361            ],
362            None,
363        );
364        assert!(
365            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one of")),
366            "expected Validation error for no fields present, got: {result:?}"
367        );
368    }
369
370    #[test]
371    fn test_conditional_required_validator_condition_met_requirement_met() {
372        let input = json!({
373            "isPremium": true,
374            "paymentMethod": "credit_card"
375        });
376        let result = ConditionalRequiredValidator::validate(
377            &input,
378            "isPremium",
379            &["paymentMethod".to_string()],
380            None,
381        );
382        result.unwrap_or_else(|e| {
383            panic!("expected conditional-required to pass when requirement met: {e}")
384        });
385    }
386
387    #[test]
388    fn test_conditional_required_validator_condition_met_requirement_missing() {
389        let input = json!({
390            "isPremium": true,
391            "paymentMethod": null
392        });
393        let result = ConditionalRequiredValidator::validate(
394            &input,
395            "isPremium",
396            &["paymentMethod".to_string()],
397            None,
398        );
399        assert!(
400            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must also be provided")),
401            "expected Validation error for missing conditional requirement, got: {result:?}"
402        );
403    }
404
405    #[test]
406    fn test_conditional_required_validator_condition_not_met() {
407        let input = json!({
408            "isPremium": null,
409            "paymentMethod": null
410        });
411        let result = ConditionalRequiredValidator::validate(
412            &input,
413            "isPremium",
414            &["paymentMethod".to_string()],
415            None,
416        );
417        result.unwrap_or_else(|e| {
418            panic!("expected conditional-required to pass when condition not met: {e}")
419        });
420    }
421
422    #[test]
423    fn test_conditional_required_validator_multiple_requirements() {
424        let input = json!({
425            "isInternational": true,
426            "customsCode": "ABC123",
427            "importDuties": "50.00"
428        });
429        let result = ConditionalRequiredValidator::validate(
430            &input,
431            "isInternational",
432            &["customsCode".to_string(), "importDuties".to_string()],
433            None,
434        );
435        result.unwrap_or_else(|e| {
436            panic!("expected conditional-required to pass with all requirements met: {e}")
437        });
438    }
439
440    #[test]
441    fn test_conditional_required_validator_one_requirement_missing() {
442        let input = json!({
443            "isInternational": true,
444            "customsCode": "ABC123",
445            "importDuties": null
446        });
447        let result = ConditionalRequiredValidator::validate(
448            &input,
449            "isInternational",
450            &["customsCode".to_string(), "importDuties".to_string()],
451            None,
452        );
453        assert!(
454            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must also be provided")),
455            "expected Validation error for one missing requirement, got: {result:?}"
456        );
457    }
458
459    #[test]
460    fn test_required_if_absent_validator_field_absent_requirements_met() {
461        let input = json!({
462            "addressId": null,
463            "street": "123 Main St",
464            "city": "Springfield",
465            "zip": "12345"
466        });
467        let result = RequiredIfAbsentValidator::validate(
468            &input,
469            "addressId",
470            &["street".to_string(), "city".to_string(), "zip".to_string()],
471            None,
472        );
473        result.unwrap_or_else(|e| {
474            panic!("expected required-if-absent to pass when requirements met: {e}")
475        });
476    }
477
478    #[test]
479    fn test_required_if_absent_validator_field_absent_requirements_missing() {
480        let input = json!({
481            "addressId": null,
482            "street": "123 Main St",
483            "city": null,
484            "zip": "12345"
485        });
486        let result = RequiredIfAbsentValidator::validate(
487            &input,
488            "addressId",
489            &["street".to_string(), "city".to_string(), "zip".to_string()],
490            None,
491        );
492        assert!(
493            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must be provided")),
494            "expected Validation error for missing requirements when field absent, got: {result:?}"
495        );
496    }
497
498    #[test]
499    fn test_required_if_absent_validator_field_present() {
500        let input = json!({
501            "addressId": "addr_123",
502            "street": null,
503            "city": null,
504            "zip": null
505        });
506        let result = RequiredIfAbsentValidator::validate(
507            &input,
508            "addressId",
509            &["street".to_string(), "city".to_string(), "zip".to_string()],
510            None,
511        );
512        result.unwrap_or_else(|e| {
513            panic!("expected required-if-absent to pass when field present: {e}")
514        });
515    }
516
517    #[test]
518    fn test_required_if_absent_validator_all_missing_from_object() {
519        let input = json!({});
520        let result = RequiredIfAbsentValidator::validate(
521            &input,
522            "addressId",
523            &["street".to_string(), "city".to_string()],
524            None,
525        );
526        assert!(
527            matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must be provided")),
528            "expected Validation error for all fields missing from empty object, got: {result:?}"
529        );
530    }
531
532    #[test]
533    fn test_error_messages_include_context() {
534        let input = json!({
535            "entityId": "123",
536            "entityPayload": { "name": "test" }
537        });
538        let result = OneOfValidator::validate(
539            &input,
540            &["entityId".to_string(), "entityPayload".to_string()],
541            Some("createInput"),
542        );
543        assert!(
544            matches!(result, Err(FraiseQLError::Validation { ref path, .. }) if *path == Some("createInput".to_string())),
545            "expected Validation error with path 'createInput', got: {result:?}"
546        );
547    }
548}