From 0c3d7fe120755f4a5675051dfa7ac3d9b4a4046c Mon Sep 17 00:00:00 2001 From: bvolpato Date: Thu, 3 Sep 2026 13:15:35 -0400 Subject: [PATCH] Merge ANTI JOIN should preserve unmatched NULL keys --- .../calcite/runtime/EnumerablesTest.java | 27 ++++++++++++++++++- .../calcite/linq4j/EnumerableDefaults.java | 13 ++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/core/src/test/java/org/apache/calcite/runtime/EnumerablesTest.java b/core/src/test/java/org/apache/calcite/runtime/EnumerablesTest.java index 575a8bfec651..e21397826c9b 100644 --- a/core/src/test/java/org/apache/calcite/runtime/EnumerablesTest.java +++ b/core/src/test/java/org/apache/calcite/runtime/EnumerablesTest.java @@ -192,30 +192,35 @@ class EnumerablesTest { newArrayList(1, 3, 4), newArrayList(1, 4), equalTo("[3]"), + equalTo("[3, null]"), JoinType.ANTI); // Matching key at start and end of right, not of left testIntersect( newArrayList(0, 1, 3, 4, 5), newArrayList(1, 4), equalTo("[0, 3, 5]"), + equalTo("[0, 3, 5, null]"), JoinType.ANTI); // Matching key at start and end of left, not right testIntersect( newArrayList(1, 3, 4), newArrayList(0, 1, 4, 5), equalTo("[3]"), + equalTo("[3, null]"), JoinType.ANTI); // Matching key not at start or end of left or right testIntersect( newArrayList(0, 2, 3, 4, 5), newArrayList(1, 3, 4, 6), equalTo("[0, 2, 5]"), + equalTo("[0, 2, 5, null]"), JoinType.ANTI); // Matching duplicated keys testIntersect( newArrayList(1, 3, 4), newArrayList(1, 1, 4, 4), equalTo("[3]"), + equalTo("[3, null]"), JoinType.ANTI); // LEFT join tests: @@ -291,24 +296,28 @@ class EnumerablesTest { newArrayList(0, 2, 4), newArrayList(1, 3, 5), equalTo("[0, 2, 4]"), + equalTo("[0, 2, 4, null]"), JoinType.ANTI); // Left empty testIntersect( new ArrayList<>(), newArrayList(1, 3, 4, 6), equalTo("[]"), + equalTo("[null]"), JoinType.ANTI); // Right empty testIntersect( newArrayList(3, 7), new ArrayList<>(), equalTo("[3, 7]"), + equalTo("[3, 7, null]"), JoinType.ANTI); // Both empty testIntersect( new ArrayList(), new ArrayList<>(), equalTo("[]"), + equalTo("[null]"), JoinType.ANTI); // LEFT join tests: @@ -342,6 +351,21 @@ class EnumerablesTest { JoinType.LEFT); } + @Test void testMergeAntiJoinWithRepeatedNullKeys() { + testIntersect( + newArrayList(1, 2, null, null), + newArrayList(1), + equalTo("[2, null, null]"), + equalTo("[2, null, null, null]"), + JoinType.ANTI); + testIntersect( + newArrayList(1, null, null), + newArrayList(1, 2), + equalTo("[null, null]"), + equalTo("[null, null, null]"), + JoinType.ANTI); + } + private static > void testIntersect( List list0, List list1, org.hamcrest.Matcher matcher, JoinType joinType) { testIntersect(list0, list1, matcher, matcher, joinType); @@ -636,7 +660,8 @@ private static > Enumerable intersect( (v0, v1) -> v0, JoinType.ANTI, null, null).toList(), - hasToString("[Emp(30, Fred), Emp(20, Sebastian), Emp(20, Zoey)]")); + hasToString("[Emp(30, Fred), Emp(20, Sebastian), Emp(20, Zoey), " + + "Emp(40, null), Emp(30, null)]")); } @Test void testMergeLeftJoin() { diff --git a/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java b/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java index 0eca4b6d5283..395d8c26bdcf 100644 --- a/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java +++ b/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java @@ -5047,11 +5047,11 @@ private Enumerator getRightEnumerator() { } /** Returns whether the left enumerator was successfully advanced to the next - * element, and it does not have a null key (except for LEFT join, that needs to process - * all elements from left. */ + * element, and it does not have a null key (except for LEFT and ANTI joins, which need to + * process all elements from left). */ private boolean leftMoveNext() { return getLeftEnumerator().moveNext() - && (joinType == JoinType.LEFT + && (isLeftOrAntiJoin() || outerKeySelector.apply(getLeftEnumerator().current()) != null); } @@ -5125,7 +5125,7 @@ private boolean advance() { // mergeJoin assumes inputs sorted in ascending order with nulls last, // if we reach a null key, we are done. if (leftKey == null || rightKey == null) { - if (joinType == JoinType.LEFT || (joinType == JoinType.ANTI && leftKey != null)) { + if (isLeftOrAntiJoin()) { // all remaining items in left are results for left/anti join remainingLeft = true; return true; @@ -5234,9 +5234,10 @@ private boolean advanceLeft(TSource left, TKey leftKey) { while (getLeftEnumerator().moveNext()) { left = getLeftEnumerator().current(); TKey leftKey2 = outerKeySelector.apply(left); - if (leftKey2 == null && joinType != JoinType.LEFT) { + if (leftKey2 == null && !isLeftOrAntiJoin()) { // mergeJoin assumes inputs sorted in ascending order with nulls last, - // if we reach a null key, we are done (except LEFT join, that needs to process LHS fully) + // if we reach a null key, we are done (except LEFT and ANTI joins, which + // process LHS fully) break; } if (!compareEquals(leftKey, leftKey2)) {