Skip to content

feat: Single join for correlated scalar subqueries - #24784

Open
Dandandan wants to merge 2 commits into
apache:mainfrom
Dandandan:feat/single-join
Open

feat: Single join for correlated scalar subqueries#24784
Dandandan wants to merge 2 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: ##16425

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.

What changes are included in this PR?

Implement single join

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.

Are these changes tested?

Are there any user-facing changes?

  • JoinType gains two variants, so exhaustive matches on it in downstream code
    need a new arm. This is an API change.

A correlated scalar subquery must return at most one row per set of outer
values. The analyzer enforced this by requiring an aggregate on top of the
subquery, so a plain attribute lookup did not plan:

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

failed with "Correlated scalar subquery must be aggregated to return at most
one row". 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, and it costs a hash
aggregation over the subquery side.

This adds the single join from Neumann and Kemper's unnesting paper as
JoinType::LeftSingle and JoinType::RightSingle. It behaves like a left or right
join, but returns "Scalar subquery returned more than one row" when a second
row matches, which is the error ScalarSubqueryExec already returns for
uncorrelated scalar subqueries. ScalarSubqueryToJoin uses it for correlated
scalar subqueries that are not already known to return at most one row, so
those queries decorrelate into a join with no aggregate.

Subqueries that are known to return at most one row keep their plain LEFT JOIN.
This matters for more than the run-time check: the optimizer can make a LEFT
JOIN inner when a predicate above it rejects nulls, fold a comparison into a
second join key, and turn the result into a semi join, none of which are
correct for a single join. Besides the aggregate the rule can see at the top of
the subquery, it now also asks the decorrelated subquery's functional
dependencies whether it is unique on the join keys, which covers a declared
constraint or an aggregate further down.

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 to detect
duplicates, 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 elsewhere.

No query in TPC-H or TPC-DS produces a single join, so no plan in either suite
changes.

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

codecov-commenter commented Aug 30, 2026

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 (3317426).
⚠️ Report is 4 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   #24784    +/-   ##
========================================
  Coverage   81.52%   81.53%            
========================================
  Files        1123     1123            
  Lines      405970   406261   +291     
  Branches   405970   406261   +291     
========================================
+ Hits       330978   331227   +249     
- Misses      55627    55667    +40     
- Partials    19365    19367     +2     

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

@github-actions

github-actions Bot commented Aug 30, 2026

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.207s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.039s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  59.030s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.035s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.577s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 120.416s] datafusion
    Building datafusion-common v55.0.0 (current)
       Built [  34.129s] (current)
     Parsing datafusion-common v55.0.0 (current)
      Parsed [   0.063s] (current)
    Building datafusion-common v55.0.0 (baseline)
       Built [  34.262s] (baseline)
     Parsing datafusion-common v55.0.0 (baseline)
      Parsed [   0.063s] (baseline)
    Checking datafusion-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.766s] 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:85
  variant JoinType:RightSingle in /home/runner/work/datafusion/datafusion/datafusion/common/src/join_type.rs:90

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  70.264s] datafusion-common
    Building datafusion-expr v55.0.0 (current)
       Built [  29.267s] (current)
     Parsing datafusion-expr v55.0.0 (current)
      Parsed [   0.077s] (current)
    Building datafusion-expr v55.0.0 (baseline)
       Built [  29.681s] (baseline)
     Parsing datafusion-expr v55.0.0 (baseline)
      Parsed [   0.078s] (baseline)
    Checking datafusion-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.283s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  61.673s] datafusion-expr
    Building datafusion-optimizer v55.0.0 (current)
       Built [  27.183s] (current)
     Parsing datafusion-optimizer v55.0.0 (current)
      Parsed [   0.030s] (current)
    Building datafusion-optimizer v55.0.0 (baseline)
       Built [  27.089s] (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.163s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  55.212s] datafusion-optimizer
    Building datafusion-physical-expr v55.0.0 (current)
       Built [  29.420s] (current)
     Parsing datafusion-physical-expr v55.0.0 (current)
      Parsed [   0.047s] (current)
    Building datafusion-physical-expr v55.0.0 (baseline)
       Built [  29.554s] (baseline)
     Parsing datafusion-physical-expr v55.0.0 (baseline)
      Parsed [   0.051s] (baseline)
    Checking datafusion-physical-expr v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.350s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  60.286s] datafusion-physical-expr
    Building datafusion-physical-optimizer v55.0.0 (current)
       Built [  40.753s] (current)
     Parsing datafusion-physical-optimizer v55.0.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-physical-optimizer v55.0.0 (baseline)
       Built [  40.349s] (baseline)
     Parsing datafusion-physical-optimizer v55.0.0 (baseline)
      Parsed [   0.023s] (baseline)
    Checking datafusion-physical-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.121s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  82.290s] datafusion-physical-optimizer
    Building datafusion-physical-plan v55.0.0 (current)
       Built [  38.223s] (current)
     Parsing datafusion-physical-plan v55.0.0 (current)
      Parsed [   0.154s] (current)
    Building datafusion-physical-plan v55.0.0 (baseline)
       Built [  38.721s] (baseline)
     Parsing datafusion-physical-plan v55.0.0 (baseline)
      Parsed [   0.155s] (baseline)
    Checking datafusion-physical-plan v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.730s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  79.086s] datafusion-physical-plan
    Building datafusion-proto-common v55.0.0 (current)
       Built [  22.473s] (current)
     Parsing datafusion-proto-common v55.0.0 (current)
      Parsed [   0.046s] (current)
    Building datafusion-proto-common v55.0.0 (baseline)
       Built [  22.614s] (baseline)
     Parsing datafusion-proto-common v55.0.0 (baseline)
      Parsed [   0.047s] (baseline)
    Checking datafusion-proto-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.168s] 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.323s] datafusion-proto-common
    Building datafusion-proto-models v55.0.0 (current)
       Built [  25.142s] (current)
     Parsing datafusion-proto-models v55.0.0 (current)
      Parsed [   0.128s] (current)
    Building datafusion-proto-models v55.0.0 (baseline)
       Built [  25.393s] (baseline)
     Parsing datafusion-proto-models v55.0.0 (baseline)
      Parsed [   0.132s] (baseline)
    Checking datafusion-proto-models v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   1.892s] 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 [  53.741s] datafusion-proto-models
    Building datafusion-sql v55.0.0 (current)
       Built [  42.647s] (current)
     Parsing datafusion-sql v55.0.0 (current)
      Parsed [   0.030s] (current)
    Building datafusion-sql v55.0.0 (baseline)
       Built [  42.770s] (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.261s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  86.734s] datafusion-sql
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [ 101.242s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [ 101.563s] (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.091s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 205.679s] datafusion-sqllogictest
    Building datafusion-substrait v55.0.0 (current)
       Built [ 314.887s] (current)
     Parsing datafusion-substrait v55.0.0 (current)
      Parsed [   0.017s] (current)
    Building datafusion-substrait v55.0.0 (baseline)
       Built [ 320.558s] (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.212s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 637.344s] datafusion-substrait

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Aug 30, 2026
@Dandandan
Dandandan marked this pull request as ready for review August 30, 2026 15:14
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