Skip to content

feat: give TryCastExpr a target field - #24835

Draft
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:adriangb/try-cast-target-field
Draft

feat: give TryCastExpr a target field#24835
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:adriangb/try-cast-target-field

Conversation

@adriangb

@adriangb adriangb commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Stacking

This is PR 3 of 3 decomposing #23169.

Opened as a draft while the stack is under review.

Rationale for this change

Expr::TryCast holds a FieldRef target so a TRY_CAST can name a destination richer than a DataType — for example an extension type resolved by a TypePlanner, whose ARROW:extension:name lives in the field's metadata. The physical TryCastExpr stored only a DataType, so there was nowhere to put that target, and create_physical_expr refused to lower the expression at all:

SELECT TRY_CAST(raw AS UUID) FROM ...;
Error during planning: TryCast from FixedSizeBinary(16) to FixedSizeBinary(16)<{"ARROW:extension:name": "arrow.uuid"}> is not supported

That is odd on its face: the same query written with CAST has worked since #20836, which gave CastExpr a target field. The guard in the planner was the symptom; the missing field was the cause.

What changes are included in this PR?

TryCastExpr gains a target_field, mirroring CastExpr:

  • TryCastExpr::new_with_target_field(expr, target_field) is the new field-aware constructor. TryCastExpr::new(expr, cast_type) keeps working unchanged and synthesizes a type-only target, so this is purely additive.
  • cast_type() now reads through the target field; target_field() exposes it.
  • try_cast_with_target_field(expr, input_schema, target_field) is the field-aware builder. It is pub(crate), matching the visibility of its counterpart cast_with_target_field; both are used only by create_physical_expr. It elides the cast only when the cast would be a genuine no-op, exactly as cast_with_target_field does — a same-type TRY_CAST is still meaningful when it drops metadata.
  • create_physical_expr passes the logical target field straight through, and the planner guard is deleted.
  • return_field derives its result from the shared cast_output_field, so TRY_CAST and CAST report their output field by the same rule.

Proto

datafusion/proto does serialize both cast expressions, and both PhysicalCastNode and PhysicalTryCastNode carried only an ArrowType. A cast to an extension type therefore came back from serialization as a plain cast to the storage type, silently losing ARROW:extension:name. For CastExpr that is a pre-existing gap, present since it gained a target field; for TryCastExpr it would be a gap this PR introduces. Fixing only one of the two would leave a confusing asymmetry, so both messages gain the same optional field:

optional datafusion_common.Field target_field = 3;

It is written only when the target says more than a data type, so plans that do not use one encode byte for byte as they did before, and a node without it still decodes by falling back to arrow_type. Generated code was refreshed with the repository's own datafusion/proto-models/regen.sh.

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-physical-expr -p datafusion-physical-plan -p datafusion-sql -p datafusion-proto -p datafusion-proto-models -p datafusion-optimizer -p datafusion-substrait --lib --tests green; ./ci/scripts/rust_clippy.sh exits 0.

New tests:

  • cast_extension_type_metadata.slt: TRY_CAST(... AS UUID) on a literal and on a column now returns arrow.uuid instead of failing to plan. These replace the statement error that pinned the old planner guard, and are the cases Align metadata propagation through Physical and Logical casts #23169's reference test file covers at its lines 49 and 66. A third case checks that a TRY_CAST naming only a data type still drops the source's metadata.
  • try_cast.rs: try_cast_with_target_field_carries_target_metadata, same_type_try_cast_is_only_elided_when_it_is_a_no_op, target_field_survives_a_proto_round_trip, a_type_only_target_field_is_not_encoded.
  • cast.rs: target_field_survives_a_proto_round_trip, a_type_only_target_field_is_not_encoded.

Load-bearing checks:

  • routing only the data type through create_physical_expr instead of the target field (leaving the guard removed) fails the first new slt case at cast_extension_type_metadata.slt:51 with NULL in place of arrow.uuid. Against main all three slt cases fail outright, with the planning error above.
  • forcing target_field: None on the encode side fails both target_field_survives_a_proto_round_trip tests, while both a_type_only_target_field_is_not_encoded tests keep passing — which is what confirms the "only encode an explicit target" condition is doing something rather than the field always being written.

Are there any user-facing changes?

Yes, and they are all fixes:

  • TRY_CAST(expr AS <extension type>) plans and executes instead of failing, and reports the target's metadata.
  • A CAST or TRY_CAST to an extension type keeps that extension type across protobuf serialization.

API changes are additive: TryCastExpr::new_with_target_field, TryCastExpr::target_field, and the optional target_field on PhysicalCastNode/PhysicalTryCastNode. try_cast_with_target_field is crate-internal and adds no public surface. TryCastExpr::new and try_cast keep their signatures and behaviour for a type-only target.

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) physical-plan Changes to the physical-plan crate labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-expr v55.0.0 (current)
       Built [  29.057s] (current)
     Parsing datafusion-expr v55.0.0 (current)
      Parsed [   0.082s] (current)
    Building datafusion-expr v55.0.0 (baseline)
       Built [  28.775s] (baseline)
     Parsing datafusion-expr v55.0.0 (baseline)
      Parsed [   0.082s] (baseline)
    Checking datafusion-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.940s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  61.208s] datafusion-expr
    Building datafusion-expr-common v55.0.0 (current)
       Built [  19.858s] (current)
     Parsing datafusion-expr-common v55.0.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-expr-common v55.0.0 (baseline)
       Built [  19.380s] (baseline)
     Parsing datafusion-expr-common v55.0.0 (baseline)
      Parsed [   0.021s] (baseline)
    Checking datafusion-expr-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.325s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  40.614s] datafusion-expr-common
    Building datafusion-physical-expr v55.0.0 (current)
       Built [  28.114s] (current)
     Parsing datafusion-physical-expr v55.0.0 (current)
      Parsed [   0.052s] (current)
    Building datafusion-physical-expr v55.0.0 (baseline)
       Built [  28.510s] (baseline)
     Parsing datafusion-physical-expr v55.0.0 (baseline)
      Parsed [   0.054s] (baseline)
    Checking datafusion-physical-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.504s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  58.114s] datafusion-physical-expr
    Building datafusion-physical-plan v55.0.0 (current)
       Built [  38.491s] (current)
     Parsing datafusion-physical-plan v55.0.0 (current)
      Parsed [   0.180s] (current)
    Building datafusion-physical-plan v55.0.0 (baseline)
       Built [  38.475s] (baseline)
     Parsing datafusion-physical-plan v55.0.0 (baseline)
      Parsed [   0.160s] (baseline)
    Checking datafusion-physical-plan v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.020s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  79.905s] datafusion-physical-plan
    Building datafusion-proto-models v55.0.0 (current)
       Built [  25.687s] (current)
     Parsing datafusion-proto-models v55.0.0 (current)
      Parsed [   0.137s] (current)
    Building datafusion-proto-models v55.0.0 (baseline)
       Built [  25.579s] (baseline)
     Parsing datafusion-proto-models v55.0.0 (baseline)
      Parsed [   0.143s] (baseline)
    Checking datafusion-proto-models v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   2.610s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field PhysicalCastNode.target_field in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1865
  field PhysicalCastNode.target_field in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1865
  field PhysicalTryCastNode.target_field in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1855
  field PhysicalTryCastNode.target_field in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1855

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  55.801s] datafusion-proto-models
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [ 101.307s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [ 101.369s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.024s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.120s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 205.458s] datafusion-sqllogictest
@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 1, 2026
NULL NULL
3 NULL

# Regression test: CAST with single-argument arrow_metadata (returns full map)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need updating?

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.
`Expr::TryCast` holds a `FieldRef` target so that a `TRY_CAST` can name a
destination richer than a `DataType` - an extension type resolved by a
`TypePlanner`, whose `ARROW:extension:name` lives in the field's metadata. The
physical `TryCastExpr` stored only a `DataType`, so there was nowhere to put
that target, and `create_physical_expr` bailed out rather than lower it:

    SELECT TRY_CAST(raw AS UUID) FROM ...;
    Error during planning: TryCast from FixedSizeBinary(16) to
    FixedSizeBinary(16)<{"ARROW:extension:name": "arrow.uuid"}> is not supported

which is odd on its face, since the same query with `CAST` has worked since
apache#20836.

Give `TryCastExpr` a `target_field`, mirroring `CastExpr`:

* `TryCastExpr::new_with_target_field` is the field-aware constructor;
  `TryCastExpr::new` keeps working and synthesizes a type-only target
* `try_cast_with_target_field` is the field-aware builder, and elides the cast
  only when it would be a genuine no-op, exactly as `cast_with_target_field`
  does
* `create_physical_expr` passes the logical target field straight through, and
  the planner guard is gone

Proto carried only the data type, for `PhysicalTryCastNode` and
`PhysicalCastNode` alike, so a cast to an extension type came back from
serialization as a plain cast to the storage type. Both messages gain an
optional `target_field`; it is written only when the target says more than a
data type, so plans that do not use one encode exactly as before, and a node
without it still decodes by falling back to `arrow_type`.
@adriangb
adriangb force-pushed the adriangb/try-cast-target-field branch from 851fa03 to 330214f Compare September 1, 2026 06:43
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
datafusion/proto-models/src/generated/pbjson.rs 0.00% 26 Missing ⚠️
datafusion/physical-expr/src/expressions/cast.rs 85.00% 4 Missing and 14 partials ⚠️
...tafusion/physical-expr/src/expressions/try_cast.rs 89.03% 3 Missing and 14 partials ⚠️
datafusion/physical-plan/src/projection.rs 84.15% 4 Missing and 12 partials ⚠️
datafusion/expr/src/expr_rewriter/mod.rs 78.26% 0 Missing and 5 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   #24835    +/-   ##
========================================
  Coverage   81.58%   81.58%            
========================================
  Files        1123     1123            
  Lines      406610   407094   +484     
  Branches   406610   407094   +484     
========================================
+ Hits       331719   332118   +399     
- Misses      55453    55496    +43     
- Partials    19438    19480    +42     

☔ 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

auto detected api change Auto detected API change 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