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, _) in edges {
124 *counts.entry(label.as_str()).or_insert(0) += 1;
125 }
126 let mut seen: indexmap::IndexMap<&str, usize> = indexmap::IndexMap::new();
127 for (label, child) in edges {
128 let i = *seen
129 .entry(label.as_str())
130 .and_modify(|n| *n += 1)
131 .or_insert(0);
132 let base = path.len();
133 crate::report::push_child_path(
134 path,
135 label,
136 crate::report::occurrence_index(i, counts[label.as_str()]),
137 );
138 match rec.field(label) {
139 None => {
140 res.add(
141 path.as_str(),
142 "unexpected field",
143 ErrorCode::UnexpectedField,
144 );
145 out.push((label.clone(), child.clone()));
146 }
147 Some(f) => {
148 let m = materialize_type(child, schema, &f.ty, path, res);
149 out.push((label.clone(), m));
150 }
151 }
152 path.truncate(base);
153 }
154 for f in rec.fields() {
155 let c = counts.get(f.label.as_str()).copied().unwrap_or(0);
156 if c < f.min || f.max.is_some_and(|max| c > max) {
157 res.add(
158 path.as_str(),
159 format!(
160 "field {:?} occurs {} time(s), expected {}",
161 f.label,
162 c,
163 f.cardinality_str()
164 ),
165 ErrorCode::Cardinality,
166 );
167 }
168 }
169 RawNode::Edges(out)
170}
171
172fn materialize_scalar(
173 node: &RawNode,
174 kind: ScalarKind,
175 nullable: bool,
176 path: &str,
177 res: &mut ValidationResult,
178) -> RawNode {
179 let value = match node {
180 RawNode::Leaf(v) => v,
181 RawNode::Edges(_) => {
182 res.add(
183 path,
184 format!("expected a {} value, got an object", kind.as_str()),
185 ErrorCode::ShapeMismatch,
186 );
187 return node.clone();
188 }
189 };
190 if matches!(value, DocScalar::Null) {
191 if !nullable {
192 res.add(path, "null not allowed here", ErrorCode::NullNotAllowed);
193 }
194 return RawNode::Leaf(DocScalar::Null);
195 }
196 if let Some(upgraded) = try_upgrade(value, kind) {
197 return RawNode::Leaf(upgraded);
198 }
199 res.add(
200 path,
201 format!(
202 "{value} cannot be read as {} (not a value-exact conversion)",
203 kind.as_str()
204 ),
205 ErrorCode::TypeMismatch,
206 );
207 node.clone()
208}
209
210/// Value-exact upgrade table -- `None` means the value cannot become
211/// `kind` without loss or ambiguity (a `type-mismatch` at the call site).
212fn try_upgrade(value: &DocScalar, kind: ScalarKind) -> Option<DocScalar> {
213 match (kind, value) {
214 (ScalarKind::String, DocScalar::Str(_)) => Some(value.clone()),
215 (ScalarKind::Boolean, DocScalar::Bool(_)) => Some(value.clone()),
216 (ScalarKind::Integer, DocScalar::Int(_)) => Some(value.clone()),
217 (ScalarKind::Integer, DocScalar::Float(f)) => {
218 // Arbitrary-precision (issue #104): no upper/lower bound to
219 // check anymore -- `BigInt` has no range limit, so any finite
220 // whole-number float upgrades. `BigInt::from_f64` decomposes
221 // the float's exact mantissa*2^exponent value (well-defined
222 // for any finite f64, not an approximation), consistent with
223 // `f.fract() == 0.0` already having confirmed there's no
224 // fractional part to lose.
225 if f.is_finite() && f.fract() == 0.0 {
226 num_bigint::BigInt::from_f64(*f).map(DocScalar::Int)
227 } else {
228 None
229 }
230 }
231 // Issue #115: upgrading BigInt to number (f64) requires value-exact
232 // conversion -- reject if the value is non-finite or loses precision
233 // when round-tripped back to BigInt.
234 (ScalarKind::Number, DocScalar::Int(i)) => {
235 if let Some(f) = i.to_f64()
236 && f.is_finite()
237 && let Some(round_tripped) = num_bigint::BigInt::from_f64(f)
238 && &round_tripped == i
239 {
240 return Some(DocScalar::Float(f));
241 }
242 None
243 }
244 (ScalarKind::Number, DocScalar::Float(_)) => Some(value.clone()),
245 // Issue #105: upgrading a plain string to Date/Time/Datetime now
246 // constructs the real `Scalar` variant (previously stayed `Str`,
247 // tagged only via the now-removed `RawNode::TemporalLeaf`
248 // write-hint). `Time`/`Datetime` canonicalize (fills a missing
249 // `:SS`, zero-pads a short fraction) the same way OML's own
250 // bare-literal grammar already does -- `is_iso_time`/
251 // `is_iso_datetime` accept non-canonical spellings that this
252 // variant's own invariant (always canonical) requires
253 // normalizing first. `Date` has no optional grammar components,
254 // so its source spelling is already canonical.
255 (ScalarKind::Date, DocScalar::Str(s)) if crate::schema::is_iso_date(s) => {
256 Some(DocScalar::Date(s.clone()))
257 }
258 (ScalarKind::Time, DocScalar::Str(s)) if crate::schema::is_iso_time(s) => {
259 Some(DocScalar::Time(crate::schema::canonicalize_iso_time(s)))
260 }
261 (ScalarKind::Datetime, DocScalar::Str(s))
262 if crate::schema::is_iso_datetime(s) && !crate::schema::is_iso_date(s) =>
263 {
264 Some(DocScalar::Datetime(
265 crate::schema::canonicalize_iso_datetime(s),
266 ))
267 }
268 // Identity: a value already correctly typed (e.g. re-materializing
269 // an already-materialized document, or one read directly from
270 // OML's/TOML's own native temporal grammar) stays as-is -- mirrors
271 // the `Integer`/`Int` and `Number`/`Float` identity arms above.
272 (ScalarKind::Date, DocScalar::Date(_))
273 | (ScalarKind::Time, DocScalar::Time(_))
274 | (ScalarKind::Datetime, DocScalar::Datetime(_)) => Some(value.clone()),
275 _ => None,
276 }
277}
278
279#[cfg(test)]
280mod tests;