Skip to content

Preserve field metadata through physical CASE expressions - #26158

Draft
osipovartem wants to merge 16 commits into
apache:mainfrom
Embucket:upstream-case-physical-metadata
Draft

osipovartem wants to merge 16 commits into
apache:mainfrom
Embucket:upstream-case-physical-metadata

Conversation

@osipovartem

@osipovartem osipovartem commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Part of #24860. Stacked on #26145; merge the logical contract first. Rebased onto logical bd6ab71dd9723494d1e0c47a1908d96ba9c2d84a, including its main merge. Physical-only comparison.

Rationale for this change

Physical CASE drops field metadata, including for CASE-based NVL/IFNULL/NVL2 rewrites. Execution-time consumers such as arrow_metadata read physical argument fields; physical-only inference also cannot reconstruct logical annotations discarded during lowering.

What changes are included?

  • Reuse coerced_fields_metadata: earlier keys win, extension name/parameters stay paired, and incompatible extension names are rejected.
  • Carry optional logical metadata through lowering, preserving absent versus explicitly empty annotations. Current physical children still determine type/nullability; storage-type changes suppress obsolete metadata.
  • Inspect all contributing result branches, with borrowed metadata for ordinary columns/literals and a bottom-up path for manually constructed nested CASE/casts.
  • Use native cached function return fields without a depth cutoff. Tests now require metadata preservation at depths 2, 9, 32 and 128 instead of accepting depth-based erasure. The NOT non-inheritance guard remains in place.
  • Preserve metadata-bearing typed NULL ELSE branches, scalar output shape, the column/NULL kernel, untyped-NULL neutrality and NULL typing after child rewrites.
  • Carry the guarded logical annotation through physical protobuf and JSON round-trips.

What is the testing strategy?

End-to-end SQL cases are consolidated in metadata.slt, including #26895's intended results, aliases, branch precedence, NULLs, extension conflicts and CASE-based function rewrites. This follow-up adds binary/NOT unmarked-branch checks and a nine-level metadata-bearing function case, and updates the planner assertion to require retained logical/physical metadata after alias lowering.

The new deep-function requirement and stale planner assertion were each reproduced failing before correction. Focused validation at dbba94af0: 70/70 CASE tests; default-feature physical-expression suite 1,764 passed, 2 existing ignored; both metadata.slt and cast_extension_type_metadata.slt passed. Protobuf/JSON tests inspect decoded fields and values, not only projection schemas. Existing tests retain scalar, typed/untyped NULL, rewriting, borrowed-field and field-name scaling coverage.

The final main-synced stack at 3018e535a, based on logical bd6ab71dd, passed the documented extended-workspace run: 13,383 Rust tests (8 existing ignored) and 545/545 SQLLogicTest files. Strict all-target/all-feature workspace Clippy with -D warnings, rustfmt and diff checks passed on this stack. The earlier pre-main-merge run passed 13,375 Rust tests, including 1,878 proto/recursive-feature-enabled physical-expression tests with 1 ignored. These revision/feature-specific counts are not interchangeable denominators.

Performance evidence and limits

The current cutoff removal delegates to cached function fields; it does not add row-wise evaluation. The inherited ordinary CASE kernels and fast paths remain covered. The final logical binary-path measurements, costs versus main and deep CASE/UDF limitation are disclosed in #26145; the structural worklist does not establish global planning linearity. The rebased stack was rerun with the logical binary-inference and NOT/NULL fixes, not only the earlier cutoff-removal revision.

The earlier physical screen used 31 fixtures, 20 Criterion samples, one-second warmup/measurement, Rust 1.99.0 and ci-bench, comparing 185efd08d to the subsequent physical implementation with the same logical dependency, not Apache main. Those are historical measurements, not a fresh timing run of this rebased revision:

Case Before median After median
Scalar field + evaluation, 4 metadata keys 542 ns 182 ns
Scalar field + evaluation, 64 keys 7.348 us 181 ns
8,192 rows, field + evaluation, 64 keys 21.636 us 13.599 us
8,192 rows, column/NULL, 64 keys 11.563 us 4.273 us
Plan 100 unmarked branches 39.643 us 41.899 us
Plan 100 branches, 4 metadata keys 67.622 us 69.021 us

That screen disclosed approximately 6% wide-planning cost and an unresolved 180-to-218-ns scalar NULL fixture difference. A separate earlier three-control comparison against main 49c67c296 measured main/candidate medians of 9.682/9.814 us (literal branches), 9.125/8.991 us (column branches), and 4.151/4.131 us (column/implicit NULL). These do not qualify current release-profile, end-to-end SQL, Iceberg or SPCS latency, and are not a zero-regression claim. API/performance decisions remain maintainer merge gates.

Are there user-facing or API changes?

CASE-derived fields retain result metadata. New PhysicalCaseNode fields are binary-protobuf wire-additive, and old encoded plans decode without a pin. Adding public generated Rust fields breaks exhaustive struct literals and needs maintainer/API-version coordination. Older binary readers can ignore and lose the annotation; older generated JSON readers may reject unknown fields. Mixed-version metadata preservation is not promised.

The physical-only portion does not change ExprSchema. End-to-end behavior requires #26145; this stack must not merge ahead of that dependency and the API/performance decisions above.

@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Oct 9, 2026
@osipovartem
osipovartem marked this pull request as draft October 9, 2026 14:04
@github-actions

github-actions Bot commented Oct 9, 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 611311cd7ef50b40dd33f88ec527e2913de8e3d1
    Building datafusion v55.1.0 (current)
       Built [  62.317s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.036s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  18.014s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.037s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.613s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  82.745s] datafusion
    Building datafusion-common v55.1.0 (current)
       Built [  19.365s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.076s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [   1.147s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.079s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.793s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  22.302s] datafusion-common
    Building datafusion-expr v55.1.0 (current)
       Built [  15.944s] (current)
     Parsing datafusion-expr v55.1.0 (current)
      Parsed [   0.109s] (current)
    Building datafusion-expr v55.1.0 (baseline)
       Built [   7.401s] (baseline)
     Parsing datafusion-expr v55.1.0 (baseline)
      Parsed [   0.112s] (baseline)
    Checking datafusion-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   1.759s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  26.144s] datafusion-expr
    Building datafusion-optimizer v55.1.0 (current)
       Built [  17.164s] (current)
     Parsing datafusion-optimizer v55.1.0 (current)
      Parsed [   0.035s] (current)
    Building datafusion-optimizer v55.1.0 (baseline)
       Built [   7.456s] (baseline)
     Parsing datafusion-optimizer v55.1.0 (baseline)
      Parsed [   0.036s] (baseline)
    Checking datafusion-optimizer v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.173s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  25.551s] datafusion-optimizer
    Building datafusion-physical-expr v55.1.0 (current)
       Built [   3.582s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.056s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [   3.590s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.058s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.402s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [   8.363s] datafusion-physical-expr
    Building datafusion-proto v55.1.0 (current)
       Built [  28.489s] (current)
     Parsing datafusion-proto v55.1.0 (current)
      Parsed [   0.013s] (current)
    Building datafusion-proto v55.1.0 (baseline)
       Built [  21.599s] (baseline)
     Parsing datafusion-proto v55.1.0 (baseline)
      Parsed [   0.014s] (baseline)
    Checking datafusion-proto v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.124s] 229 checks: 229 pass, 31 skip
     Summary no semver update required
    Finished [  51.367s] datafusion-proto
    Building datafusion-proto-models v55.1.0 (current)
       Built [  17.008s] (current)
     Parsing datafusion-proto-models v55.1.0 (current)
      Parsed [   0.164s] (current)
    Building datafusion-proto-models v55.1.0 (baseline)
       Built [   5.851s] (baseline)
     Parsing datafusion-proto-models v55.1.0 (baseline)
      Parsed [   0.167s] (baseline)
    Checking datafusion-proto-models v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   2.053s] 229 checks: 228 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.51.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field PhysicalCaseNode.logical_result_type in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1941
  field PhysicalCaseNode.logical_result_metadata in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1943
  field PhysicalCaseNode.logical_result_type in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1941
  field PhysicalCaseNode.logical_result_metadata in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:1943

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  26.305s] datafusion-proto-models

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Oct 9, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.44575% with 147 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.76%. Comparing base (06aa131) to head (57507b6).

Files with missing lines Patch % Lines
datafusion/physical-expr/src/expressions/case.rs 82.68% 38 Missing and 47 partials ⚠️
datafusion/physical-expr/src/planner.rs 78.65% 9 Missing and 26 partials ⚠️
datafusion/proto-models/src/generated/pbjson.rs 0.00% 27 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26158      +/-   ##
==========================================
- Coverage   82.77%   82.76%   -0.01%     
==========================================
  Files        1147     1147              
  Lines      450909   451585     +676     
  Branches   450909   451585     +676     
==========================================
+ Hits       373252   373776     +524     
- Misses      54934    55007      +73     
- Partials    22723    22802      +79     

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

gafiatulin pushed a commit to gafiatulin/datafusion that referenced this pull request Oct 30, 2026
…#24865)

## Which issue does this PR close?

- Part of apache#24860.

## Rationale for this change

`COALESCE`, `NVL`/`IFNULL`, and `NVL2` drop the field metadata of their
arguments when computing their return field: `return_field_from_args`
builds the output `Field` from the argument *data types* only. For
columns carrying Arrow extension types (`ARROW:extension:name` /
`ARROW:extension:metadata` — e.g. `arrow.uuid`, `geoarrow.wkb`), the
planned schema of any expression containing these functions silently
loses the extension-type identity, and metadata-aware UDFs that dispatch
on their argument `Field`s fail to plan over them (see apache#24860 for a full
reproducer).

This is one of several independent metadata drop sites listed in apache#24860.
It is the plan-time schema fix for the conditional functions. These
functions simplify into `CASE`, whose logical and physical drop sites
are addressed separately in apache#26145 and apache#26158.

## What changes are included in this PR?

- `datafusion_common::metadata::coerced_fields_metadata`: computes the
metadata for an expression whose result may come from any one of several
inputs coerced to a common type. It lives next to the related metadata
utilities so that `CASE` (in `datafusion-expr`) can share it:
- Metadata is merged across the inputs whose data type is not `Null` (an
untyped NULL literal carries no metadata and cannot contribute a typed
value), with earlier inputs taking precedence for repeated keys.
Unrelated keys are not required to agree, so metadata that arrived with
the data (e.g. from an Arrow file) cannot cause an extension type to be
dropped.
- The extension type keys are handled as a unit, as in `Expr::Cast`
output field derivation: differing values of `ARROW:extension:name` are
a planning error, and `ARROW:extension:metadata` always comes from the
input that supplied the extension name (byte-wise differences in it are
tolerated, since extension parameters are often JSON).
- `coalesce`: `return_field_from_args` attaches the merged metadata of
its arguments. Return type and nullability are unchanged. `NVL`/`IFNULL`
delegate to `coalesce` and are fixed by the same change.
- `nvl2`: same, over the second and third arguments only — the first
argument is only tested for NULL and never contributes a value, so its
metadata is excluded.

## What is the testing strategy for this PR?

- Unit tests for `coerced_fields_metadata` in `metadata.rs`: merge
precedence, untyped NULL inputs skipped, empty result, error on
differing extension names, and extension name/metadata kept paired.
- Unit tests in `coalesce.rs` and `nvl2.rs` exercise
`return_field_from_args`: metadata propagated, untyped NULL arguments
not blocking propagation, conflicting extension types rejected, NVL2's
test argument excluded, and nullability unchanged.

There is deliberately no sqllogictest yet: `arrow_metadata()` reads its
argument field at execution time, and by then these functions have been
simplified into `CASE`, so end-to-end coverage depends on the `CASE`
fixes (apache#26145, apache#26158). This ordering is safe: the optimizer's schema
invariant ignores metadata (`assert_expected_schema` /
`logically_equivalent_names_and_types`), so a plan whose `coalesce`
carries metadata that the simplified `CASE` does not still plans.

## Are there any user-facing changes?

- The planned schema (e.g. `DataFrame::schema()`) of expressions
containing `COALESCE`/`NVL`/`IFNULL`/`NVL2` over metadata-bearing
columns now preserves that metadata.
- `COALESCE`/`NVL`/`IFNULL`/`NVL2` over arguments with *differing*
`ARROW:extension:name` values (e.g. an `arrow.uuid` column and an
`arrow.json` column) is now a planning error. Previously these planned
and silently dropped the extension type.
- New public function
`datafusion_common::metadata::coerced_fields_metadata`. No other API
changes.
@osipovartem
osipovartem force-pushed the upstream-case-physical-metadata branch from ef9bdc4 to 3fad8a2 Compare October 30, 2026 18:00
@osipovartem
osipovartem marked this pull request as ready for review October 30, 2026 18:00
@osipovartem
osipovartem force-pushed the upstream-case-physical-metadata branch from 3fad8a2 to dbba94a Compare October 30, 2026 21:01
@github-actions github-actions Bot added logical-expr Logical plan and expressions optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) common Related to common crate proto Related to proto crate labels Oct 30, 2026
@osipovartem
osipovartem force-pushed the upstream-case-physical-metadata branch from dbba94a to 4693a34 Compare October 30, 2026 21:52
@github-actions

Copy link
Copy Markdown

Marked as draft after 14 days without activity: merge conflicts.

Please address these items and mark Ready for review when ready.

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 common Related to common crate core Core DataFusion crate logical-expr Logical plan and expressions optimizer Optimizer rules physical-expr Changes to the physical-expr crates proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants