Skip to content

feat: Single join for correlated scalar subqueries - #24782

Closed
Dandandan wants to merge 3 commits into
apache:mainfrom
Dandandan:feat/single-join
Closed

feat: Single join for correlated scalar subqueries#24782
Dandandan wants to merge 3 commits into
apache:mainfrom
Dandandan:feat/single-join

Conversation

@Dandandan

@Dandandan Dandandan commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

  • Closes #.

Rationale for this change

A correlated scalar subquery must return at most one row per set of outer
values. DataFusion enforced this in the analyzer by requiring an aggregate on
top of the subquery. Without one, planning failed with

Correlated scalar subquery must be aggregated to return at most one row

So a plain attribute lookup did not plan at all:

select o_orderkey, (select c_name from customer where c_custkey = o_custkey)
from orders

To run it you had to wrap the column in min, max or any_value. That
aggregate does no useful work; it only proves a row count that the data already
guarantees, and it costs a full hash aggregation over the subquery side.

The single join from Neumann and Kemper's unnesting paper removes the need for
it. It is an outer join that emits one row per row of its preserved side and
raises an error when a second row matches, so the subquery decorrelates into a
join with no aggregate at all.

What changes are included in this PR?

  • JoinType::LeftSingle and JoinType::RightSingle. They behave like Left
    and Right except that a second match for a row of the preserved side fails
    with Scalar subquery returned more than one row, the error
    ScalarSubqueryExec already raises for uncorrelated scalar subqueries.
  • ScalarSubqueryToJoin uses a single join only for subqueries not already
    known to return at most one row. Subqueries that are known to, from an
    aggregate grouped on correlated columns or from functional dependencies
    showing the subquery unique on the join keys, keep their plain LEFT JOIN.
    The analyzer no longer rejects the rest.
  • HashJoinExec implements both directions, so JoinSelection can still swap
    the inputs to choose a build side. NestedLoopJoinExec implements both for
    correlations with no equijoin key. Both reuse the matched bitmaps as the
    duplicate detector, so the check costs one bit test per matched row and
    covers matches spread over batches and partitions.
  • SortMergeJoinExec and PiecewiseMergeJoin reject single joins, and the
    physical planner routes them to an operator that implements them.
  • Proto round-trip for the new variants. The Substrait producer reports them as
    unsupported, since Substrait has no equivalent.

No query in TPC-H (22) or TPC-DS (99) produces a single join, so no plan in
either suite changes. Every correlated scalar subquery in them aggregates a
real value rather than proving a row count.

Benchmarks

TPC-H SF10, comparing the form you had to write before against the form that
now plans. Median of 7 runs, interleaved and repeated.

outer / subquery rows forced aggregate single join
lookup in a projection 100K / 1.5M 15.1 ms 6.1 ms 2.5x faster
lookup in a projection 15M / 1.5M 174 ms 141 ms 1.24x faster
lookup in a projection 60M / 2M 565 ms 542 ms within noise
lookup in a filter 15M / 1.5M 102 ms 153 ms 1.5x slower
lookup in a filter, non-pushable predicate 15M / 1.5M 90 ms 139 ms 1.55x slower

In a projection the aggregate is pure overhead, and removing it saves the share
of the work the subquery side represents.

In a filter it is not that simple. A plain LEFT JOIN lets the optimizer use a
predicate on the subquery output: eliminate_outer_join makes the join inner
because the predicate rejects nulls, extract_equijoin_predicate folds a
comparison against an outer column into a second join key, and eliminate_join
turns the result into a semi join. None of these are sound for a single join,
because all three change how many rows match, which is what the single join
must observe.

So the rule tries harder to avoid needing a single join. Besides the aggregate
it can see at the top of the subquery, it asks the decorrelated subquery's
functional dependencies whether it is already unique on the columns the join
equates with the outer plan. That covers uniqueness the subquery inherits
rather than declares: a key that survives an extra condition in the correlated
predicate, a GROUP BY on the correlated column with nothing to aggregate, and
uniqueness carried up through the subquery's own joins.

With c_custkey declared a primary key, the filter cases end up faster than
the form they replace, because they get both the aggregate removal and the
rewrites the left join enables:

forced aggregate single join path
lookup in a filter 87 ms 55 ms 1.6x faster
lookup in a filter, non-pushable predicate 97 ms 85 ms 1.15x faster
lookup in a projection 180 ms 152 ms 1.2x faster

On a table with no declared key and no way to infer uniqueness, the filter case
keeps its 1.5x cost. That is the price of the stricter semantics, and it is
opt-in: the aggregated query still plans exactly as before.

Are these changes tested?

Yes.

  • subquery.slt gains a single join section: attribute lookup with and without
    a match, the more-than-one-row error, the same in a filter rather than a
    projection, the nested loop path for a correlation with no equijoin key, and
    plan assertions for each way a subquery can show it needs no single join (an
    aggregate, a declared key surviving an extra predicate, a bare GROUP BY),
    plus one showing a key proves nothing under an inequality. Four existing
    cases changed from rejected by the analyzer to running.
  • HashJoinExec and NestedLoopJoinExec unit tests cover both directions
    across partition modes and batch sizes, the accepted and the rejected case,
    and the all-NULL build key path.
  • The full sqllogictest suite (504 files) and the workspace test suite pass.

Are there any user-facing changes?

  • Correlated scalar subqueries without an aggregate now plan and run. When the
    data has a second matching row they fail at run time with Scalar subquery returned more than one row instead of failing to plan.
  • A GROUP BY in a correlated scalar subquery may now name columns the
    correlated predicate does not fix.
  • A LIMIT above the correlated predicate still cannot be decorrelated. That
    case now reports Correlated scalar subquery with a LIMIT must be limited to a single row instead of reporting a missing aggregate.
  • JoinType gains two variants, so exhaustive matches on it in downstream code
    need a new arm. This is an API change.

Dandandan and others added 3 commits August 30, 2026 01:19
Correlated scalar subqueries must return at most one row per set of outer
values. DataFusion enforced that syntactically: the analyzer rejected any
correlated scalar subquery that did not have an aggregate on top, with
"Correlated scalar subquery must be aggregated to return at most one row".
So a lookup like

  select o_orderkey, (select c_name from customer where c_custkey = o_custkey)
  from orders

did not plan at all, and had to be written with a redundant aggregate
(min/max/any_value) whose only job was to prove the row count -- paying a
full hash aggregation over the subquery side.

This adds the "single join" of Neumann and Kemper's unnesting paper as
JoinType::LeftSingle / JoinType::RightSingle: a left/right outer join that
emits exactly one row per row of its preserved side and raises
"Scalar subquery returned more than one row" when a second row matches.
ScalarSubqueryToJoin now emits it for the subqueries whose shape does not
already guarantee the property, so those queries decorrelate into a plain
join with no aggregate at all.

Subqueries that do guarantee it -- an aggregate grouped only by columns the
correlated predicate fixes -- keep their plain LEFT JOIN, so every plan that
worked before is byte-identical, including TPC-H q2/q17/q20 and TPC-DS
q1/q6/q30/q32/q41/q81/q92.

Implemented in HashJoinExec (both build- and probe-driven, so the join can
still be swapped for build-side selection) and NestedLoopJoinExec. The
matched bitmaps double as duplicate detectors, so the check costs one
already-cached bit test per matched row and covers matches spread across
probe batches and partitions. SortMergeJoinExec and PiecewiseMergeJoin
reject single joins so the planner falls back to a join that implements
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgqwvctZdvR1ZCz2hbkEJC
Adds the single-join section of `subquery.slt` -- attribute lookup with and
without a match, the more-than-one-row error, the same in a filter rather than
a projection, the nested-loop path for a correlation with no equijoin key, and
a plan assertion that a provably-single-row subquery still gets a plain left
join -- plus `HashJoinExec` and `NestedLoopJoinExec` unit tests covering both
directions across partition modes and batch sizes.

Writing them found two gaps, both fixed here:

  - `projection_pushdown` and `NestedLoopJoinExec::build_unmatched_batch` hit
    catch-all arms for the new join types, panicking on a correlation with no
    equijoin key.
  - `NestedLoopJoinStream::update_matched_bitmap`, the path taken when the
    right batch is large relative to the batch size, set the matched bitmaps
    without checking them, so a second match went unreported.

Decorrelation still cannot pull a correlated predicate through a `LIMIT`, and
those subqueries no longer hit the "must be aggregated" check that used to
report them. They now say so directly instead of reaching the physical planner
as a leftover `ScalarSubquery` expression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgqwvctZdvR1ZCz2hbkEJC
… the keys

Preferring `LEFT JOIN` over a single join matters for more than the runtime
check. A left join lets the optimizer turn the join inner when a predicate
above it rejects nulls, fold a comparison against an outer column into a second
join key, and finish with a semi join. None of that is sound for a single join,
because all three change how many rows match -- which is the one thing a single
join has to observe. So a correlated scalar subquery in a `WHERE` clause was
about 1.6x slower than the aggregated form it replaces.

`correlated_scalar_subquery_yields_single_row` only looks for an aggregate the
rule can see from the top of the subquery. This also asks the decorrelated
subquery's functional dependencies whether it is already unique on the columns
the join equates with the outer plan, which covers uniqueness it inherits
rather than declares:

  - a declared key, when the correlated predicate carries a further condition
    and so does not reduce to the `Filter::is_scalar` shape `max_rows` looks
    for;
  - a `GROUP BY` on the correlated column with nothing to aggregate;
  - uniqueness carried up through the subquery's own joins and projections.

Only equality conjuncts count as join keys: uniqueness on `(a, b)` says nothing
about how many rows `sub.a = outer.a AND sub.b > outer.b` matches.

On TPC-H SF10 with `c_custkey` declared a primary key, `where (select
c_mktsegment from customer where c_custkey = o_custkey) = 'BUILDING'` goes from
1.5x slower than the `min()` form to 1.6x faster (87 ms -> 55 ms), and the
non-pushable variant from 1.55x slower to 1.15x faster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgqwvctZdvR1ZCz2hbkEJC
@github-actions github-actions Bot added sql SQL Planner logical-expr Logical plan and expressions physical-expr Changes to the physical-expr crates optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate common Related to common crate proto Related to proto crate physical-plan Changes to the physical-plan crate labels Aug 30, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.07950% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.53%. Comparing base (61bf6b9) to head (8b90f76).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/nested_loop_join.rs 78.57% 5 Missing and 1 partial ⚠️
datafusion/common/src/join_type.rs 69.23% 4 Missing ⚠️
datafusion/expr/src/logical_plan/invariants.rs 89.74% 1 Missing and 3 partials ⚠️
datafusion/physical-plan/src/joins/proto.rs 0.00% 4 Missing ⚠️
...ion/physical-plan/src/joins/symmetric_hash_join.rs 0.00% 4 Missing ⚠️
datafusion/proto-common/src/generated/pbjson.rs 0.00% 4 Missing ⚠️
datafusion/proto-common/src/generated/prost.rs 0.00% 4 Missing ⚠️
...to-models/src/generated/datafusion_proto_common.rs 0.00% 4 Missing ⚠️
...atafusion/optimizer/src/scalar_subquery_to_join.rs 93.18% 1 Missing and 2 partials ⚠️
datafusion/expr/src/logical_plan/plan.rs 50.00% 2 Missing ⚠️
... and 7 more
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #24782    +/-   ##
========================================
  Coverage   81.52%   81.53%            
========================================
  Files        1123     1123            
  Lines      405970   406200   +230     
  Branches   405970   406200   +230     
========================================
+ Hits       330978   331177   +199     
- Misses      55627    55657    +30     
- Partials    19365    19366     +1     

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

@Dandandan Dandandan closed this Aug 30, 2026
@github-actions

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v55.0.0 (current)
       Built [  59.441s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.037s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  58.910s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.036s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.576s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 120.960s] datafusion
    Building datafusion-common v55.0.0 (current)
       Built [  33.968s] (current)
     Parsing datafusion-common v55.0.0 (current)
      Parsed [   0.062s] (current)
    Building datafusion-common v55.0.0 (baseline)
       Built [  34.459s] (baseline)
     Parsing datafusion-common v55.0.0 (baseline)
      Parsed [   0.064s] (baseline)
    Checking datafusion-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.741s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_variant_added.ron

Failed in:
  variant JoinType:LeftSingle in /home/runner/work/datafusion/datafusion/datafusion/common/src/join_type.rs:86
  variant JoinType:RightSingle in /home/runner/work/datafusion/datafusion/datafusion/common/src/join_type.rs:91

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  70.715s] datafusion-common
    Building datafusion-expr v55.0.0 (current)
       Built [  29.237s] (current)
     Parsing datafusion-expr v55.0.0 (current)
      Parsed [   0.075s] (current)
    Building datafusion-expr v55.0.0 (baseline)
       Built [  29.376s] (baseline)
     Parsing datafusion-expr v55.0.0 (baseline)
      Parsed [   0.076s] (baseline)
    Checking datafusion-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.259s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  61.489s] datafusion-expr
    Building datafusion-optimizer v55.0.0 (current)
       Built [  27.712s] (current)
     Parsing datafusion-optimizer v55.0.0 (current)
      Parsed [   0.029s] (current)
    Building datafusion-optimizer v55.0.0 (baseline)
       Built [  27.124s] (baseline)
     Parsing datafusion-optimizer v55.0.0 (baseline)
      Parsed [   0.031s] (baseline)
    Checking datafusion-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.160s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  55.940s] datafusion-optimizer
    Building datafusion-physical-expr v55.0.0 (current)
       Built [  30.036s] (current)
     Parsing datafusion-physical-expr v55.0.0 (current)
      Parsed [   0.047s] (current)
    Building datafusion-physical-expr v55.0.0 (baseline)
       Built [  29.605s] (baseline)
     Parsing datafusion-physical-expr v55.0.0 (baseline)
      Parsed [   0.048s] (baseline)
    Checking datafusion-physical-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.345s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  61.127s] datafusion-physical-expr
    Building datafusion-physical-optimizer v55.0.0 (current)
       Built [  40.867s] (current)
     Parsing datafusion-physical-optimizer v55.0.0 (current)
      Parsed [   0.021s] (current)
    Building datafusion-physical-optimizer v55.0.0 (baseline)
       Built [  40.967s] (baseline)
     Parsing datafusion-physical-optimizer v55.0.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-physical-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.118s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  83.273s] datafusion-physical-optimizer
    Building datafusion-physical-plan v55.0.0 (current)
       Built [  38.477s] (current)
     Parsing datafusion-physical-plan v55.0.0 (current)
      Parsed [   0.152s] (current)
    Building datafusion-physical-plan v55.0.0 (baseline)
       Built [  38.779s] (baseline)
     Parsing datafusion-physical-plan v55.0.0 (baseline)
      Parsed [   0.153s] (baseline)
    Checking datafusion-physical-plan v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.721s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  79.490s] datafusion-physical-plan
    Building datafusion-proto-common v55.0.0 (current)
       Built [  22.541s] (current)
     Parsing datafusion-proto-common v55.0.0 (current)
      Parsed [   0.047s] (current)
    Building datafusion-proto-common v55.0.0 (baseline)
       Built [  22.415s] (baseline)
     Parsing datafusion-proto-common v55.0.0 (baseline)
      Parsed [   0.049s] (baseline)
    Checking datafusion-proto-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.193s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_variant_added.ron

Failed in:
  variant JoinType:Leftsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1056
  variant JoinType:Rightsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1057
  variant JoinType:Leftsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1056
  variant JoinType:Rightsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1057
  variant JoinType:Leftsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1056
  variant JoinType:Rightsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-common/src/generated/prost.rs:1057

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  47.355s] datafusion-proto-common
    Building datafusion-proto-models v55.0.0 (current)
       Built [  25.471s] (current)
     Parsing datafusion-proto-models v55.0.0 (current)
      Parsed [   0.129s] (current)
    Building datafusion-proto-models v55.0.0 (baseline)
       Built [  25.416s] (baseline)
     Parsing datafusion-proto-models v55.0.0 (baseline)
      Parsed [   0.136s] (baseline)
    Checking datafusion-proto-models v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.953s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure enum_variant_added: enum variant added on exhaustive enum ---

Description:
A publicly-visible enum without #[non_exhaustive] has a new variant.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#enum-variant-new
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/enum_variant_added.ron

Failed in:
  variant JoinType:Leftsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:1056
  variant JoinType:Rightsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:1057
  variant JoinType:Leftsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:1056
  variant JoinType:Rightsingle in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/datafusion_proto_common.rs:1057

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  54.297s] datafusion-proto-models
    Building datafusion-sql v55.0.0 (current)
       Built [  43.100s] (current)
     Parsing datafusion-sql v55.0.0 (current)
      Parsed [   0.044s] (current)
    Building datafusion-sql v55.0.0 (baseline)
       Built [  42.974s] (baseline)
     Parsing datafusion-sql v55.0.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion-sql v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.246s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  87.686s] datafusion-sql
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [ 100.740s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [ 101.087s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.090s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 205.101s] datafusion-sqllogictest
    Building datafusion-substrait v55.0.0 (current)
       Built [ 316.968s] (current)
     Parsing datafusion-substrait v55.0.0 (current)
      Parsed [   0.017s] (current)
    Building datafusion-substrait v55.0.0 (baseline)
       Built [ 322.814s] (baseline)
     Parsing datafusion-substrait v55.0.0 (baseline)
      Parsed [   0.017s] (baseline)
    Checking datafusion-substrait v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.211s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 642.144s] datafusion-substrait

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 30, 2026
@Dandandan

Copy link
Copy Markdown
Contributor Author

Superseded by #24784, which has the same change with the history cleaned up (an unrelated file had been committed by mistake).

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

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate logical-expr Logical plan and expressions optimizer Optimizer rules physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate proto Related to proto crate sql SQL Planner sqllogictest SQL Logic Tests (.slt) substrait Changes to the substrait crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants