Repository navigation
fix: keep a LIMIT inside EXISTS that an operator above it can empty - #26640
jayzhan211 merged 5 commits into
Conversation
`PullUpCorrelatedExpr::f_down` reasoned that "for EXISTS only emptiness
matters" and dropped any `Limit` with `skip=0` and a non-zero fetch from
an `EXISTS` subquery.
That holds for the emptiness of the `Limit`'s own output. What `EXISTS`
reads is whether the whole subquery is empty, so the argument breaks as
soon as a node above the `Limit` can be empty for a reason of its own:
SELECT t1_id FROM t1 WHERE EXISTS (
SELECT 1 FROM (SELECT t2_id FROM t2 WHERE t2_int = t1_int LIMIT 1) s
HAVING count(*) > 1
);
`count(*)` over the single row `LIMIT 1` keeps can never exceed 1,
whichever row that is, so the `HAVING` holds for nothing and the query
returns no rows. Dropping the `Limit` lets the aggregate count the whole
scan, and on the `subquery.slt` tables this returned `33`. A `Filter`
above the `Limit` goes wrong the same way.
Track, per node on the path from the subquery's root, whether the
emptiness of that node's output follows from the emptiness of its input
alone, and only drop the `Limit` when that holds of every node above it.
The condition is emptiness, not row count. A `Projection`, a
`SubqueryAlias`, a `Sort`, a `Window` and a `Repartition` each output one
row per input row. A `DISTINCT` and a grouped `Aggregate` output rows
exactly when their input has rows, and an `Aggregate` without a grouping
always outputs its one row, so no `Aggregate` can be emptied by the rows
a `Limit` held back - `EXISTS (SELECT count(*) FROM (.. LIMIT 1) s)`,
`EXISTS (SELECT DISTINCT .. FROM (.. LIMIT 1) s)` and a `GROUP BY` above
the limit all keep decorrelating, as does the common
`EXISTS (SELECT 1 FROM t WHERE t.a = o.a LIMIT 1)`. A `Filter` is what
this leaves out, together with the nodes that can empty a non-empty
input: `Join`, `Union`, `Unnest`.
Where the `Limit` now has to stay, the subquery does not decorrelate and
the query fails to plan. That is the same trade `subquery.slt` already
records for `EXISTS (SELECT * FROM t2 WHERE t2_id = t1_id offset 1)`,
which is asserted to error "rather than returning wrong rows", and for
the equivalent correlated `IN` subquery with a `LIMIT`.
`LIMIT 0` keeps its existing rewrite to an empty relation, which is
sound whatever sits above it: both sides are always empty.
Tests go in `subquery.slt` next to the existing `offset` cases: the two
shapes that were wrong, and the four that must keep working. The three
new assertions all fail on `main`. The rule's own unit tests cover the
decorrelated plan shape. The full sqllogictest run fails in the same 236
places before and after.
Fixes apache#26629
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Raghvendra Yadav <[email protected]>
1dd5a07 to
1cf3189
Compare
|
Rebased past #26551 — no conflict; that change is in the @jayzhan211 would you have time to look? You merged #26551 and #25781 in this area. The change is narrow: |
|
@alamb could you take a look when you have a moment, or point me at a better reviewer? It's green and mergeable, and it has been quiet since I asked @jayzhan211 on the 24th. Short version: |
|
I'll take a look, but please note that the PR queue is long, so it may take several days. |
| | LogicalPlan::Window(_) | ||
| | LogicalPlan::Repartition(_) | ||
| | LogicalPlan::Distinct(Distinct::All(_)) | ||
| | LogicalPlan::Aggregate(_) |
There was a problem hiding this comment.
@raghavyadav01 thanks for the fix! One regression I hit: an EXISTS with a MATERIALIZED CTE whose LIMIT sits at the root of the continuation now fails to plan, but on main it returns the right rows. The Cte arm pushes false for itself and then calls continuation.rewrite(self), so the Limit inside sees a blocking node above it. A Cte outputs exactly its continuation's rows, and only the continuation is rewritten, so its emptiness follows its input.
Repro (main: 1, 2; this PR: Physical plan does not support logical expression Exists):
SET datafusion.sql_parser.dialect = 'PostgreSQL';
CREATE TABLE a(k INT, x INT) AS VALUES (1, 10), (2, 20), (3, 30);
CREATE TABLE b(k INT, v INT) AS VALUES (1, 100), (1, 200), (2, 300);
SELECT a.k FROM a WHERE EXISTS (
WITH c AS MATERIALIZED (SELECT * FROM b)
SELECT 1 FROM c WHERE c.k = a.k LIMIT 1);Fix:
| LogicalPlan::Distinct(Distinct::All(_))
| LogicalPlan::Aggregate(_)
+ | LogicalPlan::MaterializedCte(MaterializedCtePlan::Cte(_))
)Could you add these two cases to cte_materialized.slt (just before "A body that refers to the outer query…")? The first must decorrelate, and the second must stay refused (on main it wrongly returns 1):
# A LIMIT at the root of the continuation does not change whether it is empty.
query I rowsort
SELECT k FROM t WHERE EXISTS (
WITH r AS MATERIALIZED (SELECT k AS rk FROM t)
SELECT 1 FROM r WHERE r.rk = t.k + 1 LIMIT 1)
----
1
2
3
# A filter above the LIMIT reads the row the LIMIT kept, so it stays correlated.
query error Physical plan does not support logical expression Exists
SELECT k FROM t WHERE EXISTS (
WITH r AS MATERIALIZED (SELECT k AS rk, v AS rv FROM t)
SELECT 1 FROM (SELECT * FROM r WHERE r.rk = t.k LIMIT 1) s WHERE s.rv > 0)| /// kept decide whether anything survives it. A `Join`, a `Union` and an | ||
| /// `Unnest` can empty a non-empty input too. Every other variant is treated the | ||
| /// same way, so that a new one does not silently become droppable. |
There was a problem hiding this comment.
Optional cleanup: a Union can't empty a non-empty input, because its output is non-empty whenever any input is. It's left off the list because the Union arm already stops the pull-up when it holds an outer reference. Maybe:
-/// kept decide whether anything survives it. A `Join`, a `Union` and an
-/// `Unnest` can empty a non-empty input too. Every other variant is treated the
-/// same way, so that a new one does not silently become droppable.
+/// kept decide whether anything survives it. A `Join` and an `Unnest` can empty
+/// a non-empty input too. Every other variant is treated the same way, so that
+/// a new one does not silently become droppable.
alamb
left a comment
There was a problem hiding this comment.
As I mentioned elsewhere (sorry for the repeated content) I don't think we should be trying to patch these subquery casees to make them work, rather I think we should make the query error rather than trying to make it work / give correct results. To make it work and have confidence it will be correct, I think we need a more general approach than patches. See here for details
… EXISTS limit Addresses review: a LIMIT at the root of a MATERIALIZED CTE continuation is safe to drop. Adds both cases to cte_materialized.slt.
|
@alamb agreed, and I think this PR already takes that route: it adds no new rewrite. Today EXISTS drops an inner LIMIT even when a filter above it reads the limited rows, which returns wrong rows (#26629). With this change that shape stays correlated, so the query fails with @jayzhan211 thanks, applied both: the materialized CTE case and the Union doc cleanup, plus your two slt cases. Confirmed the first one fails without the fix. |
|
Thank you -- will and give it a look again |
alamb
left a comment
There was a problem hiding this comment.
I think this is reasonable but it is hard to understand and I am not sure about is theoretical foundation to be honest
I think we are getting closer though. Let me know what you thin
Thank you for sticking with us @raghavyadav01 and @jayzhan211
| } | ||
| } | ||
|
|
||
| /// Whether the emptiness of `plan`'s output follows from the emptiness of its |
There was a problem hiding this comment.
I don't really understand what emptness means in this context. Is this saying if the input to the plan is non empty than the output will also be non empty?
How will we know that for user defined plans?
There was a problem hiding this comment.
Yes, that's the idea. The property is that whether the node returns any rows depends only on whether its input has any, not on which ones. A Limit with no skip and fetch > 0 has it too, so if every node above it does, the subquery returns a row exactly when the Limit's input does, with or without the Limit. That's the whole argument.
User-defined plans aren't in the list, so for an Extension the Limit stays and the subquery isn't decorrelated, the same as the offset 1 case. I rewrote the doc comment along these lines and renamed the function to depends_only_on_input_having_rows.
| /// A materialized CTE outputs exactly its continuation's rows, and only the | ||
| /// continuation is rewritten, so its emptiness follows that input. | ||
| /// | ||
| /// A `Filter` is what this leaves out: it reads values, so the rows the `Limit` |
There was a problem hiding this comment.
I found this really hard to understand -- it seems to be saying that Filter removes rows ?
There was a problem hiding this comment.
Yes. A Filter keeps rows by their values, so which rows the Limit kept decides whether any get through. Reworded that paragraph to say it directly.
| ) | ||
| } | ||
|
|
||
| /// A `Limit` whose row count nothing above it can observe is still dropped, |
There was a problem hiding this comment.
what value do these tests add over just SLT tests?
There was a problem hiding this comment.
Not much. The SLT cases cover the same shapes, and a subquery that isn't decorrelated fails to plan, so the passing SLT results already show the rewrite happened. Removed them.
…t tests The plan tests pinned the same shapes the subquery.slt cases cover, and a subquery that is not decorrelated fails to plan, so the SLT results already show the rewrite happened. Also cover the NOT EXISTS form of apache#26629. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
Thanks @alamb. Pushed b1937e7: the doc comments now state one property (a node returns rows based only on whether its input has rows), and the unit tests that duplicated the SLT cases are gone. Replies inline. Checked against DuckDB 1.5.6: the shapes that still decorrelate return the same rows, and the HAVING shape from #26629 (and its NOT EXISTS form, now in |
|
Thanks @raghavyadav01 @alamb I think it is ready to merge |
|
Thanks for getting this one in |
Which issue does this PR close?
Rationale for this change
PullUpCorrelatedExpr::f_downreasoned that "for EXISTS only emptiness matters" and dropped anyLimitwithskip=0and a non-zero fetch out of anEXISTSsubquery.That holds for the emptiness of the
Limit's own output. WhatEXISTSreads is whether thewhole subquery is empty, so the argument breaks as soon as a node above the
Limitcan beempty for a reason of its own. On the
subquery.slttables:count(*)over the single rowLIMIT 1keeps can never exceed 1, whichever row that is, so theHAVINGholds for nothing and the query returns no rows. Dropping theLimitlets the aggregatecount the whole scan, and on
mainthis returns33. AFilterabove theLimitgoes wrong thesame way.
What changes are included in this PR?
PullUpCorrelatedExprnow tracks, per node on the path from the subquery's root, whether theemptiness of that node's output follows from the emptiness of its input alone, and only drops the
Limitwhen that holds of every node above it. The entry is pushed and popped in lockstep withthe existing
column_refs_above.The condition is emptiness, not row count, which matters for how little this rejects. A
Projection, aSubqueryAlias, aSort, aWindowand aRepartitioneach output one row perinput row. A
DISTINCTand a groupedAggregateoutput rows exactly when their input has rows,and an
Aggregatewithout a grouping always outputs its one row — so noAggregatecan beemptied by the rows a
Limitheld back. These all keep decorrelating:Filteris what the predicate leaves out, together with the nodes that can empty a non-emptyinput:
Join,Union,Unnest. Anything not listed is treated as unsafe, so a newLogicalPlanvariant does not silently become droppable.
LIMIT 0keeps its existing rewrite to an empty relation, which is sound whatever sits above it,since both sides are always empty.
The trade-off worth reviewing
Where the
Limitnow has to stay, the subquery does not decorrelate and the query fails to planwith
Physical plan does not support logical expression Exists(..).That is the trade
subquery.sltalready records a few lines above these new tests, forEXISTS (SELECT * FROM t2 WHERE t2_id = t1_id offset 1), asserted to error "rather thanreturning wrong rows" — and
maindoes the same for the equivalent correlatedINsubquery witha
LIMIT. So this puts the two broken shapes into an existing, already-asserted bucket ratherthan inventing a new behaviour. Happy to go further if you would rather these decorrelate properly
via the
row_number()route the issue mentions; that looked like a much larger change and I didnot want to bundle it.
What is the testing strategy for this PR?
datafusion/sqllogictest/test_files/subquery.slt, in the existingEXISTS-with-Limit/offsetblock: the two shapes that returned wrong answers, and the four above that must keep working.
The three new assertions all fail on
main— theexplainshowsmaindropping theLimitinto a
LeftSemijoin, and both error assertions fail becausemainreturns rows.The rule's own unit tests in
decorrelate_predicate_subquery.rspin the decorrelated plan shapefor a
Limitunder aProjectionand under anAggregate, and that theHAVINGandFiltershapes are left untransformed.
cargo test -p datafusion-optimizer: 951 pass.sqllogictests: fails in the same 236 places before and after this change, identical sets.cargo clippy --all-targets --workspace --features avro,integration-tests,extended_tests -- -D warnings: clean.cargo fmt --all --check,typos, andRUSTDOCFLAGS="-D warnings" cargo doc --document-private-items: clean.🤖 Generated with Claude Code