Skip to main content

morphir_core/
data_value.rs

1//! Pure validation of closed JSON data against Morphir type declarations.
2//!
3//! The validator checks one value after metadata expansion has decided that it
4//! is data. A node reference is a distinct object term, and a bare JSON `null`
5//! that produced no fact never reaches this module.
6
7use crate::ir::{classic, v4};
8use crate::metadata::ObjectTerm;
9use crate::naming::FQName;
10use serde_json::Value;
11use std::collections::{HashMap, HashSet};
12
13mod declarations;
14use declarations::{
15    classic_path, collect_v3_definitions, collect_v3_specifications, collect_v4_definitions,
16    collect_v4_specifications, v3_shape, v4_shape,
17};
18
19/// The reason a closed data value was refused.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DataValueErrorKind {
22    /// The declared shape is supported, but the value does not match it.
23    Mismatch,
24    /// The tag is absent from a resolved custom type.
25    UnknownConstructor,
26    /// The declared type has no supported closed JSON representation.
27    UnsupportedType,
28}
29
30/// A data validation failure at a path within one JSON value.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[error("{message} at {path}")]
33pub struct DataValueError {
34    /// Failure category for callers that report contract diagnostics.
35    pub kind: DataValueErrorKind,
36    /// JSON path rooted at `$`.
37    pub path: String,
38    /// Human-readable detail.
39    pub message: String,
40}
41
42fn error(kind: DataValueErrorKind, path: &str, message: impl Into<String>) -> DataValueError {
43    DataValueError {
44        kind,
45        path: path.to_owned(),
46        message: message.into(),
47    }
48}
49
50fn mismatch(path: &str, message: impl Into<String>) -> DataValueError {
51    error(DataValueErrorKind::Mismatch, path, message)
52}
53
54fn unsupported(path: &str, message: impl Into<String>) -> DataValueError {
55    error(DataValueErrorKind::UnsupportedType, path, message)
56}
57
58#[derive(Clone, PartialEq, Eq, Hash)]
59enum Shape {
60    Unit,
61    Variable(String),
62    Reference(String, Vec<Self>),
63    Record(Vec<(String, Self)>),
64    Tuple(Vec<Self>),
65    Unsupported(&'static str),
66}
67
68#[derive(Clone)]
69enum Definition {
70    Alias {
71        params: Vec<String>,
72        body: Shape,
73    },
74    Custom {
75        params: Vec<String>,
76        constructors: HashMap<String, Vec<Shape>>,
77    },
78    Unsupported(&'static str),
79}
80
81/// A declaration closure used to validate Morphir data without I/O.
82///
83/// Build this from the supplied V3 or V4 distribution, then validate the
84/// predicate's declared type. Semantic interpreters, including target-name
85/// language-ID policy, run separately after this shared shape check.
86///
87/// # Example
88///
89/// ```
90/// use indexmap::IndexMap;
91/// use morphir_core::data_value::DataValueValidator;
92/// use morphir_core::ir::v4;
93/// use morphir_core::naming::{FQName, PackageName, Path};
94///
95/// let distribution = v4::Distribution::Specs(v4::SpecsContent {
96///     package_name: PackageName::new(Path::new("example")),
97///     dependencies: IndexMap::new(),
98///     spec: v4::PackageSpecification { modules: IndexMap::new() },
99/// });
100/// let string = v4::Type::reference(
101///     v4::TypeAttributes::default(),
102///     FQName::from_canonical_string("morphir/SDK:string#string")?,
103///     vec![],
104/// );
105/// let validator = DataValueValidator::v4(&distribution)?;
106/// assert!(validator.validate_v4_data(&string, &serde_json::json!("order")).is_ok());
107/// # Ok::<(), Box<dyn std::error::Error>>(())
108/// ```
109pub struct DataValueValidator {
110    definitions: HashMap<String, Definition>,
111}
112
113impl DataValueValidator {
114    /// Collect all type definitions and specifications visible to a V4 distribution.
115    pub fn v4(distribution: &v4::Distribution) -> Result<Self, DataValueError> {
116        let mut definitions = HashMap::new();
117        match distribution {
118            v4::Distribution::Library(library) => {
119                collect_v4_definitions(
120                    &mut definitions,
121                    &library.package_name.to_canonical_string(),
122                    &library.def,
123                )?;
124                for (package, spec) in &library.dependencies {
125                    collect_v4_specifications(&mut definitions, package, spec)?;
126                }
127            }
128            v4::Distribution::Specs(specs) => {
129                collect_v4_specifications(
130                    &mut definitions,
131                    &specs.package_name.to_canonical_string(),
132                    &specs.spec,
133                )?;
134                for (package, spec) in &specs.dependencies {
135                    collect_v4_specifications(&mut definitions, package, spec)?;
136                }
137            }
138            v4::Distribution::Application(application) => {
139                collect_v4_definitions(
140                    &mut definitions,
141                    &application.package_name.to_canonical_string(),
142                    &application.def,
143                )?;
144                for (package, def) in &application.dependencies {
145                    collect_v4_definitions(&mut definitions, package, def)?;
146                }
147            }
148        }
149        Ok(Self { definitions })
150    }
151
152    /// Collect all type definitions and specifications visible to a V3 distribution.
153    pub fn v3(distribution: &classic::Distribution) -> Result<Self, DataValueError> {
154        if distribution.format_version != 3 {
155            return Err(unsupported("$", "data type IR must be V3"));
156        }
157        let mut definitions = HashMap::new();
158        let dependencies = match &distribution.distribution {
159            classic::DistributionBody::Library(package, dependencies, definition) => {
160                collect_v3_definitions(&mut definitions, &classic_path(package), definition)?;
161                dependencies
162            }
163            classic::DistributionBody::Specs(package, dependencies, specification) => {
164                collect_v3_specifications(&mut definitions, &classic_path(package), specification)?;
165                dependencies
166            }
167        };
168        for (package, specification) in dependencies {
169            collect_v3_specifications(&mut definitions, &classic_path(package), specification)?;
170        }
171        Ok(Self { definitions })
172    }
173
174    /// Validate one V4 data value against a declared Morphir type.
175    pub fn validate_v4_data(&self, ty: &v4::Type, value: &Value) -> Result<(), DataValueError> {
176        self.validate(&v4_shape(ty), value, &HashMap::new(), "$", 0)
177    }
178
179    /// Validate one V3 data value against a declared Morphir type.
180    pub fn validate_v3_data(
181        &self,
182        ty: &classic::Type<classic::Attrs>,
183        value: &Value,
184    ) -> Result<(), DataValueError> {
185        self.validate(&v3_shape(ty), value, &HashMap::new(), "$", 0)
186    }
187
188    /// Validate a V4 metadata object only when it is a data term.
189    pub fn validate_v4_object(
190        &self,
191        ty: &v4::Type,
192        object: &ObjectTerm,
193    ) -> Result<(), DataValueError> {
194        match object {
195            ObjectTerm::Value(value) => self.validate_v4_data(ty, value.value()),
196            ObjectTerm::NodeRef(_) => Err(mismatch("$", "expected data, found node reference")),
197        }
198    }
199
200    /// Validate a named type entry point, as used by decorator sidecars.
201    pub fn validate_reference(&self, name: &FQName, value: &Value) -> Result<(), DataValueError> {
202        self.validate(
203            &Shape::Reference(name.to_canonical_string(), vec![]),
204            value,
205            &HashMap::new(),
206            "$",
207            0,
208        )
209    }
210
211    fn validate(
212        &self,
213        shape: &Shape,
214        value: &Value,
215        vars: &HashMap<String, Shape>,
216        path: &str,
217        depth: usize,
218    ) -> Result<(), DataValueError> {
219        if depth > 128 {
220            return Err(unsupported(path, "data type/value nesting exceeds 128"));
221        }
222        let next = depth + 1;
223        match shape {
224            Shape::Unit if value.is_null() => Ok(()),
225            Shape::Unit => Err(mismatch(path, "expected unit (null)")),
226            Shape::Variable(name) => {
227                let bound = vars
228                    .get(name)
229                    .ok_or_else(|| unsupported(path, format!("unbound type variable {name}")))?;
230                self.validate(bound, value, vars, path, next)
231            }
232            Shape::Reference(name, args) => {
233                if let Some((module, local)) = sdk_type(name) {
234                    return self.validate_sdk((module, local), args, value, vars, path, next);
235                }
236                let definition = self.definitions.get(name).ok_or_else(|| {
237                    unsupported(
238                        path,
239                        format!("referenced Morphir type {name} is unavailable"),
240                    )
241                })?;
242                match definition {
243                    Definition::Alias { params, body } => {
244                        let bound = bind_type_args(params, args, vars, path)?;
245                        self.validate(body, value, &bound, path, next)
246                    }
247                    Definition::Custom {
248                        params,
249                        constructors,
250                    } => {
251                        let bound = bind_type_args(params, args, vars, path)?;
252                        let items = value
253                            .as_array()
254                            .ok_or_else(|| mismatch(path, "expected constructor array"))?;
255                        let tag = items.first().and_then(Value::as_str).ok_or_else(|| {
256                            mismatch(path, "constructor array needs a string tag")
257                        })?;
258                        let fields = constructors.get(tag).ok_or_else(|| {
259                            error(
260                                DataValueErrorKind::UnknownConstructor,
261                                path,
262                                format!("unknown constructor {tag} for {name}"),
263                            )
264                        })?;
265                        if fields.len() + 1 != items.len() {
266                            return Err(mismatch(
267                                path,
268                                format!("constructor {tag} expects {} arguments", fields.len()),
269                            ));
270                        }
271                        for (index, (field_type, field_value)) in
272                            fields.iter().zip(&items[1..]).enumerate()
273                        {
274                            self.validate(
275                                field_type,
276                                field_value,
277                                &bound,
278                                &format!("{path}[{}]", index + 1),
279                                next,
280                            )?;
281                        }
282                        Ok(())
283                    }
284                    Definition::Unsupported(kind) => Err(unsupported(
285                        path,
286                        format!("{kind} has no closed JSON data mapping"),
287                    )),
288                }
289            }
290            Shape::Record(fields) => {
291                let members = value
292                    .as_object()
293                    .ok_or_else(|| mismatch(path, "expected record object"))?;
294                for (name, field_type) in fields {
295                    let field = members
296                        .get(name)
297                        .ok_or_else(|| mismatch(path, format!("missing field {name}")))?;
298                    self.validate(field_type, field, vars, &member_path(path, name), next)?;
299                }
300                if members.len() != fields.len() {
301                    return Err(mismatch(path, "record has unknown fields"));
302                }
303                Ok(())
304            }
305            Shape::Tuple(elements) => {
306                let items = value
307                    .as_array()
308                    .ok_or_else(|| mismatch(path, "expected tuple array"))?;
309                if items.len() != elements.len() {
310                    return Err(mismatch(
311                        path,
312                        format!("tuple expects {} elements", elements.len()),
313                    ));
314                }
315                for (index, (item_type, item)) in elements.iter().zip(items).enumerate() {
316                    self.validate(item_type, item, vars, &format!("{path}[{index}]"), next)?;
317                }
318                Ok(())
319            }
320            Shape::Unsupported(kind) => Err(unsupported(
321                path,
322                format!("{kind} has no closed JSON data mapping"),
323            )),
324        }
325    }
326
327    fn validate_sdk(
328        &self,
329        sdk: (&str, &str),
330        args: &[Shape],
331        value: &Value,
332        vars: &HashMap<String, Shape>,
333        path: &str,
334        next: usize,
335    ) -> Result<(), DataValueError> {
336        match (sdk.0, sdk.1, args) {
337            ("basics", "bool", []) => expect_primitive(value.is_boolean(), path, "Bool"),
338            ("basics", "int", []) => expect_primitive(
339                v4::serde_tagged::integer_from_json(value).is_some(),
340                path,
341                "Int",
342            ),
343            ("basics", "float", []) => expect_primitive(value.is_number(), path, "Float"),
344            ("string", "string", []) => expect_primitive(value.is_string(), path, "String"),
345            ("char", "char", []) => expect_primitive(
346                value.as_str().is_some_and(|text| text.chars().count() == 1),
347                path,
348                "Char",
349            ),
350            ("decimal", "decimal", []) => expect_primitive(
351                value
352                    .as_str()
353                    .is_some_and(crate::ir::decimal::is_decimal_lexeme),
354                path,
355                "Decimal",
356            ),
357            ("list", "list", [item_type]) => {
358                let items = value
359                    .as_array()
360                    .ok_or_else(|| mismatch(path, "expected list"))?;
361                for (index, item) in items.iter().enumerate() {
362                    self.validate(item_type, item, vars, &format!("{path}[{index}]"), next)?;
363                }
364                Ok(())
365            }
366            ("maybe", "maybe", [_]) if value.is_null() => Ok(()),
367            ("maybe", "maybe", [item_type]) => self.validate(item_type, value, vars, path, next),
368            ("dict", "dict", [key_type, value_type]) => {
369                if !self.is_string_key_type(key_type, vars, path, &mut HashSet::new(), 0)? {
370                    return Err(unsupported(
371                        path,
372                        "Dict keys other than String have no JSON object mapping",
373                    ));
374                }
375                let entries = value
376                    .as_object()
377                    .ok_or_else(|| mismatch(path, "expected Dict String object"))?;
378                for (key, entry) in entries {
379                    self.validate(value_type, entry, vars, &member_path(path, key), next)?;
380                }
381                Ok(())
382            }
383            _ => Err(unsupported(
384                path,
385                format!(
386                    "SDK type {}#{} has no closed JSON data mapping with {} arguments",
387                    sdk.0,
388                    sdk.1,
389                    args.len()
390                ),
391            )),
392        }
393    }
394
395    fn is_string_key_type(
396        &self,
397        key_type: &Shape,
398        vars: &HashMap<String, Shape>,
399        path: &str,
400        visiting: &mut HashSet<(String, Vec<Shape>)>,
401        depth: usize,
402    ) -> Result<bool, DataValueError> {
403        if depth > 128 {
404            return Err(unsupported(path, "Dict key alias nesting exceeds 128"));
405        }
406        let resolved = substitute_type(key_type, vars, &mut HashSet::new(), path)?;
407        let Shape::Reference(name, args) = resolved else {
408            return Ok(false);
409        };
410        if let Some((module, local)) = sdk_type(&name) {
411            return Ok((module, local) == ("string", "string") && args.is_empty());
412        }
413        let Some(Definition::Alias { params, body }) = self.definitions.get(&name) else {
414            return Ok(false);
415        };
416        let instance = (name.clone(), args.clone());
417        if !visiting.insert(instance.clone()) {
418            return Err(unsupported(path, format!("cyclic Dict key alias {name}")));
419        }
420        let result = bind_type_args(params, &args, vars, path)
421            .and_then(|bound| self.is_string_key_type(body, &bound, path, visiting, depth + 1));
422        visiting.remove(&instance);
423        result
424    }
425}
426
427fn expect_primitive(valid: bool, path: &str, name: &str) -> Result<(), DataValueError> {
428    if valid {
429        Ok(())
430    } else {
431        Err(mismatch(path, format!("expected {name}")))
432    }
433}
434
435fn member_path(path: &str, name: &str) -> String {
436    let mut bytes = name.bytes();
437    let safe_start = bytes
438        .next()
439        .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_');
440    if safe_start && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
441        format!("{path}.{name}")
442    } else {
443        let quoted = serde_json::to_string(name).expect("JSON member names serialize");
444        format!("{path}[{quoted}]")
445    }
446}
447
448fn sdk_type(name: &str) -> Option<(&str, &str)> {
449    let (package, rest) = name.split_once(':')?;
450    if package != "morphir/SDK" && package != "morphir/s-d-k" {
451        return None;
452    }
453    rest.split_once('#')
454}
455
456fn bind_type_args(
457    params: &[String],
458    args: &[Shape],
459    outer: &HashMap<String, Shape>,
460    path: &str,
461) -> Result<HashMap<String, Shape>, DataValueError> {
462    if params.len() != args.len() {
463        return Err(unsupported(
464            path,
465            format!("type expects {} arguments", params.len()),
466        ));
467    }
468    let arguments = args
469        .iter()
470        .map(|arg| substitute_type(arg, outer, &mut HashSet::new(), path))
471        .collect::<Result<Vec<_>, _>>()?;
472    let mut bound = outer.clone();
473    bound.extend(params.iter().cloned().zip(arguments));
474    Ok(bound)
475}
476
477fn substitute_type(
478    shape: &Shape,
479    vars: &HashMap<String, Shape>,
480    visiting: &mut HashSet<String>,
481    path: &str,
482) -> Result<Shape, DataValueError> {
483    match shape {
484        Shape::Variable(name) => {
485            let Some(bound) = vars.get(name) else {
486                return Ok(shape.clone());
487            };
488            if !visiting.insert(name.clone()) {
489                return Err(unsupported(path, format!("cyclic type variable {name}")));
490            }
491            let resolved = substitute_type(bound, vars, visiting, path);
492            visiting.remove(name);
493            resolved
494        }
495        Shape::Reference(name, args) => Ok(Shape::Reference(
496            name.clone(),
497            args.iter()
498                .map(|arg| substitute_type(arg, vars, visiting, path))
499                .collect::<Result<_, _>>()?,
500        )),
501        Shape::Record(fields) => Ok(Shape::Record(
502            fields
503                .iter()
504                .map(|(name, shape)| {
505                    substitute_type(shape, vars, visiting, path).map(|shape| (name.clone(), shape))
506                })
507                .collect::<Result<_, _>>()?,
508        )),
509        Shape::Tuple(elements) => Ok(Shape::Tuple(
510            elements
511                .iter()
512                .map(|shape| substitute_type(shape, vars, visiting, path))
513                .collect::<Result<_, _>>()?,
514        )),
515        Shape::Unit | Shape::Unsupported(_) => Ok(shape.clone()),
516    }
517}