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;