fix: keep a correlated filter below an aggregate with a grouping set - #25529
namanjain24-sudo wants to merge 2 commits into
Conversation
`PullUpCorrelatedExpr` adds the correlated columns to the aggregate it moves a correlated filter above. `LogicalPlanBuilder::aggregate` cross joins a plain group expression with the sets a grouping set already holds, so `ROLLUP(i.k)`, which is `GROUPING SETS ((i.k), ())`, becomes `GROUPING SETS ((i.k), (i.k, i.k))`. The empty set is gone, and with it the grand total row the subquery returns for every outer row, including the rows whose filter matches nothing. The join that replaces the filter cannot bring those rows back, so `EXISTS` reported false and `IN` reported false where both should have been true and NULL. The pull up now stops at such an aggregate and leaves the subquery correlated. When every set already groups by each column the pull up needs, it adds nothing and the sets stay exactly as they are, so the queries that were already correct still decorrelate, now without repeating a column inside every set. Closes apache#25519
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25529 +/- ##
==========================================
- Coverage 82.38% 82.38% -0.01%
==========================================
Files 1138 1138
Lines 434328 434671 +343
Branches 434328 434671 +343
==========================================
+ Hits 357824 358103 +279
- Misses 54872 54886 +14
- Partials 21632 21682 +50 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @namanjain24-sudo! One non-blocking suggestion
| statement error DataFusion error: This feature is not implemented: Physical plan does not support logical expression Exists | ||
| SELECT gs_outer.k, EXISTS (SELECT 1 FROM gs_inner WHERE gs_inner.k = gs_outer.k GROUP BY GROUPING SETS ((gs_inner.k), ())) FROM gs_outer; | ||
|
|
||
| # A set that groups by another column does not carry the correlated column either. |
There was a problem hiding this comment.
Comment gives the empty-set rationale, which doesn't apply: ((k), (j)) has no empty set. The reason is the NULL fill — the pull up turns (j) into (j, k), so gs_inner.k is non-NULL where the original set fills it with NULL. Suggested wording plus a case that shows it (main returns false for every row; should be true for 1, 2, 5):
-# A set that groups by another column does not carry the correlated column either.
+# A set that leaves out the correlated column fills it with NULL. The pull up
+# would turn `(j)` into `(j, k)`, and `k` would then carry a value in the rows
+# where the subquery returns NULL. Anything above the aggregate that reads `k`
+# sees the difference, so the subquery stays correlated.
+#
+# Known limitation: when nothing reads `k`, as here, the pull up was correct
+# before this guard and the query now fails to plan. Telling the two cases apart
+# needs the correlated column added to each set under an alias.
statement error DataFusion error: This feature is not implemented: Physical plan does not support logical expression Exists
SELECT gs_outer.k, EXISTS (SELECT 1 FROM gs_inner WHERE gs_inner.k = gs_outer.k GROUP BY GROUPING SETS ((gs_inner.k), (gs_inner.j))) FROM gs_outer;
+
+# The same sets with a HAVING that reads the NULL filled column. For k = 1 the
+# `(j)` set yields the row `(NULL, 10)`, which passes the HAVING, so EXISTS is
+# true. With `(j, k)` that row has `k = 1` and is filtered out.
+statement error DataFusion error: This feature is not implemented: Physical plan does not support logical expression Exists
+SELECT gs_outer.k, EXISTS (SELECT 1 FROM gs_inner WHERE gs_inner.k = gs_outer.k GROUP BY GROUPING SETS ((gs_inner.k), (gs_inner.j)) HAVING gs_inner.k IS NULL) FROM gs_outer;The doc on grouping_sets_cover_pull_up_cols has the same gap, it only explains the empty set:
/// `ROLLUP` and `CUBE` always contain the empty set, which yields a row for
/// outer rows the correlated filter matches nothing for, so they are only safe
/// when there is nothing to add.
+ ///
+ /// A non-empty set that leaves a column out fills it with NULL. Adding the
+ /// column would give it a value that a HAVING or a projection above the
+ /// aggregate can read, so such a set is rejected as well.The alias-based fix is fine as a follow-up.
There was a problem hiding this comment.
Thanks, you're right that the comment gave the wrong reason, and the NULL fill is the real one. Applied both your wording and the doc paragraph in f763afd, with the HAVING case added.
I checked the rows behind it first, for k = 1:
GROUPING SETS ((k), (j)) -> (1, NULL), (NULL, 10)
+ HAVING k IS NULL -> (NULL, 10) EXISTS true
GROUPING SETS ((k), (j, k)) -> (1, 10), (1, NULL)
+ HAVING k IS NULL -> no rows EXISTS false
One detail on your note: false for every row is the HAVING query. The one already in the file answers true, true, false, true, false for 1, 2, 4, 5, NULL on main, which is correct, so it is only the "fails to plan now" limitation, as your comment says. The HAVING variant is the one main gets wrong, and it is in the file now with that stated.
Agreed on the alias-based fix as a follow-up.
The comment on the two-set case gave the empty set rationale, which does not apply to GROUPING SETS ((k), (j)): the pull up turns (j) into (j, k), so k carries a value in the rows where the set fills it with NULL. Add a case with a HAVING that reads that column, which main answers false for every row, and say the same in the doc on grouping_sets_cover_pull_up_cols.
Which issue does this PR close?
Rationale for this change
A correlated subquery whose filter sits below an aggregate with a grouping set
returns wrong results, with no error and no warning.
PullUpCorrelatedExprmoves the correlated filter above the aggregate and addsthe correlated column to the aggregate's group expressions.
LogicalPlanBuilder::aggregatecross joins a plain group expression with thesets a grouping set already holds, so
ROLLUP(i.k), which isGROUPING SETS ((i.k), ()), becomesGROUPING SETS ((i.k), (i.k, i.k)). Theempty set is gone, and with it the grand total row the subquery returns for
every outer row, including the rows whose filter matches nothing. The join that
replaces the filter cannot bring those rows back.
On
mainatb4a8c824b4, withdatafusion-cli:o.kEXISTSonmainINonmainThe physical plan for the
EXISTSquery shows the loss directly.ROLLUP(k)has two sets, and both of them now group by
k:What changes are included in this PR?
datafusion/optimizer/src/decorrelate.rs, in theAggregatearm ofPullUpCorrelatedExpr::f_up:columns to them. If any set would be missing one,
can_pull_upis set tofalseand the subquery stays correlated, which is what the issue asks forinstead of a wrong result.
ROLLUPandCUBEalways contain the empty set, so they are only safe whenthe pull up has nothing to add. An explicit
GROUPING SETSis checked set byset.
keeps its sets untouched and the subquery still decorrelates. Before this PR
that case appended the column anyway and produced
GROUPING SETS ((c, c), (c, b, c)); it now staysGROUPING SETS ((c), (c, b)).collect_missing_exprswould append: thecorrelated columns and the columns of a pulled up
HAVING, minus the ones thegroup expressions already list on their own.
The rewrite still runs to completion in the unsupported case, the same way the
existing
can_pull_over_aggregationcase does. The three callers(
decorrelate_predicate_subquery,scalar_subquery_to_join,decorrelate_lateral_join) readcan_pull_uponly after the whole rewrite hasfinished, and the nodes above the aggregate still expect the pulled up columns
in its output, so returning early there fails the rewrite with a schema error
rather than declining the transform.
What is the testing strategy for this PR?
Five unit tests, in the two rules that reach this code path:
decorrelate_predicate_subquery.rs:ROLLUPunderEXISTS,CUBEunderIN, aGROUPING SETSwhose second set groups by another column, and thecovering
GROUPING SETSthat must still decorrelate.scalar_subquery_to_join.rs:ROLLUPunder a correlated scalar subquery.Twelve queries in
datafusion/sqllogictest/test_files/subquery.sltcover theend-to-end behaviour:
ROLLUP,CUBE, an explicit set list holding(), a setlist whose sets group by different columns, a grouping set that does not mention
the correlated column at all, and the
IN,NOT EXISTSand correlated scalarforms; plus the four that must keep working, a covering
GROUPING SETSwith oneand with two sets, a plain
GROUP BY, and an uncorrelatedROLLUPsubquery.Ablation, with only
decorrelate.rsreverted tomainand every test kept (thetests live in other files, so the revert does not delete them): all five unit
tests fail, and seven of the eight
statement errorcases insubquery.sltfail with "query is expected to fail, but actually succeed". The eighth, the
correlated scalar subquery, already errors on
main.On the branch:
cargo test -p datafusion-optimizerpasses, 901 + 26 + 5 tests;the full
sqllogictestsuite passes, 521 files;cargo clippy -p datafusion-optimizer --all-targets -- -D warningsandcargo fmt --all -- --checkare clean.Are there any user-facing changes?
Yes, and it is worth a look before merging. The queries above stop returning
wrong rows, but they do not start returning right ones: leaving the subquery
correlated means the physical planner rejects it with
That is the behaviour #25519 asks for, and it matches how the rule already
declines a
Union,SortorExtensionthat holds an outer reference, and aLimitthat holds one outside anEXISTS. Supporting theseplans properly needs the aggregate to keep its sets through decorrelation, which
is a larger change than this one.
Queries whose grouping set already groups by the correlated column in every set
are unaffected, other than no longer repeating that column inside each set.