Describe the bug
A correlated filter that sits on the nullable side of an outer join inside a subquery is pulled out of the subquery and attached to the decorrelated join. The filter then applies after the outer join instead of before it, so the rows that the outer join extends with NULLs are different and the query gives wrong results.
This affects EXISTS, IN and NOT IN subqueries. It is not specific to three-valued logic: the EXISTS form below is wrong as well.
To Reproduce
CREATE TABLE o(k INT) AS VALUES (1), (5);
CREATE TABLE a(id INT) AS VALUES (1), (2);
CREATE TABLE b(id INT, y INT) AS VALUES (1, 1), (2, 2);
The subquery keeps only the rows of b where b.y = o.k, then left joins a to them. For o.k = 5 no row of b remains, so every row of a is unmatched and b.y is NULL for all of them.
EXISTS form
SELECT o.k,
EXISTS (SELECT 1
FROM a LEFT JOIN (SELECT * FROM b WHERE b.y = o.k) AS b
ON a.id = b.id
WHERE b.y IS NULL) AS e
FROM o ORDER BY k;
| k |
DataFusion |
DuckDB 1.5.2 |
| 1 |
false |
true |
| 5 |
false |
true |
For o.k = 1 only b.id = 1 remains, so a.id = 2 is unmatched and b.y is NULL for it. For o.k = 5 both rows of a are unmatched. The correct answer is true for both rows.
IN form
SELECT o.k,
o.k IN (SELECT b.y
FROM a LEFT JOIN (SELECT * FROM b WHERE b.y = o.k) AS b
ON a.id = b.id) AS m
FROM o ORDER BY k;
| k |
DataFusion |
DuckDB 1.5.2 |
| 1 |
true |
true |
| 5 |
false |
NULL |
For o.k = 5 the subquery gives {NULL, NULL}, so 5 IN (...) is UNKNOWN.
Expected behavior
The results of DuckDB above.
Additional context
The plan shows the cause. Filter: b.y = o.k is gone from below the Left Join and is now the condition of the mark join:
Projection: o.k, __correlated_sq_1.mark AS e
LeftMark Join: o.k = __correlated_sq_1.y
TableScan: o projection=[k]
SubqueryAlias: __correlated_sq_1
Filter: b.y IS NULL
Projection: b.y
Left Join: a.id = b.id
TableScan: a projection=[id]
SubqueryAlias: b
TableScan: b projection=[id, y]
Filter: b.y IS NULL and o.k = __correlated_sq_1.y cannot both hold, so the mark is always false.
PullUpCorrelatedExpr in datafusion/optimizer/src/decorrelate.rs pulls a correlated Filter up through every node it does not know, and LogicalPlan::Join is one of those nodes. A correlated filter can move above an Inner join without a change of meaning, but not above the nullable side of an outer join.
Found on main at 64871d9. It is independent of #25480 and of #25338.
#25283 is the same theme in a different node: PullUpCorrelatedExpr walks through a plan node that it does not model, there a Limit with an OFFSET, here a Join. A fix can probably reuse the unsupported() helper that #25284 adds.
Describe the bug
A correlated filter that sits on the nullable side of an outer join inside a subquery is pulled out of the subquery and attached to the decorrelated join. The filter then applies after the outer join instead of before it, so the rows that the outer join extends with NULLs are different and the query gives wrong results.
This affects
EXISTS,INandNOT INsubqueries. It is not specific to three-valued logic: theEXISTSform below is wrong as well.To Reproduce
The subquery keeps only the rows of
bwhereb.y = o.k, then left joinsato them. Foro.k = 5no row ofbremains, so every row ofais unmatched andb.yis NULL for all of them.EXISTSformfalsetruefalsetrueFor
o.k = 1onlyb.id = 1remains, soa.id = 2is unmatched andb.yis NULL for it. Foro.k = 5both rows ofaare unmatched. The correct answer istruefor both rows.INformtruetruefalseNULLFor
o.k = 5the subquery gives{NULL, NULL}, so5 IN (...)is UNKNOWN.Expected behavior
The results of DuckDB above.
Additional context
The plan shows the cause.
Filter: b.y = o.kis gone from below theLeft Joinand is now the condition of the mark join:Filter: b.y IS NULLando.k = __correlated_sq_1.ycannot both hold, so the mark is alwaysfalse.PullUpCorrelatedExprindatafusion/optimizer/src/decorrelate.rspulls a correlatedFilterup through every node it does not know, andLogicalPlan::Joinis one of those nodes. A correlated filter can move above anInnerjoin without a change of meaning, but not above the nullable side of an outer join.Found on
mainat 64871d9. It is independent of #25480 and of #25338.#25283 is the same theme in a different node:
PullUpCorrelatedExprwalks through a plan node that it does not model, there aLimitwith anOFFSET, here aJoin. A fix can probably reuse theunsupported()helper that #25284 adds.