adriangb opened a new issue, #25480:
URL: https://github.com/apache/datafusion/issues/25480

   ### Describe the bug
   
   `x NOT IN (SELECT y FROM inner WHERE y = x)` returns no rows when `inner.y` 
holds a NULL. The correct answer is every row of the outer table whose `x` is 
not in `inner.y`.
   
   The correlation predicate `y = x` and the `IN` predicate `x = y` are the 
same equality. `PullUpCorrelatedExpr` removes the duplicate, so the correlation 
is gone from the plan. What is left is the **uncorrelated** `x NOT IN (SELECT y 
FROM inner)`, and that query is UNKNOWN for every row as soon as `inner.y` 
holds a NULL.
   
   The removal is correct for `IN` (a `LeftSemi` join), because `x IN (SELECT y 
FROM inner WHERE y = x)` and `x IN (SELECT y FROM inner)` give the same answer. 
It is not correct for `NOT IN`. The correlation makes the subquery result `{x}` 
or the empty set, and neither can hold a NULL, so a correlated `NOT IN` of this 
shape is never UNKNOWN.
   
   There is no error and no warning.
   
   ### To Reproduce
   
   ```sql
   CREATE TABLE t1(id INT, z INT) AS VALUES (1,10), (2,20), (NULL,30), (4,40);
   CREATE TABLE t2(id INT, z INT) AS VALUES (1,5), (NULL,50), (5,10);
   
   SELECT id FROM t1 WHERE id NOT IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id) 
ORDER BY id;
   ```
   
   `datafusion-cli`:
   
   ```
   +----+
   | id |
   +----+
   +----+
   0 row(s) fetched.
   ```
   
   Expected: `2`, `4`, `NULL`. DuckDB 1.5.2 gives those three rows.
   
   #### Why `2`, `4` and `NULL` are correct
   
   The subquery result is different for each row of `t1`.
   
   | `t1.id` | subquery result | `id NOT IN ...` | keep the row? |
   | --- | --- | --- | --- |
   | 1 | `{1}` | FALSE | no |
   | 2 | `{}` | TRUE | yes |
   | NULL | `{}` | TRUE | yes |
   | 4 | `{}` | TRUE | yes |
   
   The row `t2.id = NULL` never reaches a subquery result, because `NULL = 
t1.id` is UNKNOWN for every value of `t1.id`. Therefore no row of this query is 
UNKNOWN.
   
   #### Full matrix
   
   The same two tables. `z = 40` is only there to move the `NOT IN` into a 
larger expression.
   
   | # | Query (`... FROM t1 WHERE`) | Correct | DataFusion |
   | --- | --- | --- | --- |
   | Q1 | `id NOT IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id)` | `2, 4, NULL` 
| *(none)* ❌ |
   | Q2 | `id NOT IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id) OR z = 40` | 
`2, 4, NULL` | `4` ❌ |
   | Q3 | `(id NOT IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id)) IS NULL` | 
*(none)* | `2, 4, NULL` ❌ |
   | Q4 | `SELECT id, id NOT IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id) AS m 
FROM t1` | `1 false, 2 true, 4 true, NULL true` | same ✅ |
   | Q5 | `id IN (SELECT t2.id FROM t2 WHERE t2.id = t1.id)` | `1` | `1` ✅ |
   | C1 | `id NOT IN (SELECT t2.id FROM t2 WHERE t2.z = t1.z)` (correlation on 
another column) | `1, 2, 4, NULL` | planner error, see #25336 |
   | C2 | `id NOT IN (SELECT t2.id FROM t2)` (no correlation) | *(none)* | 
*(none)* ✅ |
   
   Q1, Q2 and Q3 are wrong. Q4 goes through the three-join materialization of a 
projected `IN`, which is not affected.
   
   ### Expected behavior
   
   Q1 and Q2 give `2`, `4` and `NULL`. Q3 gives no rows.
   
   ### Additional context
   
   Tested with a release `datafusion-cli` built from `main` at `c4f5a9e0f2`. 
The results are the same on the branch of #25339, so that pull request does not 
change this shape.
   
   `EXPLAIN` for Q1 shows that the correlation is gone. The subquery side is a 
bare `TableScan: t2`, and the join key is the `IN` equality:
   
   ```
   logical_plan
   LeftAnti Join: t1.id = __correlated_sq_1.id null_aware
     TableScan: t1 projection=[id]
     SubqueryAlias: __correlated_sq_1
       TableScan: t2 projection=[id]
   physical_plan
   HashJoinExec: mode=CollectLeft, join_type=LeftAnti, on=[(id@0, id@0)], 
null_aware
     DataSourceExec: partitions=1, partition_sizes=[1]
     DataSourceExec: partitions=1, partition_sizes=[1]
   ```
   
   This is the plan of the uncorrelated `SELECT id FROM t1 WHERE id NOT IN 
(SELECT id FROM t2)`, which correctly gives no rows.
   
   #### Suspected root cause
   
   `PullUpCorrelatedExpr` removes a correlation filter that is the same 
expression as the `IN` predicate:
   
   
https://github.com/apache/datafusion/blob/c4f5a9e0f2/datafusion/optimizer/src/decorrelate.rs#L185-L190
   
   ```rust
   let (mut join_filters, subquery_filters) = 
find_join_exprs(subquery_filter_exprs)?;
   if let Some(in_predicate) = &self.in_predicate_opt {
       // in_predicate may be already included in the join filters, remove it 
from the join filters first.
       join_filters = remove_duplicated_filter(join_filters, in_predicate)?;
   }
   ```
   
   `remove_duplicated_filter` also treats the swapped operand order as the same 
expression, so `t2.id = t1.id` matches the `IN` predicate `t1.id = t2.id`.
   
   For a `LeftSemi` join the removal changes nothing. For a null-aware 
`LeftAnti` or `LeftMark` join it drops the very predicate that keeps the NULLs 
out of the subquery result.
   
   #### Fix sketch
   
   - Keep the duplicate correlation predicate when the join is a null-aware 
`LeftAnti` or `LeftMark` join. The join then has the `IN` value key and the 
correlation scope key, which #25339 supports. Note that `on[0]` must stay the 
`IN` value key.
   - Or, because the subquery result of this shape can never hold a NULL, plan 
it as a plain (not null-aware) anti join plus the `x IS NULL` case. This is the 
cheaper plan, but it needs the optimizer to prove that the correlation is an 
equality on the `IN` value expression itself.
   
   ### Related
   
   - #25336 — a correlated `NOT IN` with a non-equality correlation. Found 
while the review of #25339 checked the shapes around it.
   - #25347 — a correlated `NOT IN` with a constant value.
   


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