Skip to content

fix: one inlining policy for the rules that inline a projection column - #25456

Draft
adriangb wants to merge 13 commits into
apache:mainfrom
pydantic:feat/unified-inlining-policy
Draft

adriangb wants to merge 13 commits into
apache:mainfrom
pydantic:feat/unified-inlining-policy

Conversation

@adriangb

@adriangb adriangb commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A function that keeps its place, for example a struct-returning UDF, is evaluated two times when the query hoists it into a common sub-expression and then reads one field of it inside a filter.

MRE:

CREATE TABLE t(a INT) AS VALUES (1), (2);
EXPLAIN SELECT a FROM t WHERE CASE WHEN arrow_field(a) IS NOT NULL THEN arrow_field(a)['nullable'] IS NULL END;

Before, on main. arrow_field(a) is in the plan two times, so it runs two times for each row.

logical_plan
01)Projection: t.a
02)--Filter: CASE WHEN __common_expr_7 IS NOT NULL THEN __datafusion_extracted_8 IS NULL END
03)----Projection: arrow_field(t.a) AS __common_expr_7, t.a, get_field(arrow_field(t.a), Utf8("nullable")) AS __datafusion_extracted_8
04)------TableScan: t projection=[a]
physical_plan
01)FilterExec: CASE WHEN __common_expr_7@0 IS NOT NULL THEN __datafusion_extracted_8@2 IS NULL END, projection=[a@1]
02)--ProjectionExec: expr=[arrow_field(a@0) as __common_expr_7, a@0 as a, get_field(arrow_field(a@0), nullable) as __datafusion_extracted_8]
03)----DataSourceExec: partitions=1, partition_sizes=[1]

After, with this PR. arrow_field(a) is in the plan one time:

logical_plan
01)Projection: t.a
02)--Filter: CASE WHEN __common_expr_1 IS NOT NULL THEN get_field(__common_expr_1, Utf8("nullable")) IS NULL END
03)----Projection: arrow_field(t.a) AS __common_expr_1, t.a
04)------TableScan: t projection=[a]
physical_plan
01)FilterExec: CASE WHEN __common_expr_1@0 IS NOT NULL THEN get_field(__common_expr_1@0, nullable) IS NULL END, projection=[a@1]
02)--ProjectionExec: expr=[arrow_field(a@0) as __common_expr_1, a@0 as a]
03)----DataSourceExec: partitions=1, partition_sizes=[1]

The cause: ExtractLeafExpressions merges get_field(__common_expr_1, 'nullable') into the projection that CommonSubexprEliminate created, so it inlines arrow_field(a) a second time.

What changes are included in this PR?

A new crate-private helper, projection_inliner.rs. The leaf rules and the OptimizeProjections merge use it to decide whether they may replace a column with its definition. It has one rule:

  • A volatile definition is never evaluated at a second site. This is checked before placement, because a volatile function such as file_row_index() can report MoveTowardsLeafNodes.
  • Otherwise, a definition may be evaluated more than one time only if it is a column or a leaf expression (placement().should_push_to_leaves()). This is the existing merge guard of OptimizeProjections (Conflicting optimization rules: common_sub_expression_eliminate and push_down_projection #8296), now shared.

DataFusion has no cost model, so the helper does not invent one. KeepInPlace is not read as "expensive". An expression that is not a column or a leaf expression is simply not duplicated by these rules. PushDownFilter is unchanged: it still pushes any non-volatile predicate, because a predicate that reaches the scan can prune data.

Plan changes, compared with main:

  • ExtractLeafExpressions no longer merges into a projection when that would duplicate a definition that is not a leaf expression. This is the CommonSubexprEliminate + leaf-expression pushdown duplicate a UDF call wrapped in get_field #25329 fix.
  • ExtractLeafExpressions used to skip the whole node when one expression referenced a volatile column. It now extracts the other expressions and leaves the volatile column alone (test_extraction_next_to_a_volatile_column).
  • PushDownLeafProjections no longer duplicates a literal definition. On main it pinned only KeepInPlace definitions.
  • file_row_index() used two times is no longer inlined (file_row_index.slt).

Not in this PR: PushDownFilter still inlines a CSE column and CommonSubexprEliminate hoists it again on every pass. The final plan evaluates arrow_field(a) one time, but the alias is renumbered. That convergence issue is fixed by #26872.

What is the testing strategy for this PR?

Are there any user-facing changes?

No API change. The plan changes are listed above.

Co-authored with @pepijnve (#25388).

🤖 Generated with Claude Code

@codecov-commenter

codecov-commenter commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.80702% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.65%. Comparing base (1b7f403) to head (73ac428).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...tafusion/optimizer/src/extract_leaf_expressions.rs 88.83% 11 Missing and 12 partials ⚠️
datafusion/optimizer/src/push_down_filter.rs 78.08% 2 Missing and 14 partials ⚠️
datafusion/optimizer/src/projection_inliner.rs 99.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #25456    +/-   ##
========================================
  Coverage   82.64%   82.65%            
========================================
  Files        1147     1148     +1     
  Lines      445684   446245   +561     
  Branches   445684   446245   +561     
========================================
+ Hits       368349   368841   +492     
- Misses      54976    54998    +22     
- Partials    22359    22406    +47     

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

@pepijnve

Copy link
Copy Markdown
Contributor

I haven't gone through the code changes in detail yet, but the description sounds like a good way forward. Implementing the assessment once rather than trying to patch things ad hoc per optimiser is the way to go.

An open question I still had related to keep-in-place was if this should be refined a bit. On #25388 I tweaked Expr::placement so that for simple expressions (unary and binary ops mostly) it is based on the operands placement rather than being keep-in-place. Is that something we would want to take along here? Or should I make a separate PR out of that?

///
/// The variants are in increasing order of cost.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum DuplicationCost {

@pepijnve pepijnve Sep 18, 2026 •

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.

This enumeration and the way DuplicationCost::of calculates it seems to overlap largely with the existing expression placement enumeration and Expr::placement. Are those two trying to express the same thing or is there a fundamental semantic difference?

lyne7-sc pushed a commit to lyne7-sc/datafusion that referenced this pull request Oct 1, 2026
…ards (apache#25452)

## Which issue does this PR close?

- Part of apache#25451.

This PR closes 5 of the 14 real test gaps that the mutation run found.
It does not close the issue.

## Rationale for this change

`extract_leaf_expressions.rs` holds several guards that stop a rewrite
in one specific case. Each guard went in with a bug fix, and each fix
came with a plan snapshot test. A plan snapshot test asserts the shape
of one output plan. It does not assert that the guard makes its decision
for the reason the guard documents.

A `cargo-mutants` run over the file shows the result. Three guards let a
mutation through with no test failure:

| Function | Mutation that survived | Effect if this happened for real |
|---|---|---|
| `passthrough_column` | guard `col.name == alias.name` to `true` | A
renaming alias (`test.a AS b`) counts as a pass-through column. The
recovery projection that restores the name is then dropped, and the
rename is lost. |
| `is_pure_extraction_projection` | body to `true`, and the
`EXTRACTED_EXPR_PREFIX` guard to `true` | A projection that holds a
`__common_expr_1` alias is pushed down a second time. The expression is
re-extracted under a new alias that the parent cannot resolve. |
| `merge_would_duplicate_kept_expr` | `>` to `>=` | The guard fires on a
single reference site. Every merge into a child projection is then
blocked, and leaf extraction stops working over a projection. |
| `merge_would_duplicate_kept_expr` | `&&` to `\|\|` | Plain columns
count as reference sites. A merge that touches only plain columns is
blocked, although a plain column is cheap to duplicate. |

Each of these is a silent behaviour change. The suite stays green.

## What changes are included in this PR?

Three unit tests, one per function, in the existing `tests` module of
`datafusion/optimizer/src/extract_leaf_expressions.rs`. Each test calls
the guard directly and asserts both answers: the case where the guard
must fire, and the case where it must not. The file already uses this
style for `find_owning_input`.

- `test_passthrough_column_rejects_renaming_alias` covers a bare column,
a trivial rename that keeps the name, a rename that changes the name,
and an alias over a non-column expression.
- `test_is_pure_extraction_projection` covers a plan that is not a
projection, a columns-only projection, an extraction projection, a
projection that uses a `CommonSubexprEliminate` alias, and a projection
with a bare expression.
- `test_merge_would_duplicate_kept_expr_counts_only_kept_columns` covers
one reference site to a `KeepInPlace` column, two sites, one site plus a
pass-through reference, and two sites that reference a plain column
only. The pass-through case is the one that
apache#23655 reported.

The PR adds a small `keep_in_place_udf` test helper. It gives a
non-volatile `KeepInPlace` expression, which the existing `volatile_udf`
helper cannot.

There is no change to product code.

## What is the testing strategy for this PR?

This PR is tests only.

`cargo test --profile ci -p datafusion-optimizer` goes from 882 to 885
library tests. The 26 integration tests and 5 documentation tests are
unchanged. All pass.

To show that the new tests close the gaps, re-run `cargo-mutants` on the
three functions:

```bash
cargo mutants -p datafusion-optimizer \
  -f 'datafusion/optimizer/src/extract_leaf_expressions.rs' \
  -F 'passthrough_column|is_pure_extraction_projection|merge_would_duplicate_kept_expr' \
  --profile ci --timeout 600 --jobs 2
```

| | Before | After |
|---|---|---|
| Caught | 16 | 21 |
| Missed | 5 | 0 |
| Unviable | 1 | 1 |

## Are there any user-facing changes?

No.

Note: the test for `merge_would_duplicate_kept_expr` becomes dead when
apache#25456 lands, because that PR
deletes the function. Whichever merges second drops that one test; the
other two tests apply unchanged.

Part of the leaf-pushdown EPIC:
apache#25459

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
@adriangb
adriangb force-pushed the feat/unified-inlining-policy branch from 67f541c to 73ac428 Compare October 3, 2026 20:50
Omega359 pushed a commit to Omega359/arrow-datafusion that referenced this pull request Oct 11, 2026
…ards (apache#25452)

## Which issue does this PR close?

- Part of apache#25451.

This PR closes 5 of the 14 real test gaps that the mutation run found.
It does not close the issue.

## Rationale for this change

`extract_leaf_expressions.rs` holds several guards that stop a rewrite
in one specific case. Each guard went in with a bug fix, and each fix
came with a plan snapshot test. A plan snapshot test asserts the shape
of one output plan. It does not assert that the guard makes its decision
for the reason the guard documents.

A `cargo-mutants` run over the file shows the result. Three guards let a
mutation through with no test failure:

| Function | Mutation that survived | Effect if this happened for real |
|---|---|---|
| `passthrough_column` | guard `col.name == alias.name` to `true` | A
renaming alias (`test.a AS b`) counts as a pass-through column. The
recovery projection that restores the name is then dropped, and the
rename is lost. |
| `is_pure_extraction_projection` | body to `true`, and the
`EXTRACTED_EXPR_PREFIX` guard to `true` | A projection that holds a
`__common_expr_1` alias is pushed down a second time. The expression is
re-extracted under a new alias that the parent cannot resolve. |
| `merge_would_duplicate_kept_expr` | `>` to `>=` | The guard fires on a
single reference site. Every merge into a child projection is then
blocked, and leaf extraction stops working over a projection. |
| `merge_would_duplicate_kept_expr` | `&&` to `\|\|` | Plain columns
count as reference sites. A merge that touches only plain columns is
blocked, although a plain column is cheap to duplicate. |

Each of these is a silent behaviour change. The suite stays green.

## What changes are included in this PR?

Three unit tests, one per function, in the existing `tests` module of
`datafusion/optimizer/src/extract_leaf_expressions.rs`. Each test calls
the guard directly and asserts both answers: the case where the guard
must fire, and the case where it must not. The file already uses this
style for `find_owning_input`.

- `test_passthrough_column_rejects_renaming_alias` covers a bare column,
a trivial rename that keeps the name, a rename that changes the name,
and an alias over a non-column expression.
- `test_is_pure_extraction_projection` covers a plan that is not a
projection, a columns-only projection, an extraction projection, a
projection that uses a `CommonSubexprEliminate` alias, and a projection
with a bare expression.
- `test_merge_would_duplicate_kept_expr_counts_only_kept_columns` covers
one reference site to a `KeepInPlace` column, two sites, one site plus a
pass-through reference, and two sites that reference a plain column
only. The pass-through case is the one that
apache#23655 reported.

The PR adds a small `keep_in_place_udf` test helper. It gives a
non-volatile `KeepInPlace` expression, which the existing `volatile_udf`
helper cannot.

There is no change to product code.

## What is the testing strategy for this PR?

This PR is tests only.

`cargo test --profile ci -p datafusion-optimizer` goes from 882 to 885
library tests. The 26 integration tests and 5 documentation tests are
unchanged. All pass.

To show that the new tests close the gaps, re-run `cargo-mutants` on the
three functions:

```bash
cargo mutants -p datafusion-optimizer \
  -f 'datafusion/optimizer/src/extract_leaf_expressions.rs' \
  -F 'passthrough_column|is_pure_extraction_projection|merge_would_duplicate_kept_expr' \
  --profile ci --timeout 600 --jobs 2
```

| | Before | After |
|---|---|---|
| Caught | 16 | 21 |
| Missed | 5 | 0 |
| Unviable | 1 | 1 |

## Are there any user-facing changes?

No.

Note: the test for `merge_would_duplicate_kept_expr` becomes dead when
apache#25456 lands, because that PR
deletes the function. Whichever merges second drops that one test; the
other two tests apply unchanged.

Part of the leaf-pushdown EPIC:
apache#25459

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
joroKr21 pushed a commit to coralogix/arrow-datafusion that referenced this pull request Oct 19, 2026
…25478)

## Which issue does this PR close?

- Closes apache#25446.

Part of the leaf-pushdown EPIC:
apache#25459

## Rationale for this change

A valid statement fails to plan. A sub-query projection renames a column
to the name of a different column of the same input, and the query reads
a struct field above it.
`datafusion.optimizer.enable_leaf_expression_pushdown` is `true` by
default, so this is a plain planning failure.

```sql
create table t(a int, b int, s struct<x varchar>) as values (1, 10, {x: 'p'}), (2, 20, {x: 'q'});

-- 1. a rename beside a same-name alias, under a filter
select b, a, s['x'] from (select t.a as b, t.b as a, s from t) where a > 0;

-- 2. the same shape under a limit
select b, a, s['x'] from (select t.a as b, t.b as a, s from t) limit 10;

-- 3. a swap of two column names
select a, c, s['x'] from (select t.b as a, t.a as c, s from t) where a > 0;
```

Observed on `main`, for all three:

```text
Optimizer rule 'push_down_leaf_projections' failed
caused by
Schema error: Schema contains qualified field name t.a and unqualified field name a which would be ambiguous
```

Expected, and what you get with `set
datafusion.optimizer.enable_leaf_expression_pushdown = false;`:

```text
+---+----+--------+        +----+---+--------+
| b | a  | t.s[x] |        | a  | c | t.s[x] |
+---+----+--------+        +----+---+--------+
| 1 | 10 | p      |        | 10 | 1 | p      |
| 2 | 20 | q      |        | 20 | 2 | q      |
+---+----+--------+        +----+---+--------+
   statements 1 and 2         statement 3
```

`order by` and `group by` above the rename fail the same way. Query
generators emit this shape.

## What changes are included in this PR?

Both changes are in
`datafusion/optimizer/src/extract_leaf_expressions.rs`.

**1. The merge of the extraction projection.**
`build_extraction_projection_impl` merges the extraction projection into
the projection below it. For every column the parent needs, it resolved
the name through the projection's rename map and then added the *input*
column that the name resolves to. For `select t.a as b, t.b as a, s from
t` the parent's `b` resolves to `t.a`, and `t.a` lands beside the output
field `a` of the other rename. `Projection::try_new` rejects that
schema.

The merge keeps every expression of the projection below it, so the
parent can still read each name that projection produces. Those names
are now skipped instead of resolved, and a pass-through column whose
name is one of the projection's output names is never added. A rename is
a computed output, and the parent refers to it by its output name only.

**2. The recovery check.** With the merge fixed, the shape reaches
`split_and_push_projection`, which decided if it needs a recovery
projection from the set of unqualified field names alone. Equal name
sets do not prove equal values: the pushed plan exposes the table column
`t.a` where the projection computed `t.a AS b`. Without this hunk the
statements above return the columns swapped instead of an error. The
recovery projection now also stays when a recovery expression is not a
pass-through of a column.

That second hunk is the same change as
apache#25445, down to the text, so
the two merge without a conflict. If that PR lands first, the hunk falls
out of the rebase.

**How this composes with the other open PRs on this function.**
apache#25412 resolves both sides of
the pass-through comparison against the input schema, and
apache#25456 replaces the rename map
with `ProjectionInliner`. This change sits above both: it decides which
columns to add before either one resolves a name. I merged this branch
into a local branch that holds both PRs. The single conflict is the loop
header, which takes the guards of this PR around the body of the other
two. The optimizer tests, the `struct`, `cse` and `push_down_filter`
sqllogictest files, and the differential fuzz all pass there with the
swap-alias shape of the fuzz generator turned on: 250 short cases, 125
short Parquet cases, 5000 extended cases and 1000 extended Parquet
cases, 0 failures and 0 skips.

## What is the testing strategy for this PR?

- `datafusion/sqllogictest/test_files/struct.slt`: the three statements
above, plus the `order by` and `group by` variants, plus a swap where
the struct field name is also a column name of the table, plus one
`EXPLAIN` that shows the renames above the extraction projection and the
struct field still read at the scan.
- `datafusion/optimizer/src/extract_leaf_expressions.rs`:
`test_extract_above_projection_that_swaps_column_names` covers the
ambiguous schema.
`test_extract_above_projection_that_redefines_column_name` covers the
recovery projection that must keep a rename alive.
- Three plan snapshots of neighbouring tests lose a duplicate
pass-through column in the intermediate stage. The optimized plans of
those three tests do not change.

Commands and results:

```text
cargo test --profile ci -p datafusion-optimizer
  884, 26, 5 passed; 0 failed

cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests
  520/520 files completed, 0 failures

cargo clippy --profile ci -p datafusion-optimizer --all-targets -- -D warnings
  clean
```

## Are there any user-facing changes?

Statements of this shape plan and run. There is no API change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
@github-actions
github-actions Bot marked this pull request as draft October 26, 2026 01:27
@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.

@adriangb
adriangb force-pushed the feat/unified-inlining-policy branch from 73ac428 to 0fb2e8a Compare October 28, 2026 00:53
adriangb and others added 9 commits October 28, 2026 22:22
… a projection column

Several rules replace a column reference with the expression that the
projection below defines for it. Each rule had its own guard against the
duplication that this can cause.

Add a crate-private `ProjectionInliner`. It holds the decision in one
place: it classifies each definition of a projection by `DuplicationCost`
(free, cheap, expensive, forbidden), counts the evaluations that the plan
has after the rewrite, and reports the definitions that must stay where
they are.

Route the `OptimizeProjections` merge guard through it. The classification
keeps the `should_push_to_leaves` split of that guard, so the plans do not
change. See apache#8296

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…jection column

`PushDownFilter` inlined every non-volatile definition of a projection
into the pushed predicates. When the predicates reference an expensive
definition more than one time, for example a column that
`CommonSubexprEliminate` made for a struct-returning UDF, the push-down
evaluates that expression more than one time. `CommonSubexprEliminate`
then makes the projection again below the filter, so the two rules never
converge.

Route the rewrite through `ProjectionInliner` and delete the local
`contain` helper and the placement partition. A predicate stays above the
projection when it references a volatile definition, a
`MoveTowardsLeafNodes` definition (as before), or an expensive definition
that the predicates reference more than one time. A predicate with one
reference is still pushed.

Part of apache#25329

Co-Authored-By: Claude Fable 5.1 <[email protected]>
`ExtractLeafExpressions` and `PushDownLeafProjections` merge an extracted
field access into an input projection, and resolve its column references
to the definitions of that projection. When `CommonSubexprEliminate` made
the column for a struct-returning UDF, the merge puts a copy of the UDF
call next to the original, so the plan evaluates it two times:

    Filter: CASE WHEN __common_expr_1 IS NOT NULL THEN __datafusion_extracted_2 IS NULL END
      Projection: arrow_field(t.a) AS __common_expr_1, t.a,
                  get_field(arrow_field(t.a), 'nullable') AS __datafusion_extracted_2

Delete `would_duplicate_volatile`, `merge_would_duplicate_kept_expr` and
`build_projection_replace_map`, and use `ProjectionInliner` at every merge
site. The inliner counts the evaluations over the extracted expressions
and over the columns that the nodes above reference directly, so it also
sees the reference sites that `merge_would_duplicate_kept_expr` could not
see: they are in a different node.

Closes apache#25329

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Add the two end-to-end shapes of the issue: the common sub-expression is
referenced bare and through a field access, in a projection and in a
filter. Both plans must show `arrow_field(a)` one time.

Covers apache#23655 and
apache#25329

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…Cost`

`DuplicationCost::of` returned `Free` as soon as the expression reported a
placement of `MoveTowardsLeafNodes`, before it walked the expression for
volatile nodes. A volatile function is free to report that placement, and
`file_row_index()` does. Such a definition was classed `Free` and the rules
inlined it into two sites.

Volatility and subqueries now win over placement. `file_row_index.slt` keeps
the extraction column and evaluates the call one time, which the new `EXPLAIN`
there pins.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The name said the opposite of the doc comment. The method is for a projection
that can stay in the plan for consumers that the caller can not see, so the
consumers are the unknown ones.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Pass `direct_columns` and unwrap the `Option` in the upstream test
`test_merge_appends_qualified_column_for_bare_request`. The merge now
resolves the bare `a` of `test_merge_bare_column_into_qualified_projection`
to its definition `test.a`. `file_row_index.slt` picks up the nullability
display of apache#26359, and the projection now keeps the two spelled calls,
because `ExtractLeafExpressions` leaves a projection unchanged when its
extraction projection cannot move below the input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The EXPLAIN test spells `file_row_index()` two times in the query, so it
does not check that a rule never copies a volatile definition. Add a query
that defines it one time in a subquery and references it from a filter and
the outer projection. The plans must show one call, and the results must
agree with that one value.

The nested-projection test shape in this PR comes from
apache#25388.

Co-authored-by: Pepijn Van Eeckhoudt <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- `DuplicationCost::of`: a volatile node of any kind is forbidden, not
  only a volatile scalar function. A higher-order function is expensive.
- `rewrite_projection`: count references only over the predicates that
  can be pushed. A kept predicate reads the computed column.
- Compute a definition's cost only when a check needs it.
- Remove a dead lookup in `extract_leaf_expressions` and a duplicate
  `unalias`.
- `cse.slt`: the data query now matches the EXPLAIN query.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Comment on lines +51 to +56
/// Contains a scalar function that reports
/// [`ExpressionPlacement::KeepInPlace`] for column arguments, or a
/// higher-order function. This is the default for a user-defined
/// function, so the optimizer must assume that it can be arbitrarily
/// expensive.
Expensive,

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.

This is again creating a mess of interpret KeepInPlace as expensive which is not clear we want to do, as already pointed out in https://github.com/apache/datafusion/pull/25456/changes#r4045318003

adriangb and others added 3 commits October 29, 2026 21:47
DataFusion has no cost model, so the inliner does not derive one from
ExpressionPlacement. A volatile call or a subquery is never evaluated at a
second site. Otherwise each rule keeps its choice from main:
OptimizeProjections and the leaf rules duplicate only leaf expressions,
PushDownFilter duplicates any expression.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review follow-up. PushDownFilter is unchanged from main. The inliner has
one rule: a volatile definition is never duplicated, and only a column or
a leaf expression is evaluated more than one time. Stale cost-model text
removed; a test for extraction next to a volatile column added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 30, 2026
# Conflicts:
#	datafusion/optimizer/src/push_down_filter.rs
adriangb added a commit to adriangb/datafusion that referenced this pull request Oct 30, 2026
… PushDownFilter

Rebuilt on the new apache#25456, where PushDownFilter is unchanged from main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
shehabgamin pushed a commit to lakehq/datafusion that referenced this pull request Nov 8, 2026
…he#27152)

## Which issue does this PR close?

- Part of apache#27131.

## Rationale for this change

A `MoveTowardsLeafNodes` expression loses leaf pushdown as soon as it
shares a
projection with a `KeepInPlace` expression and a `RepartitionExec` is
inserted.
The two symptom classes are:

```sql
-- errors: "file_row_index() is source dependent and cannot be evaluated directly"
SELECT file_row_index(), id + 1 FROM t;

-- no error, but reads the whole struct instead of pruning to one field
SELECT s['value'], id + 1 FROM t;
```

`ProjectionExec::benefits_from_input_partitioning` is true as soon as
*any*
expression in the projection is `KeepInPlace` (the default placement).
That one
flag does two opposing things:

- `EnsureRequirements` inserts a `RepartitionExec` beneath the
projection, and
- `RepartitionExec::try_swapping_with_projection` then refuses to let
that same
  projection descend past it.

So the leaf expression can never reach the source. The leaf call does
not have to
be wrapped in anything — an unrelated computed column in the same
projection is
enough, because the flag is a property of the whole projection, not of
one
expression. `get_field` is the headline case: nested column pruning is
its
documented purpose and it degrades silently.

The trigger needs a `RepartitionExec`, so the table has to exceed
`batch_size`
(`EnsureRequirements` uses `batch_size` as a row-count threshold). That
is why
this has not shown up in the existing small-fixture tests.

## What changes are included in this PR?

`ProjectionPushdown` now splits such a projection instead of giving up:

- the leaf expressions move into a lower projection that the source
absorbs;
- the remaining computation stays above the repartition, where it is
still
  parallelised.

Both guards keep their intent rather than one being traded for the
other: the
scan only gains the cheap leaf expression, and the expensive computation
is still
run in parallel.

Deliberate limits:

- The split is committed only when `child.try_swapping_with_projection`
actually
returns `Some` for the lower projection, i.e. when the source really
does
  absorb it. Otherwise the plan is left untouched.
- Only `Partitioning::RoundRobinBatch` is handled. Hash and range
partitioning
reference columns by index, so changing the schema below them would
require
  remapping their expressions; that is left out of this PR.
- Only the columns the outer expressions still reference outside the
extracted
leaves are passed through, so `s['a'] + 1` does not end up reading the
whole
  struct alongside the extracted field.

## What is the testing strategy for this PR?

- `datafusion/sqllogictest/test_files/file_row_index.slt` — value-based
regression tests for the error class: bare marker plus a computed
sibling,
`+ 1`, `arrow_typeof`, two different markers mixed, a duplicated marker,
and
  non-regression for `Filter` / `Sort` / `Aggregate`.
- `datafusion/sqllogictest/test_files/projection_pushdown.slt` — plan
assertions
for the `get_field` class. Value comparison can never catch that one:
without
pruning the results are still correct, only more data is read, so the
test has
  to assert the pruned `projection=[...]` in the plan.
- `datafusion/physical-optimizer` unit tests: 50 passed.

Two traps are called out in comments in both test files, because either
one makes
the tests pass against unfixed code:

1. the fixture must exceed `batch_size`, or no `RepartitionExec` is
inserted and
   there is nothing to reproduce;
2. a bare `LIMIT n` is pushed into the scan and hides the bug again.

Verified by reverting the source file with `git checkout main --
<file>`: the new
tests then fail with 6 errors and 16 plan-diff lines.

## Are there any user-facing changes?

No API changes. Queries that previously failed with "is source dependent
and
cannot be evaluated directly" now succeed, and `get_field` regains
nested column
pruning when it shares a projection with other expressions.

One plan-shape note for apache#25456 (inlining projection columns), which
touches
adjacent code: its test pins `file_row_index()` used twice as *not*
inlined. When
the split here fires, the two identical leaf calls are de-duplicated
into a
single lower-projection column. Values are unaffected; only the plan
shape
differs. Happy to rebase in whichever order is convenient.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <[email protected]>
cetra3 pushed a commit to pydantic/datafusion that referenced this pull request Nov 9, 2026
…25478)

## Which issue does this PR close?

- Closes apache#25446.

Part of the leaf-pushdown EPIC:
apache#25459

## Rationale for this change

A valid statement fails to plan. A sub-query projection renames a column
to the name of a different column of the same input, and the query reads
a struct field above it.
`datafusion.optimizer.enable_leaf_expression_pushdown` is `true` by
default, so this is a plain planning failure.

```sql
create table t(a int, b int, s struct<x varchar>) as values (1, 10, {x: 'p'}), (2, 20, {x: 'q'});

-- 1. a rename beside a same-name alias, under a filter
select b, a, s['x'] from (select t.a as b, t.b as a, s from t) where a > 0;

-- 2. the same shape under a limit
select b, a, s['x'] from (select t.a as b, t.b as a, s from t) limit 10;

-- 3. a swap of two column names
select a, c, s['x'] from (select t.b as a, t.a as c, s from t) where a > 0;
```

Observed on `main`, for all three:

```text
Optimizer rule 'push_down_leaf_projections' failed
caused by
Schema error: Schema contains qualified field name t.a and unqualified field name a which would be ambiguous
```

Expected, and what you get with `set
datafusion.optimizer.enable_leaf_expression_pushdown = false;`:

```text
+---+----+--------+        +----+---+--------+
| b | a  | t.s[x] |        | a  | c | t.s[x] |
+---+----+--------+        +----+---+--------+
| 1 | 10 | p      |        | 10 | 1 | p      |
| 2 | 20 | q      |        | 20 | 2 | q      |
+---+----+--------+        +----+---+--------+
   statements 1 and 2         statement 3
```

`order by` and `group by` above the rename fail the same way. Query
generators emit this shape.

## What changes are included in this PR?

Both changes are in
`datafusion/optimizer/src/extract_leaf_expressions.rs`.

**1. The merge of the extraction projection.**
`build_extraction_projection_impl` merges the extraction projection into
the projection below it. For every column the parent needs, it resolved
the name through the projection's rename map and then added the *input*
column that the name resolves to. For `select t.a as b, t.b as a, s from
t` the parent's `b` resolves to `t.a`, and `t.a` lands beside the output
field `a` of the other rename. `Projection::try_new` rejects that
schema.

The merge keeps every expression of the projection below it, so the
parent can still read each name that projection produces. Those names
are now skipped instead of resolved, and a pass-through column whose
name is one of the projection's output names is never added. A rename is
a computed output, and the parent refers to it by its output name only.

**2. The recovery check.** With the merge fixed, the shape reaches
`split_and_push_projection`, which decided if it needs a recovery
projection from the set of unqualified field names alone. Equal name
sets do not prove equal values: the pushed plan exposes the table column
`t.a` where the projection computed `t.a AS b`. Without this hunk the
statements above return the columns swapped instead of an error. The
recovery projection now also stays when a recovery expression is not a
pass-through of a column.

That second hunk is the same change as
apache#25445, down to the text, so
the two merge without a conflict. If that PR lands first, the hunk falls
out of the rebase.

**How this composes with the other open PRs on this function.**
apache#25412 resolves both sides of
the pass-through comparison against the input schema, and
apache#25456 replaces the rename map
with `ProjectionInliner`. This change sits above both: it decides which
columns to add before either one resolves a name. I merged this branch
into a local branch that holds both PRs. The single conflict is the loop
header, which takes the guards of this PR around the body of the other
two. The optimizer tests, the `struct`, `cse` and `push_down_filter`
sqllogictest files, and the differential fuzz all pass there with the
swap-alias shape of the fuzz generator turned on: 250 short cases, 125
short Parquet cases, 5000 extended cases and 1000 extended Parquet
cases, 0 failures and 0 skips.

## What is the testing strategy for this PR?

- `datafusion/sqllogictest/test_files/struct.slt`: the three statements
above, plus the `order by` and `group by` variants, plus a swap where
the struct field name is also a column name of the table, plus one
`EXPLAIN` that shows the renames above the extraction projection and the
struct field still read at the scan.
- `datafusion/optimizer/src/extract_leaf_expressions.rs`:
`test_extract_above_projection_that_swaps_column_names` covers the
ambiguous schema.
`test_extract_above_projection_that_redefines_column_name` covers the
recovery projection that must keep a rename alive.
- Three plan snapshots of neighbouring tests lose a duplicate
pass-through column in the intermediate stage. The optimized plans of
those three tests do not change.

Commands and results:

```text
cargo test --profile ci -p datafusion-optimizer
  884, 26, 5 passed; 0 failed

cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests
  520/520 files completed, 0 failures

cargo clippy --profile ci -p datafusion-optimizer --all-targets -- -D warnings
  clean
```

## Are there any user-facing changes?

Statements of this shape plan and run. There is no API change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>

Backport of apache#25478 (merged upstream as 233aeab).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CommonSubexprEliminate + leaf-expression pushdown duplicate a UDF call wrapped in get_field

3 participants