Dandandan opened a new pull request, #24782:
URL: https://github.com/apache/datafusion/pull/24782

   ## Which issue does this PR close?
   
   - Related to the "single join" of the unnesting paper; no existing issue.
   
   ## Rationale for this change
   
   A correlated scalar subquery has to return at most one row for each set of 
outer
   values. DataFusion enforced that syntactically, in the analyzer: unless the
   subquery had an aggregate on top, planning failed with
   
   ```
   Correlated scalar subquery must be aggregated to return at most one row
   ```
   
   So the most ordinary shape of correlated subquery there is — looking an
   attribute up in a dimension table —
   
   ```sql
   select o_orderkey, (select c_name from customer where c_custkey = o_custkey)
   from orders
   ```
   
   did not plan at all. To run it you had to wrap the column in an aggregate
   (`min`, `max`, `any_value`) whose only purpose was to convince the analyzer 
of
   something the data already guaranteed, and then pay for a full hash 
aggregation
   over the subquery side at run time.
   
   Neumann and Kemper's unnesting paper solves this with a *single join*: an 
outer
   join that emits exactly one row per row of its preserved side and raises an
   error if a second row matches. With one, the subquery decorrelates into a 
join
   and nothing else — no aggregate, no grouping, no extra pass over the data.
   
   ## What changes are included in this PR?
   
   - `JoinType::LeftSingle` and `JoinType::RightSingle`, behaving like their
     `Left`/`Right` counterparts except that a second match for a row of the
     preserved side fails with `Scalar subquery returned more than one row` — 
the
     same error `ScalarSubqueryExec` already raises for the uncorrelated case.
   - `ScalarSubqueryToJoin` emits a single join for correlated scalar subqueries
     that are not already known to return at most one row, and keeps the plain
     `LEFT JOIN` for the ones that are -- either from an aggregate grouped only 
by
     columns the correlated predicate fixes, or from functional dependencies
     showing the subquery unique on the join keys. The analyzer no longer 
rejects
     the former.
   - `HashJoinExec` implements both directions, so `JoinSelection` can still 
swap
     the inputs to pick the build side. `NestedLoopJoinExec` implements both for
     correlations with no equijoin key. In both operators the existing matched
     bitmaps double as the duplicate detector, so the check is one 
already-cached
     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 two new variants; the Substrait producer reports
     them as unsupported, since Substrait has no equivalent.
   
   Plans that worked before are unchanged: no query in TPC-H (22) or TPC-DS (99)
   produces a single join, because every correlated scalar subquery in them
   aggregates a real value rather than proving a row count.
   
   ## What this costs and what it buys
   
   Measured on TPC-H SF10, comparing the form you had to write before against 
the
   form that now plans. Median of 7 runs, interleaved, repeated.
   
   | | outer / subquery rows | forced aggregate | single join | |
   |---|---|---|---|---|
   | lookup in a projection | 15M / 1.5M | 174 ms | 141 ms | **1.24x faster** |
   | lookup in a projection | 60M / 2M | 565 ms | 542 ms | 1.04x, within noise |
   | lookup in a projection | 100K / 1.5M | 15.1 ms | 6.1 ms | **2.5x faster** |
   | 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 dropping it is a pure win,
   by the share of the work the subquery side represents.
   
   In a filter it is not, and that is worth being precise about. A plain `LEFT
   JOIN` lets the optimizer use a predicate on the subquery's output:
   `eliminate_outer_join` turns 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` finishes 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 the single join has to observe.
   
   So the third commit makes the rule work harder to avoid needing a single join
   at all: besides the aggregate it can see from 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, uniqueness carried up through the 
subquery's
   own joins.
   
   With `c_custkey` declared a primary key, the filter cases come out ahead of 
the
   form they replace, because they now get both the aggregate removal and every
   rewrite 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 nothing to infer uniqueness from, 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 it did.
   
   ## Are these changes tested?
   
   Yes.
   
   - `subquery.slt` gains a single-join section: attribute lookup with and 
without
     a match, the duplicate-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 prove 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, both the accepted and the rejected
     case, plus the all-NULL build key path.
   - The full sqllogictest suite (504 files) and the workspace test suite pass.
   
   ## Are there any user-facing changes?
   
   Yes, all widening:
   
   - Correlated scalar subqueries without an aggregate now plan and run. Where 
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 says so directly (`Correlated scalar subquery with a LIMIT must be
     limited to a single row`) rather than reporting a missing aggregate.
   - `JoinType` gains two variants. Exhaustive matches on it in downstream code
     will need a new arm, so this is an API change.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to