Skip to main content

omnist/
materialize.rs

1//! Schema-directed deserialization: make a freshly-read [`RawNode`] conform
2//! to a [`Schema`], or report every reason it doesn't.
3//!
4//! Ported from `~/dev/omnist/omnist/deserialize.py` (issue #14). Readers
5//! hand back text-shaped values: JSON/YAML/TOML have no `date`/`time`
6//! type, so a temporal field arrives as an ISO-8601 string; a whole-number
7//! float may need to become an int (or vice versa) to match what the
8//! schema declares. [`materialize`] walks the node together with the
9//! schema, upgrading each leaf **only when the conversion is value-exact**
10//! (`1.0 -> 1` for an `integer` field, `1 -> 1.0` for a `number` field --
11//! see the module's scalar-upgrade table below) and checking every
12//! record's shape (closed fields, cardinality) in the same pass -- not a
13//! second top-down walk delegating to [`crate::schema::Schema::validate`]
14//! afterward. That would mean re-walking the same tree twice with
15//! different traversal code for no reason: `materialize` already knows, at
16//! every node, exactly which field/type the schema expects there, so
17//! upgrading and shape-checking happen together in one pass, matching the
18//! Python reference's stated rationale.
19//!
20//! ## No native temporal `Scalar` variant
21//!
22//! [`crate::document::Scalar`] has no `date`/`time`/`datetime` variant (see
23//! `document.rs`'s module doc) -- every value this port ever materializes
24//! is `Null`/`Bool`/`Int`/`Float`/`Str`. So unlike the Python reference
25//! (which actually constructs a `datetime.date`/`time`/`datetime` object),
26//! "upgrading" a temporal field here can only ever mean "is this string
27//! shaped like, and a semantically valid, ISO date/time/datetime" -- it
28//! stays a `Str` either way. That check is exactly
29//! `crate::schema::is_iso_date`/`is_iso_time`/`is_iso_datetime`, reused here rather
30//! than reimplemented -- the exact "validate and materialize must share
31//! one strict parser" pitfall the porting playbook calls out, and the same
32//! functions [`crate::schema::matches_kind`] already uses.
33//!
34//! ## Scalar upgrade table (value-exact only)
35//!
36//! | field kind | accepts as-is           | upgrades                         |
37//! |---|---|---|
38//! | `string`   | `Str`                   | (none)                           |
39//! | `boolean`  | `Bool`                  | (none)                           |
40//! | `integer`  | `Int`                   | `Float` with a zero fractional part |
41//! | `number`   | `Float`                 | `Int` (always promoted to `Float`, matching the Python reference's `float(value)`) |
42//! | `date`/`time`/`datetime` | `Str` shaped per the shared temporal check | (none -- see above) |
43//!
44//! `null` is accepted only when the field's `Scalar` is nullable,
45//! regardless of kind.
46//!
47//! ## No `strict=` switch
48//!
49//! There's no separate strict/non-strict mode: [`materialize`] takes
50//! `schema: Option<&Schema>` -- `None` is the well-defined "opt out of
51//! validation entirely" case (the node is returned exactly as read,
52//! untouched), matching the Python reference's `schema=None` convention at
53//! the reader call sites.
54//!
55//! ## The `any` type
56//!
57//! An `Any`-typed field passes its node through completely untouched --
58//! no shape check, no scalar upgrade -- mirroring Python's
59//! `_materialize_type`: `if isinstance(d, AnyType): return node`.
60
61use crate::document::{RawNode, Scalar as DocScalar};
62use crate::error::MaterializeError;
63use crate::schema::{ErrorCode, FieldType, Resolved, ScalarKind, Schema, ValidationResult};
64use num_traits::{FromPrimitive, ToPrimitive};
65
66/// A copy of `node` with leaf values upgraded to match `schema`, guaranteed
67/// to conform to it -- or every reason it can't, collected into one
68/// [`MaterializeError`] (never just the first problem found).
69///
70/// `schema = None` is a no-op passthrough: `node` is cloned back unchanged,
71/// with no validation performed at all.
72pub fn materialize(node: &RawNode, schema: Option<&Schema>) -> Result<RawNode, MaterializeError> {
73    let Some(schema) = schema else {
74        return Ok(node.clone());
75    };
76    let mut res = ValidationResult::new();
77    let root_ty = FieldType::Ref(schema.root().clone());
78    // One path `String`, allocated once and reused for the whole walk (push
79    // a segment per edge, recurse, truncate back) rather than one `format!`
80    // per edge regardless of whether that edge ever reports anything --
81    // see issue #44.
82    let mut path = String::from("$");
83    let out = materialize_type(node, schema, &root_ty, &mut path, &mut res);
84    if !res.ok() {
85        return Err(MaterializeError(res));
86    }
87    Ok(out)
88}
89
90fn materialize_type(
91    node: &RawNode,
92    schema: &Schema,
93    ty: &FieldType,
94    path: &mut String,
95    res: &mut ValidationResult,
96) -> RawNode {
97    match schema.resolve(ty) {
98        // `any` accepts every legal value unchecked -- pass the node through
99        // exactly as read, no shape check or scalar upgrade.
100        Resolved::Any => node.clone(),
101        Resolved::Scalar(s) => materialize_scalar(node, s.kind(), s.is_nullable(), path, res),
102        Resolved::Record(rec) => materialize_record(node, schema, rec, path, res),
103    }
104}
105
106fn materialize_record(
107    node: &RawNode,
108    schema: &Schema,
109    rec: &crate::schema::Record,
110    path: &mut String,
111    res: &mut ValidationResult,
112) -> RawNode {
113    let RawNode::Edges(edges) = node else {
114        res.add(
115            path.as_str(),
116            "expected an object, got a value",
117            ErrorCode::ShapeMismatch,
118        );
119        return node.clone();
120    };
121    let mut out: Vec<(String, RawNode)> = Vec::with_capacity(edges.len());
122    let mut counts: indexmap::IndexMap<&str, usize> = indexmap::IndexMap::new();
123    for (label, child) in edges {
124        let i = *counts.entry(label.as_str()).or_insert(0);
125        counts.insert(label.as_str(), i + 1);
126        let base = path.len();
127        crate::report::push_child_path(path, label, i);
128        match rec.field(label) {
129            None => {
130                res.add(
131                    path.as_str(),
132                    "unexpected field",
133                    ErrorCode::UnexpectedField,
134                );
135                out.push((label.clone(), child.clone()));
136            }
137            Some(f) => {
138                let m = materialize_type(child, schema, &f.ty, path, res);
139                out.push((label.clone(), m));
140            }
141        }
142        path.truncate(base);
143    }
144    for f in rec.fields() {
145        let c = counts.get(f.label.as_str()).copied().unwrap_or(0);
146        if c < f.min || f.max.is_some_and(|max| c > max) {
147            res.add(
148                path.as_str(),
149                format!(
150                    "field {:?} occurs {} time(s), expected {}",
151                    f.label,
152                    c,
153                    f.cardinality_str()
154                ),
155                ErrorCode::Cardinality,
156            );
157        }
158    }
159    RawNode::Edges(out)
160}
161
162fn materialize_scalar(
163    node: &RawNode,
164    kind: ScalarKind,
165    nullable: bool,
166    path: &str,
167    res: &mut ValidationResult,
168) -> RawNode {
169    let value = match node {
170        RawNode::Leaf(v) => v,
171        RawNode::Edges(_) => {
172            res.add(
173                path,
174                format!("expected a {} value, got an object", kind.as_str()),
175                ErrorCode::ShapeMismatch,
176            );
177            return node.clone();
178        }
179    };
180    if matches!(value, DocScalar::Null) {
181        if !nullable {
182            res.add(path, "null not allowed here", ErrorCode::NullNotAllowed);
183        }
184        return RawNode::Leaf(DocScalar::Null);
185    }
186    if let Some(upgraded) = try_upgrade(value, kind) {
187        return RawNode::Leaf(upgraded);
188    }
189    res.add(
190        path,
191        format!(
192            "{value} cannot be read as {} (not a value-exact conversion)",
193            kind.as_str()
194        ),
195        ErrorCode::TypeMismatch,
196    );
197    node.clone()
198}
199
200/// Value-exact upgrade table -- `None` means the value cannot become
201/// `kind` without loss or ambiguity (a `type-mismatch` at the call site).
202fn try_upgrade(value: &DocScalar, kind: ScalarKind) -> Option<DocScalar> {
203    match (kind, value) {
204        (ScalarKind::String, DocScalar::Str(_)) => Some(value.clone()),
205        (ScalarKind::Boolean, DocScalar::Bool(_)) => Some(value.clone()),
206        (ScalarKind::Integer, DocScalar::Int(_)) => Some(value.clone()),
207        (ScalarKind::Integer, DocScalar::Float(f)) => {
208            // Arbitrary-precision (issue #104): no upper/lower bound to
209            // check anymore -- `BigInt` has no range limit, so any finite
210            // whole-number float upgrades. `BigInt::from_f64` decomposes
211            // the float's exact mantissa*2^exponent value (well-defined
212            // for any finite f64, not an approximation), consistent with
213            // `f.fract() == 0.0` already having confirmed there's no
214            // fractional part to lose.
215            if f.is_finite() && f.fract() == 0.0 {
216                num_bigint::BigInt::from_f64(*f).map(DocScalar::Int)
217            } else {
218                None
219            }
220        }
221        // Issue #115: upgrading BigInt to number (f64) requires value-exact
222        // conversion -- reject if the value is non-finite or loses precision
223        // when round-tripped back to BigInt.
224        (ScalarKind::Number, DocScalar::Int(i)) => {
225            if let Some(f) = i.to_f64()
226                && f.is_finite()
227                && let Some(round_tripped) = num_bigint::BigInt::from_f64(f)
228                && &round_tripped == i
229            {
230                return Some(DocScalar::Float(f));
231            }
232            None
233        }
234        (ScalarKind::Number, DocScalar::Float(_)) => Some(value.clone()),
235        // Issue #105: upgrading a plain string to Date/Time/Datetime now
236        // constructs the real `Scalar` variant (previously stayed `Str`,
237        // tagged only via the now-removed `RawNode::TemporalLeaf`
238        // write-hint). `Time`/`Datetime` canonicalize (fills a missing
239        // `:SS`, zero-pads a short fraction) the same way OML's own
240        // bare-literal grammar already does -- `is_iso_time`/
241        // `is_iso_datetime` accept non-canonical spellings that this
242        // variant's own invariant (always canonical) requires
243        // normalizing first. `Date` has no optional grammar components,
244        // so its source spelling is already canonical.
245        (ScalarKind::Date, DocScalar::Str(s)) if crate::schema::is_iso_date(s) => {
246            Some(DocScalar::Date(s.clone()))
247        }
248        (ScalarKind::Time, DocScalar::Str(s)) if crate::schema::is_iso_time(s) => {
249            Some(DocScalar::Time(crate::schema::canonicalize_iso_time(s)))
250        }
251        (ScalarKind::Datetime, DocScalar::Str(s))
252            if crate::schema::is_iso_datetime(s) && !crate::schema::is_iso_date(s) =>
253        {
254            Some(DocScalar::Datetime(
255                crate::schema::canonicalize_iso_datetime(s),
256            ))
257        }
258        // Identity: a value already correctly typed (e.g. re-materializing
259        // an already-materialized document, or one read directly from
260        // OML's/TOML's own native temporal grammar) stays as-is -- mirrors
261        // the `Integer`/`Int` and `Number`/`Float` identity arms above.
262        (ScalarKind::Date, DocScalar::Date(_))
263        | (ScalarKind::Time, DocScalar::Time(_))
264        | (ScalarKind::Datetime, DocScalar::Datetime(_)) => Some(value.clone()),
265        _ => None,
266    }
267}
268
269#[cfg(test)]
270mod tests;