Repository navigation
fix: preserve schema consistency across union and cast - #26201
Amogh-2404 wants to merge 3 commits into
Conversation
kosiew
left a comment
There was a problem hiding this comment.
Thanks for the fix. Recomputing the logical UNION schema from rewritten children addresses the reported metadata mismatch. I independently verified that the b * 1 and CASE WHEN true THEN b ELSE 7 END reproducers fail before the fix and pass afterward, supporting the normal-path correction.
The logical UNION is the right boundary for maintaining this contract. Its constructor owns field naming, type validation, metadata intersection, and nullability, so reusing that policy is preferable to compensating during physical planning or changing CAST metadata behavior.
I'm requesting changes because the compatibility fallback can suppress structural errors unrelated to the intended pre-coercion type mismatch. Please constrain that exception and verify its schema-consistency behavior. This concern is about the recovery policy, not a demonstrated regression in the normal execution path. The additional tests are non-blocking suggestions.
| // positional `try_new` is sufficient. | ||
| match Union::try_new(inputs.clone()) { | ||
| Ok(union) => Ok(LogicalPlan::Union(union)), | ||
| Err(e) => { |
There was a problem hiding this comment.
Could we restrict this fallback to expected pre-coercion type mismatches? Checking only the first child's width can suppress a later child's width mismatch or other reconstruction errors. Please validate all child widths and propagate unrelated errors, preferably using an explicit compatibility predicate rather than error strings. Add tests for loose-type compatibility, later-child width mismatch, and metadata/nullability handling, including when consistency is restored if the old schema must be retained.
There was a problem hiding this comment.
Good point. Checking only the first child would hide a short input further along. The old schema is kept only when every input still has the same width and a type does not match yet. A short later input, or a union with one input, returns the error. There is a case for the short input on the right, and another where the types line up afterwards and the metadata and nullability come back.
| async fn coalesce_cast_union_aggregate_preserves_field_metadata() -> Result<()> { | ||
| let ctx = SessionContext::new(); | ||
| register_t(&ctx, true)?; | ||
| assert_max_count(&ctx, COALESCE_CAST_UNION_AGG, 6).await |
There was a problem hiding this comment.
Optional: could we also test the non-aggregate UNION and assert that x retains PARQUET:field_id=1 in both the optimized logical schema and emitted batch schema? The existing aggregate regression checks planning and results, but an end-to-end metadata assertion would protect against silently stripping metadata.
There was a problem hiding this comment.
Done. Same query without the aggregate. x still has PARQUET:field_id = 1 on the optimized plan and on the batch that comes back.
| }); | ||
| assert!(stale.schema().field(0).metadata().is_empty()); | ||
|
|
||
| let refreshed = stale.recompute_schema()?; |
There was a problem hiding this comment.
Optional: please consider a same-width UNION test where the stored schema is non-nullable but a current child is nullable. Verify that recomputation updates nullability while preserving the expected name, type, and metadata, since the existing fixture exercises metadata refresh but not this nullability transition.
There was a problem hiding this comment.
Added a same-width union where the stored field is NOT NULL and one child is nullable. After recompute the name, type, and metadata are the same, and the field is nullable.
| // the prior behavior for pruned inputs. For `Union`s built | ||
| // `BY NAME`, inputs should already share width here, so | ||
| // positional `try_new` is sufficient. | ||
| match Union::try_new(inputs.clone()) { |
There was a problem hiding this comment.
Optional follow-up: could we compare this path with rebuilding the same rewritten children through with_new_exprs? That UNION arm still retains the old schema when width is unchanged. A focused test and clarification of whether recomputation must follow rebuilding would establish the intended contract without requiring a broader refactor.
There was a problem hiding this comment.
with_new_exprs still keeps the stored schema when the width is unchanged. The metadata and nullability get fixed in recompute_schema, which is what runs when a child schema changes. The new test does the rebuild first, metadata still empty, then recompute, metadata back.
|
Another case this fixes: CREATE TABLE t (id BIGINT NOT NULL, a BIGINT NOT NULL, o BIGINT NOT NULL)
AS VALUES (1, 10, 1), (1, 11, 2), (2, 20, 1);
WITH d AS (SELECT DISTINCT ON (id) id, a FROM t ORDER BY id, o)
SELECT a FROM d UNION SELECT a FROM d;On |
A width check on the first child was hiding real try_new failures. Also cover the DISTINCT ON nullability repro.
CI checks the pull request merged with main, which now requires the short SPDX source header.
|
Thanks, that repro is clearer than the one I started from. It is in |
There was a problem hiding this comment.
@Amogh-2404, thank you for addressing the earlier review comments. The revised fallback now distinguishes legitimate loose-type UNIONs from structural errors, and the additional regression tests cover metadata propagation, nullability changes, and schema recomputation. The approach looks sound.
Refreshing the schema at the logical UNION boundary is the right architectural choice. It restores consistency with rewritten children without changing CAST metadata semantics or relying on physical planning to compensate for a stale logical contract. Preserving the stored schema temporarily for inputs awaiting type coercion is also justified.
I have reviewed the follow-up changes and consider my previous blocking concern resolved. The additional tests address the suggested coverage, and I have no further technical changes to request.
Which issue does this PR close?
Rationale for this change
CAST(coalesce(non_null_col, lit) AS T)underUNION ALL+ aggregate fails planning when the column carries Arrow field metadata (PARQUET:field_id):coalesceon aNOT NULLcolumn simplifies to the column. Child projections then advertise that metadata; the Union's stored schema still has{}from the pre-simplify coalesce field. Physical planning derives from the simplified exprs, so the aggregate schema check fails.What changes are included in this PR?
LogicalPlan::recompute_schemaforUnionno longer keeps the old schema whenever field count is unchanged. It re-derives viaUnion::try_newso metadata/nullability track the current children. If width is unchanged buttry_newfails (loose-typed children before coercion), the previous schema is kept; width mismatches still return thetry_newerror.What is the testing strategy for this PR?
datafusion/core/tests/sql/union_field_metadata.rs— issue query (m=3,c=6), controls (no metadata /CAST(b)/ directb AS x/ no union), mixed-metadata legs.recompute_schema_refreshes_union_field_metadata, loose-union preserve, width-mismatch error.Verified fail-before / pass-after on
coalesce_cast_union_aggregate_preserves_field_metadataby temporarily reverting theplan.rschange.Commands run:
Not run: full
./dev/rust_lint.sh, full extended CI suite, full sqllogictest corpus.Are there any user-facing changes?
No public API change. Queries that previously hit this internal error now plan. Union output may correctly carry field metadata that a stale schema had dropped after expression rewrites. CAST metadata inheritance is unchanged.