Keep the cast when folding json_get, plus property tests for the rewriter - #129
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## fix-try-cast-json-get #129 +/- ##
=========================================================
+ Coverage 84.70% 91.17% +6.47%
=========================================================
Files 18 18
Lines 1608 1620 +12
Branches 1608 1620 +12
=========================================================
+ Hits 1362 1477 +115
+ Misses 173 97 -76
+ Partials 73 46 -27 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
adriangb
force-pushed
the
keep-cast-when-folding-json-get
branch
from
September 10, 2026 19:07
e9b5823 to
2ecfd7a
Compare
adriangb
force-pushed
the
fix-try-cast-json-get
branch
from
September 10, 2026 19:07
5ff1fd6 to
86ee661
Compare
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.
The bug
JsonFunctionRewriterreplacedCAST(json_get(x,'k') AS T)with the bare accessor forT. The accessors return one Arrow type per JSON type —json_get_intis alwaysInt64,json_get_floatalwaysFloat64,json_get_stralwaysUtf8— so wheneverTwas not that type, the expression silently came out as the accessor's type and the narrowing the cast promised never happened:Values escaped the declared type too. On
{"a": 3000000000}:CAST(json_get(x,'a') AS INT)3000000000(Int64)TRY_CAST(json_get(x,'a') AS INT)3000000000(Int64)NULL(Int32), asTRY_CASTpromisesCAST(json_get(x,'a') AS DECIMAL(10,2))3000000000.0(Float64)The
CASThalf predates #127; #127 routesTRY_CASTthrough the same type table, which is how theTRY_CASTrow above arises.The fix
Replace only the cast's input, and drop the cast only where the accessor already returns the type that was asked for:
::bigint,::doubleand::booleanplan exactly as before. Where the cast stays it costs one primitive-to-primitive cast, and the JSON union is still never materialized — which was the whole point of the rewrite.That removes the reason
arrow_castfolded only the four types an accessor returns exactly:arrow_cast(x,'Int32')now folds toCAST(json_get_int(x) AS Int32)and keeps itsInt32, so it stops materializing the union for no reason. Both paths share one type table now, with thearrow_*type argument parsed the way DataFusion parses it itself inArrowCastFunc::return_field_from_args.ArrowCastFunc::simplifyalready drops the cast when the accessor's type matches, so the exactarrow_castcases keep their old plans as well.The set of Arrow types that fold is unchanged —
smallintand friends are still left alone.Pushdown is unaffected
JsonGet::placementmoves ajson_getover a column with literal path arguments towards the leaf nodes, and a cast sitting on top of the accessor is exactly the shape that could have stranded it higher up the plan. It does not: the accessor still lands in the leaf projection for every folding target, andunwrap_cast_in_comparisonremoves the added cast again for an integer comparison, sowhere (json_data->'foo')::int = 5plans identically to before.narrowing_cast_still_reaches_the_leaf_nodespins this.Tests
tests/main.rsexpectations move from the accessor's type to the type the query asked for (::int→Int32,::float→Float32,::numeric→Decimal128(38,10),::string→Utf8View), and the plan tests show the retained cast.test_plan_arrow_cast_fn_inexact_type_not_foldedis nowtest_plan_arrow_cast_fn_narrowing_type_keeps_cast— its premise is inverted by this change.tests/rewrite_differential.rs(new)Property tests over generated JSON documents × 5 cast spellings (
CAST,::,TRY_CAST,arrow_cast,arrow_try_cast) × 8 target types.prop_fold_is_invisible— folding must equal applying the same cast to the typed accessor:CAST(json_get(x,'k') AS T) == CAST(json_get_<T>(x,'k') AS T). The right-hand side is not itself folded (the rewriter only folds casts overjson_get), so it works as a reference implementation. This is the property that found the bug above; it now covers every folding pair rather than only the type-exact ones.prop_unnest_is_invisible— flatteningjson_get(json_get(j,'a'),'b')must not change the answer, checked against the genuinely un-rewritten plan.Getting that un-rewritten plan needs no second
SessionContext. Function rewrites run in the analyzer, before the optimizer merges projections, so a cast over a subquery column is never folded — andmerge_projectionthen inlines thejson_getback underneath.barrier_really_blocks_foldingasserts the trick still works, so the differential cannot quietly become a tautology.For cast folding that plain "same with and without the rewrite" equality is false by design, and
unfolded_union_cast_is_weakerrecords why: casting the JSON union is far weaker than the accessors (CAST(union AS Float64)is NULL even for42), so the rewrite is deliberately better than what it replaces. Hence the typed-accessor reference instead.Also
every_cast_spelling_folds_as_documented(plan-shape table over the whole matrix),narrowing_cast_preserves_type_and_narrows,narrowing_cast_still_avoids_the_unionandnarrowing_cast_still_reaches_the_leaf_nodes.tests/json_robustness.rs(new)Three properties across all 11 path-taking UDFs, so a change to any one of them or to the shared scanning code in
common.rshas to keep them true:prop_no_panic_on_arbitrary_json— valid JSON, truncated JSON, JSON with a byte removed, and arbitrary text must come back as NULL, never a panic or an error. This is the shape of the bug fixed in Fix panic on JSON integers outside the i64 fast path #124 (a JSON integer too wide for jiter's fast path reached atodo!()); the generator emits those integers, so it would have caught it.prop_scalar_and_array_paths_agree— a literal document is const-evaluated through each UDF'sScalarValuepath, a column goes through the array path.prop_input_encoding_does_not_change_result—Utf8,LargeUtf8,Utf8Viewand dictionary-encoded input must agree.That last one found a separate, unrelated bug —
json_get_arrayfails on every dictionary-encoded column — which is pinned here byjson_get_array_errors_on_dictionary_inputand fixed in its own PR. Delete that test and thejson_get_arrayexclusion when the fix lands.Case counts default low so the suite stays fast (~14s);
PROPTEST_CASESoverrides, which is how these get run as a fuzzer:Verification
cargo testandcargo test --releasegreen (189 tests). The release run is not optional here — it is how the profile-dependent error path in thejson_get_arraycharacterization test was caught.cargo clippy --all-targets --all-featuresclean (the crate deniesclippy::pedantic).cargo-mutants,src/rewrite.rs): 14 missed / 45 viable on Fold json_get casts written as TRY_CAST, arrow_cast or arrow_try_cast #127's head, 10 after adding these tests. The four newly killed are exactly the fold's type arms — three of the four type arms Fold json_get casts written as TRY_CAST, arrow_cast or arrow_try_cast #127 adds tooptimise_json_get_arrow_castcould be deleted with Fold json_get casts written as TRY_CAST, arrow_cast or arrow_try_cast #127's own tests still green. The 10 that remain are equivalent mutants:expr_to_sql_repr's eight integer arms are byte-identical to its_ => scalar.to_string()fallback, andname()only feeds diagnostics.cargo-llvm-cov): 87.9% → 93.5% lines overall, functions-missed 23 → 8.🤖 Generated with Claude Code