Skip to main content

yaml_schema/schemas/
if_then_else.rs

1//! JSON Schema-style `if` / `then` / `else` conditional validation.
2
3use std::fmt::Display;
4
5use log::debug;
6use saphyr::AnnotatedMapping;
7use saphyr::MarkedYaml;
8use saphyr::YamlData;
9
10use crate::Context;
11use crate::Error;
12use crate::Result;
13use crate::Validator;
14use crate::YamlSchema;
15
16/// Conditional schema: `if` outcome selects `then` or `else`; `if` errors are not asserted on the parent.
17#[derive(Debug, PartialEq)]
18pub struct IfThenElseSchema {
19    pub if_schema: Box<YamlSchema>,
20    pub then_schema: Option<Box<YamlSchema>>,
21    pub else_schema: Option<Box<YamlSchema>>,
22}
23
24impl Display for IfThenElseSchema {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "if: {}", self.if_schema)?;
27        if let Some(t) = &self.then_schema {
28            write!(f, ", then: {t}")?;
29        }
30        if let Some(e) = &self.else_schema {
31            write!(f, ", else: {e}")?;
32        }
33        Ok(())
34    }
35}
36
37impl<'r> TryFrom<&MarkedYaml<'r>> for IfThenElseSchema {
38    type Error = crate::Error;
39
40    fn try_from(value: &MarkedYaml<'r>) -> Result<Self> {
41        if let YamlData::Mapping(mapping) = &value.data {
42            IfThenElseSchema::try_from(mapping)
43        } else {
44            Err(expected_mapping!(value))
45        }
46    }
47}
48
49impl<'r> TryFrom<&AnnotatedMapping<'r, MarkedYaml<'r>>> for IfThenElseSchema {
50    type Error = crate::Error;
51
52    fn try_from(mapping: &AnnotatedMapping<'r, MarkedYaml<'r>>) -> crate::Result<Self> {
53        let if_key = MarkedYaml::value_from_str("if");
54        let Some(if_value) = mapping.get(&if_key) else {
55            return Err(generic_error!("No `if` key found for if/then/else"));
56        };
57        let if_schema: YamlSchema = if_value.try_into()?;
58
59        let then_schema = mapping
60            .get(&MarkedYaml::value_from_str("then"))
61            .map(|v| v.try_into())
62            .transpose()?
63            .map(Box::new);
64
65        let else_schema = mapping
66            .get(&MarkedYaml::value_from_str("else"))
67            .map(|v| v.try_into())
68            .transpose()?
69            .map(Box::new);
70
71        Ok(IfThenElseSchema {
72            if_schema: Box::new(if_schema),
73            then_schema,
74            else_schema,
75        })
76    }
77}
78
79impl Validator for IfThenElseSchema {
80    fn validate(&self, context: &Context, value: &saphyr::MarkedYaml) -> crate::Result<()> {
81        debug!(
82            "if/then/else: validating instance against `if` schema: {}",
83            self.if_schema
84        );
85        let if_context = context.get_sub_context_fresh_eval();
86        let if_result = self.if_schema.validate(&if_context, value);
87
88        let if_passed = match if_result {
89            Ok(()) | Err(Error::FailFast) => !if_context.has_errors(),
90            Err(e) => return Err(e),
91        };
92
93        if if_passed {
94            if let (Some(p), Some(f)) = (&context.object_evaluated, &if_context.object_evaluated) {
95                p.extend(&f.snapshot());
96            }
97            if let (Some(pcell), Some(fcell)) =
98                (&context.array_unevaluated, &if_context.array_unevaluated)
99            {
100                let snap = fcell.borrow().clone();
101                pcell.borrow_mut().merge_from(&snap);
102            }
103            if let Some(then_s) = &self.then_schema {
104                then_s.validate(context, value)?;
105            }
106        } else if let Some(else_s) = &self.else_schema {
107            else_s.validate(context, value)?;
108        }
109
110        Ok(())
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use saphyr::LoadableYamlNode;
117
118    use crate::Context;
119    use crate::Engine;
120    use crate::Validator;
121    use crate::loader;
122
123    use super::*;
124
125    #[test]
126    fn if_passes_then_enforced() {
127        let root = loader::load_from_str(
128            r#"
129            if:
130              type: integer
131            then:
132              type: integer
133              minimum: 5
134            "#,
135        )
136        .unwrap();
137        let ctx = Context::with_root_schema(&root, false);
138        let v = MarkedYaml::load_from_str("7").unwrap();
139        root.validate(&ctx, v.first().unwrap()).unwrap();
140        assert!(!ctx.has_errors());
141
142        let ctx = Context::with_root_schema(&root, false);
143        let v = MarkedYaml::load_from_str("3").unwrap();
144        root.validate(&ctx, v.first().unwrap()).unwrap();
145        assert!(ctx.has_errors());
146    }
147
148    #[test]
149    fn if_fails_else_enforced() {
150        let root = loader::load_from_str(
151            r#"
152            if:
153              type: integer
154            else:
155              type: string
156              minLength: 2
157            "#,
158        )
159        .unwrap();
160        let ctx = Context::with_root_schema(&root, false);
161        let v = MarkedYaml::load_from_str("\"ab\"").unwrap();
162        root.validate(&ctx, v.first().unwrap()).unwrap();
163        assert!(!ctx.has_errors());
164
165        let ctx = Context::with_root_schema(&root, false);
166        let v = MarkedYaml::load_from_str("\"x\"").unwrap();
167        root.validate(&ctx, v.first().unwrap()).unwrap();
168        assert!(ctx.has_errors());
169    }
170
171    #[test]
172    fn if_fails_no_else_ok() {
173        let root = loader::load_from_str(
174            r#"
175            if:
176              type: string
177            then:
178              minLength: 10
179            "#,
180        )
181        .unwrap();
182        let ctx = Context::with_root_schema(&root, false);
183        let v = MarkedYaml::load_from_str("42").unwrap();
184        root.validate(&ctx, v.first().unwrap()).unwrap();
185        assert!(
186            !ctx.has_errors(),
187            "if failed so then is skipped; instance should be valid"
188        );
189    }
190
191    #[test]
192    fn if_errors_not_reported_on_parent() {
193        let root = loader::load_from_str(
194            r#"
195            if:
196              type: string
197            then:
198              minLength: 100
199            "#,
200        )
201        .unwrap();
202        let ctx = Context::with_root_schema(&root, false);
203        let v = MarkedYaml::load_from_str("42").unwrap();
204        root.validate(&ctx, v.first().unwrap()).unwrap();
205        assert!(
206            !ctx.has_errors(),
207            "failure of `if` must not surface as parent errors"
208        );
209    }
210
211    #[test]
212    fn type_and_conditional_both_apply() {
213        let root = loader::load_from_str(
214            r#"
215            type: integer
216            maximum: 10
217            if:
218              const: 5
219            then:
220              minimum: 0
221            "#,
222        )
223        .unwrap();
224        let ctx = Context::with_root_schema(&root, false);
225        let v = MarkedYaml::load_from_str("20").unwrap();
226        root.validate(&ctx, v.first().unwrap()).unwrap();
227        assert!(
228            ctx.has_errors(),
229            "fails maximum even when if does not match and then is skipped"
230        );
231    }
232
233    /// Examples from JSON Schema docs: if / then / else (postal codes).
234    /// https://json-schema.org/understanding-json-schema/reference/conditionals#ifthenelse
235    #[test]
236    fn json_schema_doc_usa_canada_postal_if_then_else() {
237        let schema = r#"
238type: object
239properties:
240  street_address:
241    type: string
242  country:
243    enum:
244      - United States of America
245      - Canada
246if:
247  type: object
248  properties:
249    country:
250      const: "United States of America"
251then:
252  type: object
253  properties:
254    postal_code:
255      type: string
256      pattern: '^[0-9]{5}(-[0-9]{4})?$'
257else:
258  type: object
259  properties:
260    postal_code:
261      type: string
262      pattern: '^[A-Z][0-9][A-Z] [0-9][A-Z][0-9]$'
263"#;
264        let root = loader::load_from_str(schema).unwrap();
265
266        let ok_usa = r#"
267street_address: "1600 Pennsylvania Avenue NW"
268country: "United States of America"
269postal_code: "20500"
270"#;
271        assert!(!Engine::evaluate(&root, ok_usa, false).unwrap().has_errors());
272
273        let ok_default_us = r#"
274street_address: "1600 Pennsylvania Avenue NW"
275postal_code: "20500"
276"#;
277        assert!(
278            !Engine::evaluate(&root, ok_default_us, false)
279                .unwrap()
280                .has_errors()
281        );
282
283        let ok_ca = r#"
284street_address: "24 Sussex Drive"
285country: Canada
286postal_code: "K1M 1M4"
287"#;
288        assert!(!Engine::evaluate(&root, ok_ca, false).unwrap().has_errors());
289
290        let bad_ca = r#"
291street_address: "24 Sussex Drive"
292country: Canada
293postal_code: "10000"
294"#;
295        assert!(Engine::evaluate(&root, bad_ca, false).unwrap().has_errors());
296
297        let bad_wrong_zip = r#"
298street_address: "1600 Pennsylvania Avenue NW"
299postal_code: "K1M 1M4"
300"#;
301        assert!(
302            Engine::evaluate(&root, bad_wrong_zip, false)
303                .unwrap()
304                .has_errors()
305        );
306    }
307
308    #[test]
309    fn json_schema_doc_allof_three_countries_postal() {
310        let schema = r#"
311type: object
312properties:
313  street_address:
314    type: string
315  country:
316    enum:
317      - United States of America
318      - Canada
319      - Netherlands
320allOf:
321  - if:
322      type: object
323      properties:
324        country:
325          const: "United States of America"
326    then:
327      type: object
328      properties:
329        postal_code:
330          type: string
331          pattern: '^[0-9]{5}(-[0-9]{4})?$'
332  - if:
333      type: object
334      properties:
335        country:
336          const: Canada
337      required:
338        - country
339    then:
340      type: object
341      properties:
342        postal_code:
343          type: string
344          pattern: '^[A-Z][0-9][A-Z] [0-9][A-Z][0-9]$'
345  - if:
346      type: object
347      properties:
348        country:
349          const: Netherlands
350      required:
351        - country
352    then:
353      type: object
354      properties:
355        postal_code:
356          type: string
357          pattern: '^[0-9]{4} [A-Z]{2}$'
358"#;
359        let root = loader::load_from_str(schema).unwrap();
360
361        let ok_usa = r#"
362street_address: "1600 Pennsylvania Avenue NW"
363country: "United States of America"
364postal_code: "20500"
365"#;
366        assert!(!Engine::evaluate(&root, ok_usa, false).unwrap().has_errors());
367
368        let ok_default_us = r#"
369street_address: "1600 Pennsylvania Avenue NW"
370postal_code: "20500"
371"#;
372        assert!(
373            !Engine::evaluate(&root, ok_default_us, false)
374                .unwrap()
375                .has_errors()
376        );
377
378        let ok_ca = r#"
379street_address: "24 Sussex Drive"
380country: Canada
381postal_code: "K1M 1M4"
382"#;
383        assert!(!Engine::evaluate(&root, ok_ca, false).unwrap().has_errors());
384
385        let nl = r#"
386street_address: "Adriaan Goekooplaan"
387country: Netherlands
388postal_code: "2517 JX"
389"#;
390        assert!(!Engine::evaluate(&root, nl, false).unwrap().has_errors());
391
392        let bad_ca = r#"
393street_address: "24 Sussex Drive"
394country: Canada
395postal_code: "10000"
396"#;
397        assert!(Engine::evaluate(&root, bad_ca, false).unwrap().has_errors());
398
399        let bad_wrong_zip = r#"
400street_address: "1600 Pennsylvania Avenue NW"
401postal_code: "K1M 1M4"
402"#;
403        assert!(
404            Engine::evaluate(&root, bad_wrong_zip, false)
405                .unwrap()
406                .has_errors()
407        );
408    }
409}