Skip to content

fix: push filters above a pure extraction projection into the scan below it - #26281

Open
adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:fix/filter-into-scan-below-extraction-projection
Open

adriangb wants to merge 2 commits into
apache:mainfrom
adriangb:fix/filter-into-scan-below-extraction-projection

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Since #25455, a filter on a partition column can stop reaching the scan. Partition pruning is then lost, and the scan reads every file.

This occurs when a filter reaches a pure extraction projection (one with only __datafusion_extracted_N aliases and pass-through columns) after the optimizer pass that created the projection. PushDownFilter no longer moves a filter below such a projection, so the predicate never gets into TableScan::filters. A common way to get this shape is to put a filter on an already optimized plan and optimize it again. Query rewrites that reuse an optimized subtree do this.

Reproduction

Data:

COPY (SELECT * FROM (VALUES
  ('2025-01-01', named_struct('a', 1, 'b', 'x')),
  ('2025-01-02', named_struct('a', 2, 'b', 'y')),
  ('2025-01-03', named_struct('a', 3, 'b', 'z'))) AS t(day, s))
TO 'data/' STORED AS PARQUET PARTITIONED BY (day);

Program:

use datafusion::logical_expr::{LogicalPlanBuilder, col, lit};
use datafusion::prelude::*;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    let ctx = SessionContext::new();
    ctx.sql("CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION 'data/' PARTITIONED BY (day)")
        .await?;

    // The filter on a struct field keeps a pure extraction projection in the optimized plan.
    let optimized = ctx
        .sql("SELECT s['a'] AS a, day FROM t WHERE s['b'] <> 'q'")
        .await?
        .into_optimized_plan()?;

    // Put a filter on the optimized plan and optimize again.
    let filtered = LogicalPlanBuilder::from(optimized)
        .filter(col("day").eq(lit("2025-01-03")))?
        .build()?;
    let df = DataFrame::new(ctx.state(), filtered);
    println!("{}", df.clone().into_optimized_plan()?.display_indent());
    let physical = df.create_physical_plan().await?;
    println!("{}", datafusion::physical_plan::displayable(physical.as_ref()).indent(false));
    Ok(())
}

Before #25455, the predicate is in full_filters and the scan reads one file:

Projection: __datafusion_extracted_2 AS a, t.day
  Filter: __datafusion_extracted_1 != Utf8("q")
    Projection: get_field(t.s, Utf8("b")) AS __datafusion_extracted_1, t.day, get_field(t.s, Utf8("a")) AS __datafusion_extracted_2
      TableScan: t projection=[s, day], full_filters=[t.day = Dictionary(UInt16, Utf8("2025-01-03"))], partial_filters=[get_field(t.s, Utf8("b")) != Utf8("q")]

DataSourceExec: file_groups={1 group: [[data/day=2025-01-03/....parquet]]}, ...

On main, the predicate stays in the filter above the projection, and the scan reads all three files:

Projection: __datafusion_extracted_2 AS a, t.day
  Filter: __datafusion_extracted_1 != Utf8("q") AND t.day = Dictionary(UInt16, Utf8("2025-01-03"))
    Projection: get_field(t.s, Utf8("b")) AS __datafusion_extracted_1, t.day, get_field(t.s, Utf8("a")) AS __datafusion_extracted_2
      TableScan: t projection=[s, day], partial_filters=[get_field(t.s, Utf8("b")) != Utf8("q")]

DataSourceExec: file_groups={3 groups: [[data/day=2025-01-01/....parquet], [data/day=2025-01-02/....parquet], [data/day=2025-01-03/....parquet]]}, ...

With this PR, the logical plan and the file list are the same as before #25455.

What changes are included in this PR?

  • When PushDownFilter finds a pure extraction projection directly above a TableScan, it keeps the filter above the projection, as fix: decide the precedence between PushDownFilter and PushDownLeafProjections #25455 requires. But it offers the scan each predicate that reads only pass-through columns. The scan records the predicates that its source accepts. A predicate that the source applies exactly leaves the filter. Thus PushDownLeafProjections has nothing to undo, and a second pass changes nothing.
  • The TableScan arm of PushDownFilter moves into the function push_down_into_table_scan, so both paths use the same code. Its behaviour does not change.
  • The module documentation of push_down_filter and extract_leaf_expressions, and the "Rule Precedence" section of the query optimizer guide, no longer say that the earlier pass always sets TableScan::filters. They describe the new step.

No new public API and no new configuration option.

What is the testing strategy for this PR?

Unit tests in push_down_filter.rs:

  • filter_above_pure_extraction_projection_reaches_the_scan replaces filter_not_pushed_through_pure_extraction_projection. It shows that a predicate on a pass-through column reaches full_filters and leaves no filter node, with the projection still next to the scan.
  • inexact_predicate_above_pure_extraction_projection_reaches_the_scan shows that an inexact source records the predicate in partial_filters, and that the filter keeps it. A predicate on an extracted alias stays in the filter only.
  • pushdown_into_scan_below_extraction_projection_is_a_fixed_point applies the rule two times and shows that the second application changes nothing.
  • unsupported_predicate_stays_above_pure_extraction_projection shows that the plan does not change when the source does not support the predicate.

The existing filter_and_extraction_projection_reach_fixed_point test still passes.

Commands run:

  • cargo test -p datafusion-optimizer: all pass.
  • cargo test -p datafusion-sqllogictest --test sqllogictests: all files pass.
  • cargo clippy -p datafusion-optimizer --all-targets -- -D warnings: clean.

I could not reproduce the shape with one SQL statement, because in that case PushDownFilter runs before the extraction projection exists. Thus the tests are unit tests, not sqllogictest cases.

Are there any user-facing changes?

Yes. A filter that is put on an optimized plan reaches the scan again, so partition pruning and the provider's filter pushdown work again for such plans. No API changes.

adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 13, 2026
…low it

A filter that reaches a pure extraction projection after the pass that created
the projection never reached TableScan::filters, because PushDownFilter does not
move a filter below such a projection. Offer the predicates that read only
pass-through columns to the scan directly below the projection, without moving
the filter.

Carried from apache#26281 (open).
@github-actions github-actions Bot added documentation Improvements or additions to documentation optimizer Optimizer rules labels Oct 13, 2026
@codecov-commenter

codecov-commenter commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.94709% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.83%. Comparing base (e732212) to head (ade7bae).
⚠️ Report is 197 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/optimizer/src/push_down_filter.rs 89.94% 10 Missing and 9 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #26281    +/-   ##
========================================
  Coverage   82.83%   82.83%            
========================================
  Files        1151     1151            
  Lines      455332   455452   +120     
  Branches   455332   455452   +120     
========================================
+ Hits       377165   377270   +105     
- Misses      55262    55267     +5     
- Partials    22905    22915    +10     

☔ 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 added a commit to pydantic/datafusion that referenced this pull request Oct 14, 2026
…low it

A filter that reaches a pure extraction projection after the pass that created
the projection never reached TableScan::filters, because PushDownFilter does not
move a filter below such a projection. Offer the predicates that read only
pass-through columns to the scan directly below the projection, without moving
the filter.

Carried from apache#26281 (open).
@adriangb
adriangb force-pushed the fix/filter-into-scan-below-extraction-projection branch from 7705e95 to ade7bae Compare October 15, 2026 00:48
adriangb added a commit to pydantic/datafusion that referenced this pull request Oct 16, 2026
…low it

A filter that reaches a pure extraction projection after the pass that created
the projection never reached TableScan::filters, because PushDownFilter does not
move a filter below such a projection. Offer the predicates that read only
pass-through columns to the scan directly below the projection, without moving
the filter.

Carried from apache#26281 (open).
@adriangb
adriangb force-pushed the fix/filter-into-scan-below-extraction-projection branch from ade7bae to 7b6df75 Compare October 28, 2026 00:51
adriangb and others added 2 commits October 28, 2026 22:22
…low it

A filter that reaches a pure extraction projection after the pass that created
the projection never reached TableScan::filters, because PushDownFilter does not
move a filter below such a projection. Offer the predicates that read only
pass-through columns to the scan directly below the projection, without moving
the filter.
…ransformed in extraction-projection pushdown

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@kosiew kosiew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@adriangb,

Thanks for addressing the late-filter regression. Offering pass-through predicates directly to the scan restores source-level pruning without moving filters below pure extraction projections. The approach is appropriate, and the new tests cover the main exact, inexact, and fixed-point behaviors.

The architectural boundary is important: extraction projections must remain adjacent to scans for struct-leaf pruning, while eligible predicates must still reach the source. Reusing the scan's acceptance policy preserves that separation, provided pushdown also respects scan row-selection semantics.

I'm requesting changes because the new path can push predicates into scans with a positive offset but no fetch, changing which rows survive. The fetch guard needs to cover offsets as well. I've also noted two optional regression-coverage improvements.

};
// The fetch of a scan applies after its filters, so a filter above a scan
// with a fetch cannot move into it without changing the result.
if scan.fetch.is_some() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please guard positive scan.skip values as well as scan.fetch. For b=[0,10,20], Filter(b > 5) -> Projection -> Scan(skip=1, fetch=None) should return [10,20], but pushing even an inexact predicate before the skip can change the result to [20]. Add an offset-only regression using a source supporting exact filter and skip pushdown, and check the direct-scan path too. The direct-scan gap predates this PR, but the extraction-projection path newly exposes it.

};
// Keep the predicates in their original order, which `reorder_predicates`
// chose by cost.
let keep_predicates = predicates

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider adding a mixed-acceptance regression with Exact and Inexact or Unsupported pass-through conjuncts plus an extracted-alias conjunct. Verify that only Exact predicates leave the filter, accepted predicates reach the scan, residual order is preserved, and a second rewrite is unchanged. This is optional coverage for selective predicate reconciliation.

/// and the module documentation.
#[test]
fn filter_not_pushed_through_pure_extraction_projection() -> Result<()> {
fn filter_above_pure_extraction_projection_reaches_the_scan() -> Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider an end-to-end regression for optimizing a plan, appending a partition filter, and optimizing again. With three partition files, verify the returned rows, preserved extraction projection, and physical scan file groups containing only the matching partition. This would cover real get_field extraction and dictionary partition literals beyond the existing unit tests. Optional coverage improvement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation optimizer Optimizer rules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants