fix(rust)!: stop forcing serde_json preserve_order on downstream crates - #55
Open
nielspardon wants to merge 1 commit into
Open
fix(rust)!: stop forcing serde_json preserve_order on downstream crates#55nielspardon wants to merge 1 commit into
preserve_order on downstream crates#55nielspardon wants to merge 1 commit into
Conversation
…ates `substrait-extensions` and `substrait-prost` both declared their `serde_json` dependency with the `preserve_order` feature. That feature is *global*: due to Cargo feature unification, enabling it anywhere in a dependency graph switches `serde_json::Map` from `BTreeMap` to `IndexMap` for every crate in the build. Downstream users had no way to opt out short of patching these crates. Only one thing actually needed order preservation: the free-form-object arm of the `Type` schema, which carries a named struct's fields, whose order is semantically significant (substrait-io/substrait#915). `build.rs` already asked typify to preserve order via `with_map_type("::indexmap::IndexMap")`, but typify ignores that setting for free-form JSON objects and hardcodes `::serde_json::Map<String, Value>`, so the guarantee silently rested on `preserve_order` instead. Rewrite just that one type in the generated output to `IndexMap`, which preserves insertion order unconditionally, and drop `preserve_order` from both crates. The rewrite asserts it matched exactly once, so a typify or prettyplease upgrade that reflows the output fails the build loudly rather than silently reintroducing the dependency on `preserve_order`. `substrait-prost` never used `serde_json` at all, so dropping the feature there is a no-op beyond removing the contagion. BREAKING CHANGE: `text::simple_extensions::Type::Object` now holds an `indexmap::IndexMap<String, serde_json::Value>` instead of a `serde_json::Map<String, serde_json::Value>`. Every other generated field (`metadata`, `required_options`) is unchanged. Refs: substrait-io/substrait-rs#502
nielspardon
marked this pull request as ready for review
July 31, 2026 11:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the Rust half of substrait-io/substrait-rs#502.
Problem
substrait-extensionsandsubstrait-prostboth declaredserde_jsonwith thepreserve_orderfeature:preserve_orderis global. Because of Cargo feature unification, enabling it anywhere in a dependency graph switchesserde_json::MapfromBTreeMaptoIndexMapfor every crate in the build, not just ours. Downstream users had no way to opt out short of patching these crates.The issue names only
substrait, which was exactly right when it was filed on 2026-06-15: at that pointsubstrait-rswas a single crate with a singleserde_jsondeclaration and its ownbuild.rs.substrait-extensionsandsubstrait-prostdid not exist yet — they were created three days later in #20, which copied thepreserve_orderdeclaration into both.So the fix surface grew from one crate to three after the report, and
substrait-rsonly began depending on the packaged crates in substrait-io/substrait-rs#507. Today all three carry it, and the two here are non-optional dependencies ofsubstrait— so fixingsubstrait-rsalone would change nothing:substraitsubstrait-extensionssubstrait-prostserdefeatureRoot cause
Exactly one thing needed order preservation: the free-form-object arm of the
Typeschema (oneOf: [string, object]), which carries a named struct's fields. Their order is semantically significant — see substrait-io/substrait#915.build.rsalready asked typify to preserve order:But
with_map_typedoes not apply to this type, and that is documented, intended typify behaviour — not an oversight on typify's part. typify hardcodes::serde_json::Map<String, Value>whenever a map's key isStringand its value is arbitrary JSON (typify-impl-0.7.0/src/type_entry.rs:1821), which is exactly the shape of$defs/type's object arm. From typify's README:The typify maintainer raised this precise line while reviewing the PR that added
with_map_type(oxidecomputer/typify#708) — "I think this is the right thing to do in this case, but I do want to point out that this is an exceptional case ... document that the override doesn't apply to this" — so it is a considered design decision.The practical consequence for us is that
with_map_typenever covered the one type that mattered, and the ordering guarantee rested onpreserve_orderinstead.Fix
Rewrite just that one type in the generated output to
IndexMap, which preserves insertion order unconditionally, then droppreserve_orderfrom both crates.The rewrite is anchored on the whole
Typeblock rather than applied globally, so the diff against the previously generated code is exactly 3 lines:metadataandrequired_optionshold order-insensitive data ("arbitrary data created by the dialect author") and keep their current public types.Option<...>count in the generated output is unchanged at 366, identical to before.substrait-prostnever referencedserde_jsonat all — the pbjson-generated code does not use it — so dropping the feature there is a no-op beyond removing the contagion. (The dependency itself now looks unused; I left it in place to keep this PR focused, but it may be worth removing separately.)Why post-process codegen, and why that is safe here
The obvious objection to rewriting generated source in a build script is silent failure: a typify or prettyplease upgrade reflows the output, the pattern stops matching,
serde_json::Mapquietly returns, and nothing fails — leaving the ordering guarantee broken again with no signal.So the rewrite asserts it matched exactly once. Verified it fires by perturbing the anchor:
That turns the failure mode from a silent regression into a build break naming the file and the function to fix.
Alternatives considered
Two supported typify APIs also work, and I prototyped both:
with_conversionon bare{"type": "object"}— works, but also wraps 70 unrelated fields (metadata,required_options) in an extraOptionlayer, pushing theOptioncount from 366 to 576. Cause: typify decides optionality fromTypeEntryDetails(structs.rs:440-444); a converted type isNative, which falls through toRequiredand gets wrapped. PassingTypeSpaceImpl::Defaultdoes not suppress it —has_defaultnever consults the impls list. Strictly larger breaking change for no benefit.with_replacement("Type", ...)— equally surgical, but movesTypeout of the generated module, so it needs a hand-written ~20-line enum mirroring the schema plus a re-export to keeptext::simple_extensions::Typeresolving. Viable fallback if the team's position is that build scripts must not post-process generated source; the cost is a hand-maintained schema mirror.Since the typify behaviour is intended and documented, there is no upstream bug to wait on — solving this on our side is the correct level. (A feature request to make the fallback configurable would have to argue against an explicit prior decision. There is a reasonable argument available —
serde_json::Map's ordering is only controllable through a global feature, which is the whole reason this leaked downstream — but that is a separate conversation and not a blocker here.)The better thing to wait on is substrait-io/substrait#915, which may remove the need for this entirely; see below.
Verification
Prototyped against the published 0.87.0 crates (this repo's
build.rsis byte-identical to the published one) withsubstrait-rspointed at both patched crates via[patch.crates-io]:preserve_orderabsent from the whole graph, confirmed withcargo tree -e features --all-features -i serde_json.--all-features(63),--no-default-features --features parse,extensions(60), and--no-default-features.serde_yamlinto the generated types and asserting field order survives — passes withoutpreserve_order.cargo fmt --checkandcargo clippy --all-targetsclean.Note for reviewers: the existing order test is currently masked
substrait-rs'stest_named_struct_field_order_stabilitypasses under--all-featureseven with the ordering guarantee fully broken, becausesubstrait-prost'sserdefeature dragspreserve_orderback in. I confirmed this by removingpreserve_orderwithout the fix — the suite went green anyway. Only on a feature set excludingserdedoes it correctly fail:CI needs a job on a feature set that excludes
serde, or this guarantee stays untested. That belongs in the companion substrait-rs PR.Breaking change
text::simple_extensions::Type::Objectnow holdsindexmap::IndexMap<String, serde_json::Value>instead ofserde_json::Map<String, serde_json::Value>. Mechanical for callers — insubstrait-rsit was a 4-line change, and its parse path already collected into anIndexMap.Worth flagging: this crate's version tracks Substrait spec releases, so a breaking Rust API change cannot be expressed as a major bump. Please sanity-check the release timing.
This is an interim fix by design
Worth recording for whoever reads this later: preserving YAML key order here is a workaround for an open spec question, not settled behaviour.
Struct fields in Substrait are referenced by zero-indexed ordinal position, not by name (
fields are unnamed and references are always based on zero-index ordinal positions), and a YAML named struct is sugar whose key order defines those ordinals. But JSON objects are formally unordered (RFC 8259), so the schema currently relies on something JSON does not guarantee. That is substrait-io/substrait#915, still open, which proposes four resolutions — key/value pair lists, parallelnames/typesarrays, lexicographic ordering, or dropping object notation. Under the last two, order preservation becomes unnecessary or the object arm disappears.#915 was filed while reviewing substrait-io/substrait-rs#395 — the PR that introduced
preserve_order— and that review thread is explicit that the map-type approach was meant to be temporary: "it sounds like the right decision will be to leverage an alternative map type using something like anIndexMap. This would just be until the upstream is solved."So this PR keeps that interim contract intact while removing the global feature that leaked out of it. It is deliberately small and localised so it can be deleted cleanly when #915 lands.
Follow-ups
substrait-rsPR dropping its ownpreserve_orderand adapting the 4 lines (not yet opened — this PR should land first).🤖 Generated with AI