Skip to content

Commit 82f1b36

Browse files
authored
fix: NOT IN with NULL subquery returns wrong results under SortMergeJoin (#22810)
## Problem `NOT IN (subquery)` is a null-aware anti join: when the subquery yields a NULL the predicate is never TRUE, so the query must return zero rows. With `prefer_hash_join = false` and multiple partitions, the planner routed the null-aware anti join to `SortMergeJoinExec`, which is not null-aware, so it returned wrong results. HashJoin (the default) was already correct. ## Proof ```sql set datafusion.optimizer.prefer_hash_join = false; create table t1(x int) as values (1); create table t2(y int) as values (NULL); select x from t1 where x not in (select y from t2); ``` Expected 0 rows (the subquery contains a NULL). Before this change it returned `1`. With `prefer_hash_join = true` it correctly returned 0 rows. `EXPLAIN` showed the wrong config selecting `SortMergeJoinExec: join_type=LeftAnti`. ## Solution The planner already requires null-aware joins to use the CollectLeft HashJoin, and the HashJoin branch guards on `!null_aware`. The SortMergeJoin branch was missing the same guard, so this adds `&& !*null_aware` to it. Null-aware anti joins now fall through to the CollectLeft HashJoin regardless of `prefer_hash_join`. `SortMergeJoinExec` has no `null_aware` parameter and cannot honor these semantics. Added a regression test in `subquery.slt` (under `prefer_hash_join = false`) covering both a null-containing subquery (zero rows) and a null-free subquery (normal anti join). All 61 SortMergeJoin unit tests pass.
1 parent b8d3b0b commit 82f1b36

2 files changed

Lines changed: 63 additions & 0 deletions

File tree

datafusion/core/src/physical_planner.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,11 @@ impl DefaultPhysicalPlanner {
18031803
} else if session_state.config().target_partitions() > 1
18041804
&& session_state.config().repartition_joins()
18051805
&& !prefer_hash_join
1806+
&& !*null_aware
1807+
// Null-aware joins (e.g. `NOT IN` with a nullable subquery) must
1808+
// use the CollectLeft HashJoin below: SortMergeJoinExec does not
1809+
// implement null-aware anti-join semantics and would return wrong
1810+
// results when the right side contains a null join key.
18061811
{
18071812
// Use SortMergeJoin if hash join is not preferred
18081813
let join_on_len = join_on.len();

datafusion/sqllogictest/test_files/subquery.slt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2599,3 +2599,61 @@ DROP TABLE sq_count_customer;
25992599

26002600
statement ok
26012601
DROP TABLE sq_count_orders;
2602+
2603+
# Regression test: `NOT IN` is a null-aware anti join. When the subquery yields a
2604+
# NULL the predicate is never TRUE, so the query must return zero rows. This must
2605+
# hold regardless of the chosen physical join operator. Previously, with
2606+
# prefer_hash_join = false and multiple partitions, the planner routed the
2607+
# null-aware anti join to SortMergeJoin (which is not null-aware) and returned
2608+
# wrong results; null-aware anti joins must use the CollectLeft HashJoin.
2609+
2610+
statement ok
2611+
set datafusion.optimizer.prefer_hash_join = false;
2612+
2613+
statement ok
2614+
CREATE TABLE nia_left(x INT) AS VALUES (1), (2), (3), (4);
2615+
2616+
statement ok
2617+
CREATE TABLE nia_right_with_null(y INT) AS VALUES (2), (NULL);
2618+
2619+
statement ok
2620+
CREATE TABLE nia_right_no_null(y INT) AS VALUES (2), (4);
2621+
2622+
# Subquery contains a NULL -> NOT IN must return no rows.
2623+
query I
2624+
SELECT x FROM nia_left WHERE x NOT IN (SELECT y FROM nia_right_with_null) ORDER BY x;
2625+
----
2626+
2627+
# The null-aware anti join must be planned as a CollectLeft HashJoinExec even with
2628+
# prefer_hash_join = false: SortMergeJoinExec is not null-aware and must not be used.
2629+
query TT
2630+
EXPLAIN SELECT x FROM nia_left WHERE x NOT IN (SELECT y FROM nia_right_with_null);
2631+
----
2632+
logical_plan
2633+
01)LeftAnti Join: nia_left.x = __correlated_sq_1.y null_aware
2634+
02)--TableScan: nia_left projection=[x]
2635+
03)--SubqueryAlias: __correlated_sq_1
2636+
04)----TableScan: nia_right_with_null projection=[y]
2637+
physical_plan
2638+
01)HashJoinExec: mode=CollectLeft, join_type=LeftAnti, on=[(x@0, y@0)], null_aware
2639+
02)--DataSourceExec: partitions=1, partition_sizes=[1]
2640+
03)--DataSourceExec: partitions=1, partition_sizes=[1]
2641+
2642+
# Subquery has no NULL -> NOT IN behaves like a normal anti join.
2643+
query I
2644+
SELECT x FROM nia_left WHERE x NOT IN (SELECT y FROM nia_right_no_null) ORDER BY x;
2645+
----
2646+
1
2647+
3
2648+
2649+
statement ok
2650+
DROP TABLE nia_left;
2651+
2652+
statement ok
2653+
DROP TABLE nia_right_with_null;
2654+
2655+
statement ok
2656+
DROP TABLE nia_right_no_null;
2657+
2658+
statement ok
2659+
reset datafusion.optimizer.prefer_hash_join;

0 commit comments

Comments
 (0)