Skip to content

feat: rewrite inner joins against a single constant row into a filter - #26404

Merged
xudong963 merged 5 commits into
apache:mainfrom
xudong963:xudong963/eliminate-constant-join
Oct 20, 2026
Merged

xudong963 merged 5 commits into
apache:mainfrom
xudong963:xudong963/eliminate-constant-join

Conversation

@xudong963

@xudong963 xudong963 commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

AggregateStatistics can answer a scalar subquery such as (SELECT min(ts) FROM live) from statistics and replace it with a row of literals. A join against that row still runs as a NestedLoopJoinExec, or as a CrossJoinExec when the join has no condition.

That join only filters its other input, but neither join operator preserves its inputs' ordering. Operators above it therefore lose the input's ordering. For example, an aggregation grouped by the input's sort key can no longer emit each group as soon as its sort key changes (group_clustering_mode=Full) and must hold every group until the end. The literal bound is also not pushed into the scan for pruning.

What changes are included in this PR?

A new physical optimizer rule, EliminateConstantJoin, runs right after AggregateStatistics. It applies when one input of an inner NestedLoopJoinExec or a CrossJoinExec is provably a single row of literals: projections over a single-partition PlaceholderRowExec. The rule substitutes the literals into the join filter. It then replaces the join with a FilterExec over the other input, under a ProjectionExec that restores the join's output schema and metadata. A cross join has no filter, so it becomes only the projection. Besides dropping ordering, CrossJoinExec buffers its other input as the build side when that input has no statistics.

At this point in the optimizer, a folded min(ts) AS first_ts is still an alias projection over the literal projection, so the rule follows stacked projections. Expressions over a folded value, such as min(ts) + 1, only become constant at this point, so the rule folds them with PhysicalExprSimplifier.

The rule leaves the join unchanged when:

  • the join is not an inner join;
  • the placeholder has more than one partition, because it emits one row per partition;
  • a value does not fold to a literal; for example, random() would then be evaluated per row instead of once;
  • the join filter contains a lambda, because lambda variables are numbered after the filter's own columns, not the other input's.

What is the testing strategy for this PR?

  • eliminate_constant_join.slt has plans and results for four rewrites: a scalar folded from Parquet statistics, an expression over it (min(ts) + 1), a cross join, and a literal subquery. In the first and the cross join, the aggregation becomes group_clustering_mode=Full; the bound is pushed into the Parquet scan. The file also checks that a left join and a random() constant keep the join.
  • physical_optimizer/eliminate_constant_join.rs covers cases SQL cannot reach:
  • explain.slt: EXPLAIN VERBOSE lists the new rule.
  • cte.slt, joins.slt: j1, LATERAL (SELECT 1) now plans a projection instead of a CrossJoinExec.

Are there any user-facing changes?

Queries that join against a row folded from statistics, or against a literal subquery, now plan a filter and a projection instead of a nested loop join or cross join. EXPLAIN VERBOSE and PhysicalOptimizer::new() include the new rule. There are no API changes.

AggregateStatistics can fold a scalar subquery into a row of literals, but a
join against that row still runs as a NestedLoopJoinExec, which preserves
neither input's ordering. EliminateConstantJoin substitutes the literals into
the join filter and replaces the join with a FilterExec and a ProjectionExec,
which keep the other input's ordering and expose the bound for scan pruning.

Closes apache#26349.
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) labels Oct 16, 2026
@codecov-commenter

codecov-commenter commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.29412% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.92%. Comparing base (fe612e2) to head (b9fa393).
⚠️ Report is 19 commits behind head on main.

Files with missing lines Patch % Lines
.../physical-optimizer/src/eliminate_constant_join.rs 85.29% 10 Missing and 10 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26404      +/-   ##
==========================================
+ Coverage   82.89%   82.92%   +0.03%     
==========================================
  Files        1154     1155       +1     
  Lines      458022   457927      -95     
  Branches   458022   457927      -95     
==========================================
+ Hits       379683   379745      +62     
+ Misses      55303    55151     -152     
+ Partials    23036    23031       -5     

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

@xudong963
xudong963 marked this pull request as ready for review October 19, 2026 02:57

@asolimando asolimando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, this is a clever optimization, I left a few minor comments

.expr()
.iter()
.map(|ProjectionExpr { expr, .. }| {
if expr.is::<Literal>() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: ConstEvaluator seems to be always constant-folding constant expressions (no column references, no volatile functions), we could possibly add a comment around that, as at first sight it looked too restrictive to me, and I had to play with a few SLT tests to verify.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checked this: constants written in SQL are folded by the logical simplifier, but values folded by AggregateStatistics only become literals during physical planning, so (SELECT min(ts) + 1 ...) reached the rule as 25 + 1 and was skipped. The rule now substitutes the row below and folds with PhysicalExprSimplifier, accepting only a literal result; volatile expressions and ones that fail to evaluate are still rejected. Added a comment and an slt case in b9fa393.

b 20 25
c 5 25

# A left join keeps the constant row when nothing matches

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: I think an EXPLAIN here would help to show the bail-out of the rule, beyond the correct result returned

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added an EXPLAIN showing the left join stays a NestedLoopJoinExec (b9fa393).

}

/// Rewrites `join` over its non-constant input, if the other input is a constant row.
fn eliminate(join: &NestedLoopJoinExec) -> Result<Option<Arc<dyn ExecutionPlan>>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This optimization would work also for CrossJoinExec, I think we could either add support for it (it doesn't seem much work from a cursory look), or document the gap and possibly file a follow-up issue. wdyt?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in b9fa393. CrossJoinExec drops input ordering and, when the other input has no statistics, buffers it as the build side, so the rewrite helps there as well. This changes the j1, LATERAL (SELECT 1) plans in cte.slt and joins.slt.

- Fold the constant side's expressions at planning time, so values over a
  statistics-folded aggregate, such as `min(ts) + 1`, are recognized.
  Volatile expressions and ones that fail to evaluate are still rejected.
- Rewrite `CrossJoinExec` against a constant row too. It drops input
  ordering and may buffer the other input as its build side.
- Show in an EXPLAIN that a left join against a constant row stays a join.
@xudong963
xudong963 requested a review from zhuqi-lucas October 19, 2026 23:37
@xudong963
xudong963 added this pull request to the merge queue Oct 20, 2026
Merged via the queue into apache:main with commit 2c2ecae Oct 20, 2026
42 of 43 checks passed
@xudong963
xudong963 deleted the xudong963/eliminate-constant-join branch October 20, 2026 15:46
xudong963 added a commit to massive-com/arrow-datafusion that referenced this pull request Oct 22, 2026
…apache#26404)

## Which issue does this PR close?

- Closes apache#26349.

## Rationale for this change

`AggregateStatistics` can answer a scalar subquery such as `(SELECT
min(ts) FROM live)` from statistics and replace it with a row of
literals. A join against that row still runs as a `NestedLoopJoinExec`,
or as a `CrossJoinExec` when the join has no condition.

That join only filters its other input, but neither join operator
preserves its inputs' ordering. Operators above it therefore lose the
input's ordering. For example, an aggregation grouped by the input's
sort key can no longer emit each group as soon as its sort key changes
(`group_clustering_mode=Full`) and must hold every group until the end.
The literal bound is also not pushed into the scan for pruning.

## What changes are included in this PR?

A new physical optimizer rule, `EliminateConstantJoin`, runs right after
`AggregateStatistics`. It applies when one input of an inner
`NestedLoopJoinExec` or a `CrossJoinExec` is provably a single row of
literals: projections over a single-partition `PlaceholderRowExec`. The
rule substitutes the literals into the join filter. It then replaces the
join with a `FilterExec` over the other input, under a `ProjectionExec`
that restores the join's output schema and metadata. A cross join has no
filter, so it becomes only the projection. Besides dropping ordering,
`CrossJoinExec` buffers its other input as the build side when that
input has no statistics.

At this point in the optimizer, a folded `min(ts) AS first_ts` is still
an alias projection over the literal projection, so the rule follows
stacked projections. Expressions over a folded value, such as `min(ts) +
1`, only become constant at this point, so the rule folds them with
`PhysicalExprSimplifier`.

The rule leaves the join unchanged when:
- the join is not an inner join;
- the placeholder has more than one partition, because it emits one row
per partition;
- a value does not fold to a literal; for example, `random()` would then
be evaluated per row instead of once;
- the join filter contains a lambda, because lambda variables are
numbered after the filter's own columns, not the other input's.

## What is the testing strategy for this PR?

- `eliminate_constant_join.slt` has plans and results for four rewrites:
a scalar folded from Parquet statistics, an expression over it (`min(ts)
+ 1`), a cross join, and a literal subquery. In the first and the cross
join, the aggregation becomes `group_clustering_mode=Full`; the bound is
pushed into the Parquet scan. The file also checks that a left join and
a `random()` constant keep the join.
- `physical_optimizer/eliminate_constant_join.rs` covers cases SQL
cannot reach:
  - a multi-partition placeholder;
- a lambda join filter, planned without optimizer rules because
projection pushdown cannot plan it yet (apache#26399);
  - schema metadata that comes from the constant input.
- `explain.slt`: `EXPLAIN VERBOSE` lists the new rule.
- `cte.slt`, `joins.slt`: `j1, LATERAL (SELECT 1)` now plans a
projection instead of a `CrossJoinExec`.

## Are there any user-facing changes?

Queries that join against a row folded from statistics, or against a
literal subquery, now plan a filter and a projection instead of a nested
loop join or cross join. `EXPLAIN VERBOSE` and
`PhysicalOptimizer::new()` include the new rule. There are no API
changes.

(cherry picked from commit 2c2ecae)

Adapt SQL plan expectations to branch-55, which still uses
ordering_mode=Sorted instead of group_clustering_mode=Full.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize inner joins with a proven single constant row into Filter/Projection

4 participants