Skip to content

fix: arrow_cast must not elide a metadata-changing cast - #24834

Closed
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:adriangb/arrow-cast-strip-extension
Closed

fix: arrow_cast must not elide a metadata-changing cast#24834
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:adriangb/arrow-cast-strip-extension

Conversation

@adriangb

@adriangb adriangb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Stacking

This is PR 2 of 3 decomposing #23169.

Opened as a draft while the stack is under review.

Rationale for this change

With the strict cast-metadata rule from #24833 in place, this still returns arrow.uuid:

SELECT arrow_metadata(arrow_cast(uuid_val, 'FixedSizeBinary(16)'), 'ARROW:extension:name');
-- arrow.uuid

That is an arrow.uuid value that escaped a cast back to its plain storage type, which is the failure mode in #22079.

ArrowCastFunc::simplify short-circuits when the argument's data type already equals the requested one and returns the argument untouched, so no cast is built and no rule about cast metadata can apply. That was sound while a same-type cast could not change anything. It is not any more: a cast target's metadata is authoritative, and arrow_cast names a storage type and nothing else — its declared return field (return_field_from_args) never carries metadata. Casting an extension-typed value back to its own storage type therefore is meaningful, and the short circuit swallowed it.

What changes are included in this PR?

ArrowCastFunc::simplify now elides the cast only when the argument carries no metadata for the cast to strip. When it does carry metadata, a real Expr::Cast with a type-only target is built, and the rule from #24833 drops the metadata.

The condition is "the argument carries any metadata", not "the argument carries ARROW:extension:name". That follows from the rule rather than from the symptom: the cast target carries no metadata at all, so any metadata on the argument is metadata this cast removes, and singling out one key would leave arrow_cast silently preserving the rest.

This is also where the second commit of #24833 earns its keep. Building the cast is not enough on its own — the physical lowering used to drop a same-type cast with a type-only target, which would have put the metadata straight back.

What is the testing strategy for this PR?

Full sqllogictest suite green (504/504 files), cargo test -p datafusion-expr -p datafusion-expr-common -p datafusion-functions -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 cast_extension_type_metadata.slt cases:

  • arrow_cast(uuid_val, 'FixedSizeBinary(16)') — same storage type — no longer reports arrow.uuid. This is the case Align metadata propagation through Physical and Logical casts #23169's reference test file covers at its line 80.
  • arrow_cast(uuid_val, 'Binary') — different storage type — likewise.
  • an EXPLAIN pinning that an arrow_cast whose argument has no metadata is still simplified away entirely, so the short circuit is narrowed rather than removed.

Load-bearing check: reverting the condition to the old source_type == target_type fails the first new case (cast_extension_type_metadata.slt:70) with arrow.uuid instead of NULL, while the EXPLAIN case keeps passing — which is what confirms the two halves of the new condition are each doing something.

Are there any user-facing changes?

Yes. arrow_cast(expr, '<type>') no longer returns expr unchanged when expr already has that type but carries field metadata; it now produces a value with that type and no metadata, matching arrow_cast's declared return field. No public API changes.

adriangb and others added 4 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.
`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.
@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) functions Changes to functions implementation physical-plan Changes to the physical-plan crate labels Sep 1, 2026
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.
`ArrowCastFunc::simplify` short-circuited whenever the argument's data type
already equalled the requested one and returned the argument untouched. That
was sound while a same-type cast could not change anything, but a cast
target's metadata is authoritative, and `arrow_cast` names a storage type and
nothing else - its declared return field (`return_field_from_args`) never
carries metadata.

So casting an extension-typed value back to its own storage type is not a
no-op, it strips the extension metadata, and the short circuit swallowed it:

    SELECT arrow_metadata(arrow_cast(uuid_val, 'FixedSizeBinary(16)'),
                          'ARROW:extension:name')
    -- 'arrow.uuid' before, NULL after

which is an `arrow.uuid` value that escaped a cast back to plain
`FixedSizeBinary(16)` (apache#22079).

Elide the cast only when the argument carries no metadata for it to strip.
Checking metadata emptiness rather than just `ARROW:extension:name` follows
from the rule itself: the target field carries no metadata at all, so any
metadata on the argument is metadata the cast removes.
@adriangb
adriangb force-pushed the adriangb/arrow-cast-strip-extension branch from bd548b6 to d939afc Compare September 1, 2026 06:34
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.57576% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.58%. Comparing base (a274959) to head (d939afc).
⚠️ 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 ⚠️
datafusion/functions/src/core/arrow_cast.rs 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #24834    +/-   ##
========================================
  Coverage   81.58%   81.58%            
========================================
  Files        1123     1123            
  Lines      406610   406889   +279     
  Branches   406610   406889   +279     
========================================
+ Hits       331719   331949   +230     
- Misses      55453    55470    +17     
- Partials    19438    19470    +32     

☔ 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.

@adriangb adriangb closed this Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation 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)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants