Skip to content

Commit 0fcf628

Browse files
authored
fix: don't infer join predicates for null-aware joins in push_down_filter (apache#23901)
## Which issue does this PR close? - Closes apache#23900. ## Rationale for this change `push_down_filter` infers equi-key predicates across a join's ON keys and pushes them to the opposite side. For a null-aware join (the `LeftAnti` join produced by `NOT IN` with a nullable subquery), an outer predicate on the left key like `outer.id > 5` is rewritten to `sub.id > 5` and pushed onto the subquery input. Since the inferred predicate must be null-rejecting to be pushed, this drops the subquery's NULL rows and breaks the three-valued `NOT IN` semantics — a NULL in the subquery key must reach the join so the result is empty. Same class of bug as apache#23848, in a different rule. ## What changes are included in this PR? - Skip predicate inference in `infer_join_predicates` when `join.null_aware` is set (mirrors the apache#23848 guard on `FilterNullJoinKeys`). - A `push_down_filter` unit test asserting no predicate is inferred onto the subquery side of a null-aware `LeftAnti` join. - SLT coverage for the failing query, plus a `prefer_hash_join = false` / multi-partition variant. ## Are these changes tested? Yes — new unit test (verified it fails without the guard) and SLT cases. The full optimizer lib suite passes. ## Are there any user-facing changes? No, aside from the correctness fix.
1 parent 6d76482 commit 0fcf628

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

datafusion/optimizer/src/push_down_filter.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,17 @@ fn infer_join_predicates(
576576
predicates: &[Expr],
577577
on_filters: &[Expr],
578578
) -> Result<Vec<Expr>> {
579+
// Null-aware joins (e.g. `NOT IN` with a nullable subquery) rely on SQL
580+
// three-valued logic: a NULL join key on the right/subquery side makes the
581+
// predicate UNKNOWN and empties the result, so those NULLs must reach the
582+
// join. Inferring an equi-key predicate here would rewrite a left-side
583+
// predicate onto the right side and, because the inferred predicate must be
584+
// null-rejecting, drop the subquery's NULL rows and produce wrong results.
585+
// Skip inference entirely for null-aware joins.
586+
if join.null_aware {
587+
return Ok(vec![]);
588+
}
589+
579590
// Only allow both side key is column.
580591
let join_col_keys = join
581592
.on
@@ -3826,6 +3837,51 @@ mod tests {
38263837
)
38273838
}
38283839

3840+
/// Regression test: for a null-aware LeftAnti join (the shape produced by
3841+
/// `NOT IN` with a nullable subquery), a right-side predicate must NOT be
3842+
/// inferred onto the join. Inference would push a null-rejecting predicate
3843+
/// to the subquery side, dropping its NULL rows and breaking the
3844+
/// three-valued `NOT IN` semantics.
3845+
#[test]
3846+
fn null_aware_left_anti_join_no_inferred_pushdown() -> Result<()> {
3847+
let table_scan = test_table_scan_with_name("test1")?;
3848+
let left = LogicalPlanBuilder::from(table_scan)
3849+
.project(vec![col("a"), col("b")])?
3850+
.build()?;
3851+
let right_table_scan = test_table_scan_with_name("test2")?;
3852+
let right = LogicalPlanBuilder::from(right_table_scan)
3853+
.project(vec![col("a"), col("b")])?
3854+
.build()?;
3855+
let plan = LogicalPlanBuilder::from(left)
3856+
.join_detailed_with_options(
3857+
right,
3858+
JoinType::LeftAnti,
3859+
(
3860+
vec![Column::from_qualified_name("test1.a")],
3861+
vec![Column::from_qualified_name("test2.a")],
3862+
),
3863+
None,
3864+
datafusion_common::NullEquality::NullEqualsNothing,
3865+
true,
3866+
)?
3867+
.filter(col("test1.a").gt(lit(2u32)))?
3868+
.build()?;
3869+
3870+
// The left-side filter is pushed to the left input, but — unlike the
3871+
// non-null-aware `left_anti_join` test — no `test2.a > 2` predicate is
3872+
// inferred onto the right/subquery side.
3873+
assert_optimized_plan_equal!(
3874+
plan,
3875+
@r"
3876+
LeftAnti Join: test1.a = test2.a null_aware
3877+
Projection: test1.a, test1.b
3878+
TableScan: test1, full_filters=[test1.a > UInt32(2)]
3879+
Projection: test2.a, test2.b
3880+
TableScan: test2
3881+
"
3882+
)
3883+
}
3884+
38293885
#[test]
38303886
fn left_anti_join_with_filters() -> Result<()> {
38313887
let table_scan = test_table_scan_with_name("test1")?;

datafusion/sqllogictest/test_files/null_aware_anti_join.slt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,3 +530,48 @@ RESET datafusion.execution.parquet.pushdown_filters;
530530

531531
statement ok
532532
RESET datafusion.optimizer.enable_join_dynamic_filter_pushdown;
533+
534+
#############
535+
## Regression: null-aware NOT IN with an outer predicate on the join key
536+
##
537+
## `push_down_filter` used to infer the outer predicate `id > 5` onto the
538+
## subquery side (as `eid > 5`), dropping the subquery's NULL row and wrongly
539+
## returning outer rows. The subquery NULL must reach the join so that
540+
## `NOT IN` stays UNKNOWN for every row.
541+
#############
542+
543+
statement ok
544+
CREATE TABLE nai_outer(id INT) AS VALUES (3), (7);
545+
546+
statement ok
547+
CREATE TABLE nai_inner(id INT) AS VALUES (NULL);
548+
549+
# Expected: zero rows (subquery contains NULL => NOT IN is UNKNOWN for all).
550+
query I
551+
SELECT id FROM nai_outer WHERE id > 5 AND id NOT IN (SELECT id FROM nai_inner) ORDER BY id;
552+
----
553+
554+
# Same query under SortMergeJoin + multiple partitions: null-aware joins must
555+
# be planned as a CollectLeft HashJoin, not a plain anti SortMergeJoin.
556+
statement ok
557+
SET datafusion.optimizer.prefer_hash_join = false;
558+
559+
statement ok
560+
SET datafusion.execution.target_partitions = 4;
561+
562+
query I
563+
SELECT id FROM nai_outer WHERE id NOT IN (SELECT id FROM nai_inner) ORDER BY id;
564+
----
565+
566+
statement ok
567+
SET datafusion.optimizer.prefer_hash_join = true;
568+
569+
# The SLT runner sets target_partitions to 4, so restore that value explicitly.
570+
statement ok
571+
SET datafusion.execution.target_partitions = 4;
572+
573+
statement ok
574+
DROP TABLE nai_outer;
575+
576+
statement ok
577+
DROP TABLE nai_inner;

0 commit comments

Comments
 (0)