adriangb opened a new issue, #25340:
URL: https://github.com/apache/datafusion/issues/25340
### Describe the bug
`<constant> NOT IN (<subquery>)` in a `WHERE` clause gives wrong results
when the subquery result contains NULL.
`3 NOT IN (1, NULL)` is UNKNOWN, so a `WHERE` clause must remove the row.
DataFusion keeps every row. DuckDB 1.5.2 and PostgreSQL 17.11 return no rows.
This is a silent wrong-results bug. There is no error and no warning.
The same expression in a `SELECT` list is correct (it returns `NULL`). The
bug occurs only when the optimizer rewrites the subquery to a join.
Tested on commit `a0631edb77` (`datafusion-cli` 55.1.0, release build).
### To Reproduce
```sql
CREATE TABLE t1(id INT) AS VALUES (1), (2);
CREATE TABLE t2(id INT) AS VALUES (1), (NULL);
SELECT id FROM t1 WHERE 3 NOT IN (SELECT id FROM t2) ORDER BY id;
```
`datafusion-cli`:
```
+----+
| id |
+----+
| 1 |
| 2 |
+----+
2 row(s) fetched.
```
Expected: no rows. The subquery result is `{1, NULL}`. `3 NOT IN {1, NULL}`
is UNKNOWN for every row of `t1`.
More queries on the same tables:
```sql
-- Q2
SELECT id FROM t1 WHERE NOT (3 IN (SELECT id FROM t2)) ORDER BY id;
-- Q3
SELECT id FROM t1 WHERE 3 NOT IN (SELECT id FROM t2) OR id = 1 ORDER BY id;
-- Q4
SELECT id FROM t1 WHERE (3 NOT IN (SELECT id FROM t2)) IS NULL ORDER BY id;
-- Q5
SELECT 3 NOT IN (SELECT id FROM t2) AS r;
-- C1 (the constant is in the subquery result)
SELECT id FROM t1 WHERE 1 NOT IN (SELECT id FROM t2) ORDER BY id;
-- C2 (no NULL in the subquery result)
SELECT id FROM t1 WHERE 3 NOT IN (SELECT id FROM t2 WHERE id IS NOT NULL)
ORDER BY id;
-- C3 (the value expression refers to a column)
SELECT id FROM t1 WHERE id + 2 NOT IN (SELECT id FROM t2) ORDER BY id;
```
| Query | DataFusion `a0631edb77` | DuckDB 1.5.2 | PostgreSQL 17.11 |
Correct |
| --- | --- | --- | --- | --- |
| Q1 `WHERE 3 NOT IN (...)` | 1, 2 | (no rows) | (no rows) | (no rows) |
| Q2 `WHERE NOT (3 IN (...))` | 1, 2 | (no rows) | (no rows) | (no rows) |
| Q3 `WHERE 3 NOT IN (...) OR id = 1` | 1, 2 | 1 | 1 | 1 |
| Q4 `WHERE (3 NOT IN (...)) IS NULL` | (no rows) | 1, 2 | 1, 2 | 1, 2 |
| Q5 `SELECT 3 NOT IN (...)` | NULL | NULL | NULL | NULL |
| C1 | (no rows) | (no rows) | (no rows) | (no rows) |
| C2 | 1, 2 | 1, 2 | 1, 2 | 1, 2 |
| C3 | (no rows) | (no rows) | (no rows) | (no rows) |
Q1 to Q4 are wrong. Q5 and the controls are correct.
### Expected behavior
Q1 and Q2 return no rows. Q3 returns `1`. Q4 returns `1` and `2`.
### Additional context
`EXPLAIN` for Q1 (`datafusion.explain.format = 'indent'`):
```
logical_plan
LeftAnti Join: null_aware
TableScan: t1 projection=[id]
SubqueryAlias: __correlated_sq_1
Projection:
Filter: t2.id = Int32(3)
TableScan: t2 projection=[id]
physical_plan
NestedLoopJoinExec: join_type=RightAnti
FilterExec: id@0 = 3, projection=[]
DataSourceExec: partitions=1, partition_sizes=[1]
DataSourceExec: partitions=1, partition_sizes=[1]
```
`EXPLAIN` for Q3:
```
logical_plan
Projection: t1.id
Filter: NOT __correlated_sq_1.mark OR t1.id = Int32(1)
LeftMark Join:
TableScan: t1 projection=[id]
SubqueryAlias: __correlated_sq_1
Projection: CAST(t2.id AS Int64)
Filter: t2.id = Int32(3)
TableScan: t2 projection=[id]
physical_plan
FilterExec: NOT mark@1 OR id@0 = 1, projection=[id@0]
RepartitionExec: partitioning=RoundRobinBatch(12), input_partitions=1
NestedLoopJoinExec: join_type=RightMark
CoalescePartitionsExec
ProjectionExec: expr=[CAST(id@0 AS Int64) as t2.id]
RepartitionExec: partitioning=RoundRobinBatch(12),
input_partitions=1
FilterExec: id@0 = 3
DataSourceExec: partitions=1, partition_sizes=[1]
DataSourceExec: partitions=1, partition_sizes=[1]
```
#### Suspected root cause
1. `DecorrelatePredicateSubquery` rewrites Q1 to `LeftAnti Join: Filter:
Int64(3) = __correlated_sq_1.id null_aware`. The value expression `3` has no
column, so this predicate is not an equi-join key. It stays in the join filter.
2. `push_down_filter` moves that join filter into the subquery side as
`Filter: t2.id = 3`. In `push_down_all_join`
([`push_down_filter.rs`](https://github.com/apache/datafusion/blob/a0631edb77/datafusion/optimizer/src/push_down_filter.rs#L449-L459)),
`on_lr_is_preserved(LeftAnti)` lets a right-only join filter conjunct go to
the right input. That is correct for a normal anti join. For a null-aware join
it is not correct: the pushed filter removes the NULL rows before the join can
see them. https://github.com/apache/datafusion/pull/23901 already stopped
`push_down_filter` from *inferring* predicates for null-aware joins (the
`join.null_aware` check in `infer_join_predicates`), but the pushdown of the
join's own filter has no such check.
3. The join now has no equi-join keys, so the physical planner creates a
`NestedLoopJoinExec`
([`physical_planner.rs`](https://github.com/apache/datafusion/blob/a0631edb77/datafusion/core/src/physical_planner.rs#L1608)).
`NestedLoopJoinExec::try_new` has no `null_aware` parameter, so a null-aware
join without equi-join keys cannot keep `NOT IN` semantics. The flag is dropped
without an error.
For the mark join in Q3 and Q4, step 1 already gives a join that is not
null-aware, because `build_join` only sets `null_aware` on a `LeftMark` join
when the whole join filter is hashable (the same gate as in
https://github.com/apache/datafusion/issues/25336). Steps 2 and 3 then apply in
the same way.
https://github.com/apache/datafusion/pull/25339 (the fix for
https://github.com/apache/datafusion/issues/25336) does not fix this bug. It
removes the `LeftMark` gate, but Q1 to Q4 give the same results on that branch,
because steps 2 and 3 still apply.
#### Fix sketch
- In `push_down_all_join`, do not push join filter conjuncts into the right
input of a `null_aware` join.
- Keep the value predicate as a hash join key when the value expression has
no column, for example by projecting the value expression as a column on the
outer side. Then the existing null-aware hash join handles it.
- In the physical planner, do not silently drop `null_aware`: return an
error instead of a nested loop join that gives wrong results.
--
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]