Skip to content

fix: make a cast target's metadata authoritative - #24833

Draft
adriangb wants to merge 5 commits into
apache:mainfrom
pydantic:adriangb/cast-metadata-strict-rule
Draft

fix: make a cast target's metadata authoritative#24833
adriangb wants to merge 5 commits into
apache:mainfrom
pydantic:adriangb/cast-metadata-strict-rule

Conversation

@adriangb

@adriangb adriangb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Stacking

This is PR 1 of 3 decomposing #23169.

Opened as a draft while the stack is under review.

Rationale for this change

Field metadata such as ARROW:extension:name describes how to interpret one particular storage type. A cast produces a different storage type, so inheriting the source's metadata mints a field that claims to be an extension type it no longer is:

SELECT arrow_metadata(CAST(uuid_val AS BYTEA), 'ARROW:extension:name');
-- 'arrow.uuid'  <- a Binary column claiming to be a UUID

That is the failure mode described in #22079.

Underneath it sits a second problem. Expr::Cast/Expr::TryCast and the physical CastExpr each derive the output field of a cast, and each did it differently:

  • logical cast_output_field() inherited the source's metadata unless the target carried some;
  • physical CastExpr::resolved_target_field() used a non-synthesized target field verbatim, and otherwise inherited everything from the source.

Two implementations of one question is how the layers drifted apart (#24724), and because arrow_metadata(...) in SQL observes the physical field, a logical-only change is invisible end to end.

What changes are included in this PR?

One rule, in one place. datafusion_expr_common::casts::cast_output_field is now the single definition of how a cast's source field and target field combine, and both layers call it:

  • the data type always comes from the target
  • the metadata always comes from the target, including when it is empty
  • the name and nullability come from the target when it says more than a data type (is_type_only_cast_target), and from the source otherwise

Callers: logical Expr::Cast/Expr::TryCast (expr_schema.rs), physical CastExpr::resolved_target_field, and physical TryCastExpr::return_field. TryCastExpr has no target field yet, so it passes a type-only stand-in; PR 3 gives it a real one.

The behaviour change is the second bullet. A caller that wants metadata on the result of a cast now has to ask for it, by putting the metadata on the cast target.

Audit of the three same-type-cast elision sites. Under the old rule a same-type cast was a metadata no-op; under the new one it is meaningful — it is how you spell "drop this metadata" — so every place that elides one changes semantics.

  1. Expr::cast_to (expr_schema.rs) elides when the types already match.
  2. cast_with_target_field (physical-expr/expressions/cast.rs) elided when the types match and the target is type-only.

Site 1 is fine as it stands. Expr::cast_to is a type-only coercion helper whose contract is "give this expression this type"; when it declines to build a cast, no cast exists in the logical plan and none is lowered, so the two layers agree. It is not reachable from a user-written CAST, which the SQL planner lowers straight to Expr::Cast.

Site 2 is not. A logical Expr::Cast with matching types and a type-only target does something under the new rule — it strips metadata — but the physical lowering dropped it, so the physical plan kept metadata the logical plan had already discarded. The second commit changes the condition to "elide only when the cast would produce the field the child already has", which cast_output_field answers directly. It is not observable end to end in this PR because the only query that reaches it is short-circuited earlier by site 3; PR 2 relies on it.

  1. ArrowCastFunc::simplify short-circuits when source and target types are equal and returns the argument with no cast at all. That one is genuinely wrong under the new rule and is fixed in PR 2 — it is why arrow_cast(uuid_val, 'FixedSizeBinary(16)') still keeps arrow.uuid.

UNION branch coercion. This is the one site that did not survive the rule change. coerce_exprs_for_schema cast each branch to the destination's data type, so the cast carried a type-only target and dropped the metadata the union's output schema still advertised, leaving the physical plan inconsistent with the logical one:

Internal error: Physical input schema should be the same as the one converted from logical input schema. Differences:
        - field metadata at index 0 [name]: (physical) {} vs (logical) {"metadata_key": "the nonnull_name field"}

on all three of the metadata-preserving UNION regression queries in metadata.slt. The fix is to coerce to the destination field rather than just its data type (cast_expr_to_field), so a coerced branch ends up carrying exactly the metadata it was coerced to. The destination contributes only its data type and metadata; the name and nullability stay those of the expression being cast, which is what keeps the logical and physical fields identical.

Updated assertions. Seven metadata.slt assertions added by #21390 pinned the old inheritance (CAST/TRY_CAST preserving source metadata). They now assert the new rule.

What is the testing strategy for this PR?

Full sqllogictest suite green (504/504 files), and cargo test -p datafusion-expr -p datafusion-expr-common -p datafusion-physical-expr -p datafusion-physical-plan -p datafusion-sql -p datafusion-proto -p datafusion-optimizer --lib --tests green. ./ci/scripts/rust_clippy.sh exits 0.

New tests:

  • datafusion/expr-common/src/casts.rs: type_only_cast_target_is_recognised, cast_output_field_does_not_inherit_source_metadata, cast_output_field_takes_an_explicit_target_verbatim, cast_output_field_force_nullable_is_for_try_cast
  • datafusion/physical-expr/src/expressions/cast.rs: type_only_cast_does_not_inherit_source_metadata, same_type_cast_is_only_elided_when_it_is_a_no_op
  • datafusion/physical-expr/src/expressions/try_cast.rs: try_cast_does_not_inherit_source_metadata
  • cast_extension_type_metadata.slt: casting an arrow.uuid value to BYTEA drops the extension metadata
  • metadata.slt: a UNION branch coerced across types keeps the metadata the union's output schema advertises

Each new test was checked to be load-bearing by temporarily reverting the fix it covers:

  • reverting the metadata rule to the old one fails all six new unit tests plus the new cast_extension_type_metadata.slt case, the seven updated metadata.slt assertions, and two parquet_metadata_functions.slt queries;
  • reverting cast_expr_to_field back to a data-type-only cast fails the new metadata.slt UNION case and reproduces the three Internal error: Physical input schema should be the same... failures at metadata.slt:128, :158 and :190;
  • reverting the elision condition to the old type-equality check fails same_type_cast_is_only_elided_when_it_is_a_no_op.

Are there any user-facing changes?

Yes. CAST and TRY_CAST no longer copy the source column's field metadata onto their result. To keep metadata across a cast, put it on the cast target (for example via a TypePlanner extension type). No public API is removed; datafusion_expr_common::casts::cast_output_field and is_type_only_cast_target are added.

adriangb and others added 3 commits August 31, 2026 17:21
Field metadata on a ProjectionExec's output schema could silently disappear
when the physical optimizer removed or rewrote projections:

1. A metadata-only identity projection was treated as removable, because the
   check only compared column indices, aliases, and counts.
2. Collapsing a projection across a metadata boundary substituted the outer
   expression through the inner projection, so metadata-reading expressions
   saw the scan field instead of the projected field.
3. `make_with_child` rebuilt the projection with `try_new`, rederiving the
   output schema and dropping the original metadata.

This commit is taken verbatim from @gene-bordegaray's work in
apache#24670.

Co-Authored-By: Gene Bordegaray <gene.bordegaray@datadoghq.com>
The logical `Expr::Cast`/`Expr::TryCast` carry a `FieldRef` target so a cast
can express a destination that is more than a `DataType` (for example an
extension type produced by a `TypePlanner`). `cast_output_field` ignored that
field's metadata entirely and always inherited the source's, so
`Expr::to_field()` disagreed with the physical `CastExpr`, which already treats
a non-synthesized target field as authoritative.

The divergence was masked because the physical optimizer rederives a
projection's schema from its expressions, repairing the logical schema on the
way through. Once projections preserve their metadata faithfully (previous
commit) the underlying bug surfaces, and a cast to an extension type loses it:

    SELECT arrow_metadata(CAST(raw AS UUID), 'ARROW:extension:name')
    -- 'arrow.uuid' before, NULL after

Take the target's metadata when it carries any, and otherwise inherit the
source's. A plain `CAST(expr AS type)` synthesizes a target with no metadata,
so its long-standing behaviour is unchanged.
`Expr::Cast`/`Expr::TryCast` and the physical `CastExpr` each derive the
output field of a cast, and each did it differently: the logical side
inherited the source's metadata unless the target carried some, while the
physical side used a non-synthesized target field verbatim. Two rules for one
question is how the layers drifted apart in
apache#24724.

Give them one rule, in one place - `datafusion_expr_common::casts::cast_output_field`:

* the data type always comes from the target
* the metadata always comes from the target, *including* when it is empty
* the name and nullability come from the target when it says more than a data
  type, and from the source otherwise

The behaviour change is the second point. Metadata such as
`ARROW:extension:name` describes how to read one particular storage type; a
cast produces a different one, so inheriting the source's metadata mints a
field claiming to be an extension type it no longer is
(apache#22079):

    SELECT arrow_metadata(CAST(uuid_val AS BYTEA), 'ARROW:extension:name')
    -- 'arrow.uuid' before, NULL after

A caller that wants metadata on the result now has to ask for it, by putting
it on the cast target.

That makes a same-type cast meaningful - it is how you spell "drop this
metadata" - so the places that elide one had to be checked. The logical
`Expr::cast_to` and the physical `cast()`/`cast_with_target_field` already
agree: both elide only when the target is type-only, and neither is reachable
from a user-written `CAST`, which the SQL planner lowers directly.

The one place that did not survive is UNION branch coercion.
`coerce_exprs_for_schema` cast each branch to the destination's *data type*,
so the cast target carried no metadata and the coerced branch dropped the
metadata the union's output schema still advertised - leaving the physical
plan inconsistent with the logical one:

    Internal error: Physical input schema should be the same as the one
    converted from logical input schema.
      - field metadata at index 0 [name]: (physical) {} vs (logical)
        {"metadata_key": "the nonnull_name field"}

Coerce to the destination *field* instead, so the branch ends up carrying
exactly the metadata it was coerced to.

The `metadata.slt` assertions that pinned the old inheritance are updated to
the new rule.
@github-actions github-actions Bot added logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Sep 1, 2026
`cast_with_target_field` dropped the cast whenever the data types already
matched and the target field was the synthesized type-only one. That was
sound while a type-only cast could not change metadata; now that the target's
metadata is authoritative, such a cast is exactly how you spell "drop this
metadata", and eliding it leaves the physical plan reporting metadata the
logical plan has already dropped.

Elide only when the cast would produce the field the child already has, which
`cast_output_field` answers directly.

This is not observable end to end yet: the one query that reaches it,
`arrow_cast(uuid_val, 'FixedSizeBinary(16)')`, is short-circuited earlier by
`ArrowCastFunc::simplify`, which never builds the cast in the first place.
That is fixed in the next PR of this stack, which relies on this one.
An explicit target field fully determines a cast's output field, so there is
no need to resolve the child expression to derive it. Resolving it anyway
breaks `rewrite_file_row_index_expr`, which deliberately wraps a `Column`
whose index lies outside the schema the cast is later asked about, and which
relied on the previous short-circuit for explicit targets.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.73006% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.58%. Comparing base (a274959) to head (0bb24a2).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/projection.rs 84.15% 4 Missing and 12 partials ⚠️
datafusion/physical-expr/src/expressions/cast.rs 75.47% 2 Missing and 11 partials ⚠️
datafusion/expr/src/expr_rewriter/mod.rs 78.26% 0 Missing and 5 partials ⚠️
...tafusion/physical-expr/src/expressions/try_cast.rs 80.95% 0 Missing and 4 partials ⚠️
datafusion/expr-common/src/casts.rs 98.73% 0 Missing and 1 partial ⚠️
datafusion/expr/src/expr_schema.rs 97.95% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #24833    +/-   ##
========================================
  Coverage   81.58%   81.58%            
========================================
  Files        1123     1123            
  Lines      406610   406886   +276     
  Branches   406610   406886   +276     
========================================
+ Hits       331719   331947   +228     
- Misses      55453    55470    +17     
- Partials    19438    19469    +31     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

2 participants