fix: apply struct field filters when the file schema needs adaptation - #24125
fix: apply struct field filters when the file schema needs adaptation#24125adriangb wants to merge 4 commits into
Conversation
When the declared table schema differs from the physical file schema for a struct column, the expression adapter wraps the whole struct column in a cast, so `s['x']` becomes `get_field(cast(s AS Struct<..>), 'x')`. That hides the column from consumers that pattern match on `get_field(column, 'f')`. The Parquet scan is one such consumer: it decides at planning time (against the table schema) that a struct-field predicate can be evaluated as a row filter and reports it as fully handled, so `FilterExec` is removed from the plan. At runtime the row filter builder no longer recognizes the adapted expression, silently drops the predicate, and the query returns unfiltered rows. Narrow the cast to the field that is actually read: `get_field(cast(s AS Struct<..>), 'x')` becomes `cast(get_field(s, 'x') AS <type of x>)`. This keeps the column visible under the `get_field`, and also avoids materializing a whole cast struct just to read one field. Fields that are missing from the file collapse to a typed null literal, matching what the struct cast would have produced. `get_field` on a Map column is a runtime key lookup rather than a schema-level field access, so those keep the whole-column cast. Closes apache#24109. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MebN5PsVnYvXUeVKju5K7P
`get_field` has a flattened multi-key form: the logical simplifier rewrites `s['a']['b']` into `get_field(s, 'a', 'b')`. The narrowing rule only matched the two-argument form, so nested field access kept the whole-struct cast and stayed exposed to the wrong-results bug it was meant to fix. Resolve the full key path through nested struct fields on both the logical (cast target) and physical sides, and rebuild `get_field` with every key preserved. A path whose leaf is missing from the file still collapses to a typed null literal; a path that runs through a non-struct field is left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MebN5PsVnYvXUeVKju5K7P
| /// Filters on struct fields (`s['x'] = 200`) must still be applied when the | ||
| /// table schema disagrees with the physical file schema, which forces the | ||
| /// expression adapter to insert a cast. | ||
| /// | ||
| /// See <https://github.com/apache/datafusion/issues/24109>. | ||
| mod struct_field_pushdown { |
There was a problem hiding this comment.
Prefer SLT tests if possible
Drop the opener-level Rust tests in favour of SLT, which covers the same ground end to end through the planner. Adds a matching-schema control and a missing-field case alongside the existing adapted-schema tests, so the deleted Rust coverage is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MebN5PsVnYvXUeVKju5K7P
There was a problem hiding this comment.
Pull request overview
Fixes a wrong-results bug in Parquet filter pushdown when struct-field predicates are used and the declared table schema differs from the physical file schema. The change ensures the runtime schema adaptation preserves a get_field(Column(..), ...) shape so Parquet’s row-filter builder can still recognize and apply the predicate that planning-time pushdown claimed would be handled.
Changes:
- Add a physical-expr rewrite that narrows
cast(struct)underget_fieldintocast(get_field(..)), preserving pushdown compatibility and avoiding casting whole structs unnecessarily. - Add unit tests covering flat, nested, and flattened multi-key
get_fieldpaths, missing-field-to-typed-null behavior, and Map behavior (no narrowing). - Add a SQL logic regression test reproducing issue #24109 and validating correct filtering for both schema-adapted and matching-schema reads.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| datafusion/physical-expr-adapter/src/schema_rewriter.rs | Introduces try_narrow_struct_cast + field-path resolution and adds focused unit tests for the new rewrite behavior. |
| datafusion/sqllogictest/test_files/parquet_filter_pushdown.slt | Adds an end-to-end SQL regression test to prevent the struct-field predicate from being silently dropped under schema adaptation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24125 +/- ##
==========================================
+ Coverage 80.85% 80.99% +0.14%
==========================================
Files 1099 1104 +5
Lines 374304 379123 +4819
Branches 374304 379123 +4819
==========================================
+ Hits 302642 307075 +4433
- Misses 53607 53825 +218
- Partials 18055 18223 +168 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…rowing Coverage analysis of the narrowing showed two reachable branches with no test behind them: a struct column that needs no adaptation at all, and one where only a sibling field forced the column-level cast, so the accessed field needs no cast of its own. Both matter — the first is the common case the rewrite must not disturb, the second is where the cast disappears rather than moving. The remaining uncovered branches in the function are guards against shapes that cannot reach it: a `get_field` with fewer than two arguments, and any key path running through a non-struct field, which the column-level cast validation rejects first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MebN5PsVnYvXUeVKju5K7P
Which issue does this PR close?
get_fieldpredicate when the file needs schema adaptation (wrong results) #24109.Rationale for this change
With
datafusion.execution.parquet.pushdown_filters = true, a filter on a struct field returns all rows when the declared table schema differs from the physical file schema for that column:The planning-time decision and the runtime construction disagree:
ParquetSource::try_pushdown_filtersevaluatescan_expr_be_pushed_down_with_schemasagainst the table schema.get_field(s, 'x')has a bare column under theget_field, so it reports the predicate as fully handled andFilterExecis removed from the plan.rewrite_columnwraps the whole column in a cast, givingget_field(cast(s AS Struct<x: Int64>), 'x').PushdownCheckeronly recognizesget_fieldwhose first argument is aColumn. It now sees aCastExpr, falls through to normal traversal, hits the structColumn, and rejects pushdown — so no row filter is built and the conjunct is silently dropped.Nothing applies the predicate, and the scan returns unfiltered rows.
What changes are included in this PR?
Narrow the cast to the field that is actually read, in
DefaultPhysicalExprAdapter:Expressions are rewritten bottom-up, so the new
try_narrow_struct_castmatches theget_fieldnode after its struct argument has already been wrapped, and rebuilds theget_fieldover the uncast struct (recomputing its return field from the physical field type) with the cast moved outside. This keeps the column visible under theget_field, so the Parquet row filter builder makes good on what planning promised.Two details worth calling out:
get_fieldon aMapcolumn is a runtime key lookup rather than a schema-level field access, so map values keep the whole-column cast.As a side effect this also avoids materializing an entire cast struct just to read one field, which is a small win for any struct-field access over an evolved schema — not only for filters.
Not addressed here
The issue also raises the broader concern that "a static determination made at planning time about what the scan can do, and the runtime construction that has to make good on it, are computed by different code against different schemas, and there is no mechanism forcing them to agree." This PR fixes the reported wrong-results bug; it does not add a mechanism (e.g. post-decode filtering in
ParquetOpener) that would make any future divergence safe by construction. That seems worth doing separately.Are these changes tested?
Yes.
datafusion/physical-expr-adapter/src/schema_rewriter.rs: unit tests for the narrowed cast (flat and nested field access), the missing-field null literal, and that Map columns keep their cast.datafusion/datasource-parquet/src/opener/mod.rs: end-to-end opener tests reading aStruct<x: Int32>file through aStruct<x: Int64>table schema with pushdown enabled, plus a matching-schema control.datafusion/sqllogictest/test_files/parquet_filter_pushdown.slt: a SQL-level regression test. Verified that it fails onmain(returns all 3 rows) and passes with the fix.Full runs:
cargo clippy --all-targets --all-features -- -D warnings, the complete sqllogictest suite (498 files),datafusion-physical-expr-adapter,datafusion-datasource-parquet, and thedatafusioncore_integration/parquet_integrationsuites all pass.Are there any user-facing changes?
A wrong-results bug fix: struct-field predicates are now applied when the scan needs schema adaptation. No public API changes.
Generated by Claude Code