Conversation
…rules Take the expression list instead of a `LogicalPlan`, and make the function `pub(crate)`. `PushDownFilter` needs the same predicate in the next commit. No behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`PushDownFilter` and `PushDownLeafProjections` want the opposite order for an adjacent filter and pure extraction projection, so on `main` they undo each other on every optimizer pass and the rule that runs later decides the plan. Give the extraction projection precedence: `rewrite_projection` keeps every predicate above a pure extraction projection. The projection then stays next to the scan, so a Parquet scan merges it into the file projection and reads only the struct leaf. This is the property the leaf pushdown feature exists for, and it is lost with the opposite precedence whenever `datafusion.execution.parquet.pushdown_filters` is `false`, which is the default. The filter loses nothing. `PushDownFilter` runs before `ExtractLeafExpressions`, so it records the predicate in `TableScan::filters` in the first pass, before any extraction projection exists. Record the invariant in the module documentation of both rules and in the query optimizer guide. Two expected plans in `projection_pushdown.slt` change: - A `TableScan` loses a `Boolean(true)` entry from `partial_filters`. The entry was a no-op. - `CAST(character_length(...) AS Int64)` prints as `CAST(character_length(...) AS length(get_field(...)) AS Int64)`. The SQL planner writes `length(x)` as `character_length(x) AS length(x)`, and the coercion pass wraps that alias in the cast. `main` prints the same text for `SELECT a * 2 + length(b) AS score FROM t`, with no struct and no extraction. The alias disappeared from this test only because of the projection merge that the rule fight caused. The physical plan does not change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Unit tests in `push_down_filter.rs`: a filter stays above a pure extraction projection, and a projection that also computes an expression is still pushed through. A new section in `projection_pushdown.slt` with the query from apache#14540 on a memory table and on a Parquet file. The Parquet plan shows both properties the precedence must keep: `DataSourceExec` reads only the struct leaf `ids.id1`, and the `date` predicate reaches the scan for row group pruning. The section also pins the plan at a reduced `datafusion.optimizer.max_passes`. The simple shape gives the same plan at one pass as at the default. The issue shape gives the same plan at two passes, because the two filters merge in the second pass. Neither plan depends on the pass limit, so neither depends on which of the two rules runs last. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25455 +/- ##
==========================================
- Coverage 82.33% 82.33% -0.01%
==========================================
Files 1137 1137
Lines 432498 432542 +44
Branches 432498 432542 +44
==========================================
+ Hits 356115 356148 +33
- Misses 54844 54845 +1
- Partials 21539 21549 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
PushDownFilterandPushDownLeafProjectionsboth move nodes towards the leaves. For an adjacent filter and pure extraction projection they want the opposite order. A pure extraction projection is the nodeExtractLeafExpressionscreates: its expressions are only__datafusion_extracted_Naliases and pass-through columns.push_down_filterputs (A) below (B).push_down_leaf_projectionsputs (B) below (A). Onmainboth rules report a change in every optimizer pass, and the rule that runs later in the rule list decides the plan. Nobody had picked a side. The last comment on the issue asks which rule must yield.Experiment
Three variants were measured with
datafusion-cli, on the query from the issue and on the simplerSELECT ids['id1'] FROM t1 WHERE date = '2025-01-03', against a memory table and a Parquet file, withdatafusion.execution.parquet.pushdown_filtersset tofalse(the default) and totrue:push_down_leaf_projectionsruns last.PushDownLeafProjectionsdoes not move a pure extraction projection through a filter whose predicate does not reference the extracted aliases. The filter ends below the extraction projection.PushDownFiltertreats a pure extraction projection as non-pushable. The filter stays above it, and the leaf rule has nothing to undo.Two properties must both survive: the struct leaf must reach
DataSourceExec(projection=[get_field(...) ...]), and thedatepredicate must reachDataSourceExec(predicate=pluspruning_predicate=).pushdown_filtersprojection=[date, ids])projection=[date, timestamp, ids, structs])"passes" counts the invocations of
push_down_filter, that is the number of optimizer passes the plan needs.Decision
PushDownFilteryields. Pure extraction projections win. Variant (c).Variant (b) fails the goal. At the default
pushdown_filters = falsetheget_fieldno longer reachesDataSourceExec, so the scan reads the whole struct. The physicalProjectionPushdownrule cannot recover it.FilterExec::try_swapping_with_projectionrewrites the predicate against the projection output, and the predicate needs thedatecolumn, which the projection does not produce. TheProjectionExectherefore stays above theFilterExecand never reaches the scan. Onlypushdown_filters = truerecovers it, and that is not the default.Variants (a) and (c) keep both properties everywhere, so the tie breaks on the remaining differences. Variant (c) wins on three of them:
Filternodes and an extra pass-throughProjectionbetween them. Under (c) the two filters merge into one node and the pass-through projection is gone.date = '2025-01-03'before the fourget_fieldcomparisons.The filter loses nothing by staying one node higher.
PushDownFilterruns beforeExtractLeafExpressionsin the rule list, so it records the predicate inTableScan::filtersin the first pass, before any extraction projection exists. Row group pruning and source level filtering are unaffected, which the Parquet rows of the table show. On a source that cannot absorb the projection, such as a memory table, (c) keeps the filter above the extraction projection, soget_fieldruns before the filter. That is the ordermainproduces today (variant (a)), so it is not a regression, but it is not the better order for that source. Only variant (b) runs the filter first there, and (b) is rejected for the Parquet reason above. Residual risk: a filter that a rule creates afterPushDownFilterhas run in a pass stays above a pure extraction projection until the next pass. I could not build that shape from SQL. A test with aTableProviderthat answersExactis a follow-up, because that is the case where a filter node absorbed in pass 1 cannot be recovered later.What changes are included in this PR?
rewrite_projectioninpush_down_filter.rsreturns the filter unchanged when the projection is a pure extraction projection. This is the whole behaviour change.is_pure_extraction_projectioninextract_leaf_expressions.rsis nowpub(crate)and takes the expression list, so both rules use one predicate.docs/source/library-user-guide/query-optimizer.md.No new public API and no new configuration option.
What is the testing strategy for this PR?
push_down_filter.rs:filter_not_pushed_through_pure_extraction_projectionshows the pair does not move, andfilter_pushed_through_mixed_extraction_projectionshows a projection that also computes an expression is still pushed through.datafusion/sqllogictest/test_files/projection_pushdown.slt:EXPLAINplan;datafusion.optimizer.max_passes = 1as at the default. The push_down_filter and common_sub_expression_eliminate fight between them #14540 shape gives the same plan atmax_passes = 2as at the default. Two passes are needed there because the two filters merge in the second pass, which is real work and not a rule fight;DataSourceExecshows both theget_fieldleaf projection and thedatepredicate.Commands run:
cargo test --profile ci -p datafusion-optimizer-> 882 passed, 0 failed (lib), 26 passed (integration), 5 passed / 1 ignored (doctests).cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests-> 520 of 520 files pass.cargo clippy --profile ci -p datafusion-optimizer --all-targets -- -D warnings-> clean.RUSTDOCFLAGS="-D warnings" cargo doc --profile ci -p datafusion-optimizer --no-deps-> clean.--workspace --lib --tests --binswithavro,json,backtrace,extended_tests,recursive_protection,parquet_encryption, withoutdatafusion-examples,datafusion-benchmarks,datafusion-clianddatafusion-sqllogictest) -> 68 suites, 11910 passed, 0 failed, 8 ignored.Changed expectations
Two lines in
projection_pushdown.sltchange. Both are in queries that the fight used to rewrite.EXPLAIN SELECT s['value'] * 2 + length(s['label']) as score FROM simple_struct WHERE id > 1;. The plan shape does not change. The alias inside the cast is no longer stripped:CAST(character_length(...) AS Int64)becomesCAST(character_length(...) AS length(get_field(simple_struct.s, Utf8("label"))) AS Int64). The SQL planner writeslength(x)ascharacter_length(x) AS length(x), and the coercion pass wraps that alias in the cast. Onmainthis alias is visible too, with no struct and no extraction:EXPLAIN SELECT a * 2 + length(b) AS score FROM tt;printsCAST(character_length(tt.b) AS length(tt.b) AS Int64). It only disappeared in this test because the extra projection merge that the rule fight caused removed it. The physical plan is identical.TableScanloses aBoolean(true)entry frompartial_filters. The entry was a no-op.Are there any user-facing changes?
The logical plan of a query that reads a struct field and filters on another column changes shape. The filter node now sits above the extraction projection instead of below it. Results do not change, and the physical plan keeps both the leaf projection and the scan predicate.
Part of the leaf-pushdown EPIC: #25459
🤖 Generated with Claude Code