This is an automated email from the ASF dual-hosted git repository.
rubenada pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git
The following commit(s) were added to refs/heads/main by this push:
new f064f0744a [CALCITE-7919] Enumerable comparators fail with
ClassCastException on ROW, ARRAY, MULTISET and MAP keys
f064f0744a is described below
commit f064f0744ad5aad66fdf7ca2d08cb4baf3adffe8
Author: Vladislav Pyatkov <[email protected]>
AuthorDate: Mon Nov 9 13:50:02 2026 +0300
[CALCITE-7919] Enumerable comparators fail with ClassCastException on ROW,
ARRAY, MULTISET and MAP keys
---
.../calcite/adapter/enumerable/PhysTypeImpl.java | 78 +++++++----
.../java/org/apache/calcite/runtime/Utilities.java | 35 +++++
.../apache/calcite/test/CollectionTypeTest.java | 144 +++++++++++++++++++++
.../org/apache/calcite/test/SqlFunctionsTest.java | 26 ++++
core/src/test/resources/sql/sort.iq | 70 ++++++++++
.../apache/calcite/linq4j/EnumerableDefaults.java | 24 ++++
6 files changed, 354 insertions(+), 23 deletions(-)
diff --git
a/core/src/main/java/org/apache/calcite/adapter/enumerable/PhysTypeImpl.java
b/core/src/main/java/org/apache/calcite/adapter/enumerable/PhysTypeImpl.java
index f1490a02c0..15350e861c 100644
--- a/core/src/main/java/org/apache/calcite/adapter/enumerable/PhysTypeImpl.java
+++ b/core/src/main/java/org/apache/calcite/adapter/enumerable/PhysTypeImpl.java
@@ -19,6 +19,7 @@
import org.apache.calcite.adapter.java.JavaTypeFactory;
import org.apache.calcite.linq4j.Ord;
import org.apache.calcite.linq4j.function.Function1;
+import org.apache.calcite.linq4j.function.Predicate1;
import org.apache.calcite.linq4j.tree.BlockBuilder;
import org.apache.calcite.linq4j.tree.Expression;
import org.apache.calcite.linq4j.tree.Expressions;
@@ -327,13 +328,10 @@ static PhysType of(
final Expression fieldComparator =
generateCollatorExpression(fieldType.getCollation());
Expression arg0 = fieldReference(parameterV0, index);
Expression arg1 = fieldReference(parameterV1, index);
- switch (Primitive.flavor(fieldClass(index))) {
- case OBJECT:
+ if (Primitive.flavor(fieldClass(index)) == Primitive.Flavor.OBJECT
+ && !isComposite(fieldType)) {
arg0 = EnumUtils.convert(arg0, Comparable.class);
arg1 = EnumUtils.convert(arg1, Comparable.class);
- break;
- default:
- break;
}
final boolean nullsFirst =
collation.nullDirection
@@ -345,17 +343,13 @@ static PhysType of(
Expressions.statement(
Expressions.assign(
parameterC,
- Expressions.call(
- Utilities.class,
+ generateComparison(fieldType,
fieldNullable(index)
? (nullsFirst != descending
? "compareNullsFirst"
: "compareNullsLast")
: "compare",
- Expressions.list(
- arg0,
- arg1)
- .appendIfNotNull(fieldComparator)))));
+ arg0, arg1, fieldComparator))));
body.add(
Expressions.ifThen(
Expressions.notEqual(
@@ -455,13 +449,10 @@ private Expression generateComparator(RelCollation
collation,
final Expression fieldComparator =
generateCollatorExpression(fieldType.getCollation());
Expression arg0 = fieldReference(parameterV0, index);
Expression arg1 = fieldReference(parameterV1, index);
- switch (Primitive.flavor(fieldClass(index))) {
- case OBJECT:
+ if (Primitive.flavor(fieldClass(index)) == Primitive.Flavor.OBJECT
+ && !isComposite(fieldType)) {
arg0 = EnumUtils.convert(arg0, Comparable.class);
arg1 = EnumUtils.convert(arg1, Comparable.class);
- break;
- default:
- break;
}
final boolean descending =
fieldCollation.getDirection()
@@ -470,13 +461,9 @@ private Expression generateComparator(RelCollation
collation,
Expressions.statement(
Expressions.assign(
parameterC,
- Expressions.call(
- Utilities.class,
+ generateComparison(fieldType,
compareMethodNameFunction.apply(fieldCollation),
- Expressions.list(
- arg0,
- arg1)
- .appendIfNotNull(fieldComparator)))));
+ arg0, arg1, fieldComparator))));
body.add(
Expressions.ifThen(
Expressions.notEqual(
@@ -538,12 +525,57 @@ private Expression generateComparator(RelCollation
collation,
// merge join keys must be sorted in ascending order, nulls last
assert fieldCollation.nullDirection ==
RelFieldCollation.NullDirection.LAST;
assert fieldCollation.getDirection() ==
RelFieldCollation.Direction.ASCENDING;
- return fieldNullable(fieldCollation.getFieldIndex())
+ final int index = fieldCollation.getFieldIndex();
+ // Even a non-null ROW can have NULL fields that prevent a join match.
+ return fieldNullable(index) ||
rowType.getFieldList().get(index).getType().isStruct()
? "compareNullsLastForMergeJoin"
: "compare";
});
}
+ private static boolean isComposite(RelDataType type) {
+ return type.isStruct()
+ || type.getComponentType() != null
+ || type.getSqlTypeName() == SqlTypeName.MAP;
+ }
+
+ /** ROW, ARRAY, MULTISET and MAP sort keys are not Comparable.
+ * Use the same element-wise comparator as the single-key sort path. */
+ private static Expression generateComparison(RelDataType type, String method,
+ Expression left, Expression right, @Nullable Expression collator) {
+ if (isComposite(type)) {
+ // Composite types have no collation of their own, so collator is unused
here.
+ final String compositeMethod;
+ switch (method) {
+ case "compare":
+ compositeMethod = "compareComposite";
+ break;
+ case "compareNullsFirst":
+ compositeMethod = "compareCompositeNullsFirst";
+ break;
+ case "compareNullsLast":
+ compositeMethod = "compareCompositeNullsLast";
+ break;
+ case "compareNullsLastForMergeJoin":
+ compositeMethod = "compareCompositeNullsLastForMergeJoin";
+ // Make sure merge join does not match ROW(1, NULL) = ROW(1, NULL);
+ // this is the same check that hash join applies through
generateNullAwareAccessor
+ if (type.isStruct()) {
+ final ParameterExpression row = Expressions.parameter(Object.class,
"row");
+ return Expressions.call(Utilities.class, compositeMethod, left,
right,
+ Expressions.lambda(Predicate1.class,
+ structIsNullOrContainsNullExpression(row, type), row));
+ }
+ break;
+ default:
+ throw new AssertionError("unexpected comparison method: " + method);
+ }
+ return Expressions.call(Utilities.class, compositeMethod, left, right);
+ }
+ return Expressions.call(Utilities.class, method,
+ Expressions.list(left, right).appendIfNotNull(collator));
+ }
+
@Override public RelDataType getRowType() {
return rowType;
}
diff --git a/core/src/main/java/org/apache/calcite/runtime/Utilities.java
b/core/src/main/java/org/apache/calcite/runtime/Utilities.java
index 3409af7224..ba78b123c6 100644
--- a/core/src/main/java/org/apache/calcite/runtime/Utilities.java
+++ b/core/src/main/java/org/apache/calcite/runtime/Utilities.java
@@ -17,6 +17,8 @@
package org.apache.calcite.runtime;
import org.apache.calcite.linq4j.EnumerableDefaults;
+import org.apache.calcite.linq4j.function.Functions;
+import org.apache.calcite.linq4j.function.Predicate1;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -261,6 +263,39 @@ public static int compareNullsLastForMergeJoin(@Nullable
Comparable v0, @Nullabl
return EnumerableDefaults.compareNullsLastForMergeJoin(v0, v1, comparator);
}
+ /** Compares two non-null ROW, ARRAY, MULTISET or MAP values element-wise. */
+ public static int compareComposite(Object v0, Object v1) {
+ return Functions.compareListItems(v0, v1);
+ }
+
+ /** Compares two ROW, ARRAY, MULTISET or MAP values element-wise, nulls
first. */
+ public static int compareCompositeNullsFirst(@Nullable Object v0, @Nullable
Object v1) {
+ return v0 == v1 ? 0
+ : v0 == null ? -1
+ : v1 == null ? 1
+ : Functions.compareListItems(v0, v1);
+ }
+
+ /** Compares two ROW, ARRAY, MULTISET or MAP values element-wise, nulls
last. */
+ public static int compareCompositeNullsLast(@Nullable Object v0, @Nullable
Object v1) {
+ return Functions.compareListItems(v0, v1);
+ }
+
+ /** Merge join variant of {@link #compareCompositeNullsLast}: throws if both
+ * values are null, so that merge join does not match two null keys. */
+ public static int compareCompositeNullsLastForMergeJoin(@Nullable Object v0,
+ @Nullable Object v1) {
+ return compareCompositeNullsLastForMergeJoin(v0, v1, null);
+ }
+
+ /** ROW variant that also rejects equal keys containing NULL fields.
+ * The generated predicate descends into ROW fields, but not collection
elements. */
+ public static int compareCompositeNullsLastForMergeJoin(@Nullable Object v0,
+ @Nullable Object v1, @Nullable Predicate1<Object> containsNull) {
+ return EnumerableDefaults.compareObjectsNullsLastForMergeJoin(v0, v1,
+ Functions::compareListItems, containsNull);
+ }
+
/** Creates a pattern builder. */
public static Pattern.PatternBuilder patternBuilder() {
return Pattern.builder();
diff --git a/core/src/test/java/org/apache/calcite/test/CollectionTypeTest.java
b/core/src/test/java/org/apache/calcite/test/CollectionTypeTest.java
index ba08547ce1..859f674b05 100644
--- a/core/src/test/java/org/apache/calcite/test/CollectionTypeTest.java
+++ b/core/src/test/java/org/apache/calcite/test/CollectionTypeTest.java
@@ -16,14 +16,17 @@
*/
package org.apache.calcite.test;
import org.apache.calcite.DataContext;
+import org.apache.calcite.adapter.enumerable.EnumerableRules;
import org.apache.calcite.config.CalciteConnectionConfig;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.linq4j.AbstractEnumerable;
import org.apache.calcite.linq4j.Enumerable;
import org.apache.calcite.linq4j.Enumerator;
+import org.apache.calcite.plan.RelOptPlanner;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
import org.apache.calcite.runtime.CalciteContextException;
+import org.apache.calcite.runtime.Hook;
import org.apache.calcite.schema.ScannableTable;
import org.apache.calcite.schema.Schema;
import org.apache.calcite.schema.SchemaPlus;
@@ -36,16 +39,21 @@
import org.checkerframework.checker.nullness.qual.Nullable;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
@@ -65,6 +73,142 @@ class CollectionTypeTest {
"SELECT ARRAY[ROW(MOD(\"ID\", 2), CAST(NULL AS INTEGER))] AS a,\n"
+ "\"ARRAYFIELD\"[1] AS n FROM \"T\"";
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7919">[CALCITE-7919]
+ * Enumerable comparators fail with ClassCastException on ROW, ARRAY,
MULTISET and MAP keys</a>.
+ * Tests sorting by composite keys in enumerable operators. */
+ @ParameterizedTest
+ @MethodSource("compositeSortCases")
+ void testSortCompositeKeys(String key, String order, boolean limit,
+ String[] expected) {
+ withNestedTable()
+ .query("SELECT id FROM " + compositeKeyQuery(key)
+ + " AS t ORDER BY k " + order + ", id DESC"
+ + (limit ? " OFFSET 1 ROWS FETCH NEXT 3 ROWS ONLY" : ""))
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner -> {
+ if (limit) {
+ planner.removeRule(EnumerableRules.ENUMERABLE_SORT_RULE);
+ planner.addRule(EnumerableRules.ENUMERABLE_LIMIT_SORT_RULE);
+ }
+ })
+ .explainContains(limit ? "EnumerableLimitSort" : "EnumerableSort")
+ .returnsOrdered(limit ? Arrays.copyOfRange(expected, 1, 4) : expected);
+ }
+
+ private static List<Arguments> compositeSortCases() {
+ final List<Arguments> cases = new ArrayList<>();
+ for (String key : compositeKeys()) {
+ for (boolean limit : new boolean[]{false, true}) {
+ cases.add(
+ Arguments.of(key, "ASC NULLS FIRST", limit,
+ new String[]{"ID=4", "ID=2", "ID=0", "ID=1", "ID=3"}));
+ cases.add(
+ Arguments.of(key, "ASC NULLS LAST", limit,
+ new String[]{"ID=2", "ID=0", "ID=1", "ID=3", "ID=4"}));
+ cases.add(
+ Arguments.of(key, "DESC NULLS FIRST", limit,
+ new String[]{"ID=4", "ID=3", "ID=1", "ID=2", "ID=0"}));
+ cases.add(
+ Arguments.of(key, "DESC NULLS LAST", limit,
+ new String[]{"ID=3", "ID=1", "ID=2", "ID=0", "ID=4"}));
+ }
+ }
+ return cases;
+ }
+
+ private static List<String> compositeKeys() {
+ // IDs 0 and 2 produce equal maps with different entry insertion orders.
+ return Arrays.asList("ARRAY[a, b]", "ROW(a, b)", "ARRAY[ROW(a, b)]",
+ "ARRAY[ARRAY[a, b]]", "ROW(ARRAY[a], ARRAY[b])",
+ "MULTISET[a, b]",
+ "CASE WHEN id = 2 THEN MAP[1, b, 0, a] ELSE MAP[0, a, 1, b] END");
+ }
+
+ private static String compositeKeyQuery(String key) {
+ return compositeKeyQuery(key, true);
+ }
+
+ private static String compositeKeyQuery(String key, boolean nullableKey) {
+ // IDs 0 and 2 have equal keys; ID 3 has a null field; ID 4 optionally has
a null key.
+ return "(SELECT id, " + (nullableKey ? "CASE WHEN id = 4 THEN NULL ELSE "
+ key + " END" : key)
+ + " AS k\n"
+ + "FROM (SELECT \"ID\" AS id, MOD(\"ID\", 2) AS a,\n"
+ + "CASE WHEN \"ID\" = 3 THEN NULL ELSE 0 END AS b FROM \"T\"))";
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7919">[CALCITE-7919]
+ * Enumerable comparators fail with ClassCastException on ROW, ARRAY,
MULTISET and MAP keys</a>.
+ * Tests window ordering by composite keys. */
+ @ParameterizedTest
+ @MethodSource("compositeKeys")
+ void testWindowCompositeKeys(String key) {
+ withNestedTable()
+ .query("SELECT id, ROW_NUMBER() OVER (ORDER BY k NULLS FIRST, id DESC)
AS rn\n"
+ + "FROM " + compositeKeyQuery(key) + " AS t")
+ .explainContains("EnumerableWindow")
+ .returnsUnordered("ID=4; RN=1", "ID=2; RN=2", "ID=0; RN=3",
+ "ID=1; RN=4", "ID=3; RN=5");
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7919">[CALCITE-7919]
+ * Enumerable comparators fail with ClassCastException on ROW, ARRAY,
MULTISET and MAP keys</a>.
+ * Tests that merge and hash joins produce the same results for composite
keys. */
+ @ParameterizedTest
+ @MethodSource("compositeJoinCases")
+ void testMergeJoinCompositeKeys(String key, boolean matchesNullField,
boolean nullableKey) {
+ final String data = compositeKeyQuery(key, nullableKey);
+ for (boolean leftJoin : new boolean[]{false, true}) {
+ final List<String> expected =
+ new ArrayList<>(Arrays.asList("LID=0; RID=0", "LID=1; RID=1",
"LID=2; RID=2"));
+ if (matchesNullField) {
+ expected.add("LID=3; RID=3");
+ } else if (leftJoin) {
+ expected.add("LID=3; RID=null");
+ }
+ if (!nullableKey) {
+ expected.add("LID=4; RID=4");
+ } else if (leftJoin) {
+ expected.add("LID=4; RID=null");
+ }
+ for (boolean mergeJoin : new boolean[]{false, true}) {
+ withNestedTable()
+ .query("SELECT l.id AS lid, r.id AS rid\n"
+ + "FROM " + data + " AS l\n"
+ + (leftJoin ? "LEFT JOIN " : "JOIN ") + data
+ + " AS r ON l.id = r.id AND l.k = r.k")
+ .withHook(Hook.PLANNER, (Consumer<RelOptPlanner>) planner -> {
+ if (mergeJoin) {
+ planner.removeRule(EnumerableRules.ENUMERABLE_JOIN_RULE);
+ planner.addRule(EnumerableRules.ENUMERABLE_MERGE_JOIN_RULE);
+ } else {
+ planner.removeRule(EnumerableRules.ENUMERABLE_MERGE_JOIN_RULE);
+ planner.addRule(EnumerableRules.ENUMERABLE_JOIN_RULE);
+ }
+ })
+ .explainContains(mergeJoin ? "EnumerableMergeJoin" :
"EnumerableHashJoin")
+ .returnsUnordered(expected.toArray(new String[0]));
+ }
+ }
+ }
+
+ private static List<Arguments> compositeJoinCases() {
+ final List<Arguments> cases = new ArrayList<>();
+ for (String key : compositeKeys()) {
+ // NULL fields prevent ROW equality, but NULL elements in collections do
not.
+ cases.add(Arguments.of(key, !key.equals("ROW(a, b)"), true));
+ }
+ cases.add(Arguments.of("ROW(a, ROW(b))", false, true));
+ cases.add(Arguments.of("ROW(a, CASE WHEN b IS NULL THEN NULL ELSE ROW(b)
END)", false, true));
+ cases.add(Arguments.of("ROW(a, CASE WHEN b IS NULL THEN NULL ELSE ARRAY[b]
END)", false, true));
+ // A non-null ROW may still have nullable fields. ID 4 must match after
the NULL field at ID 3.
+ cases.add(Arguments.of("ROW(a, b)", false, false));
+ cases.add(Arguments.of("ROW(a, ROW(b))", false, false));
+ cases.add(Arguments.of("ROW(ARRAY[a], ARRAY[b])", true, false));
+ return cases;
+ }
+
@Test void testDistinctArrayOfRows() {
withNestedTable()
.query("SELECT DISTINCT a, n FROM (" + ARRAY_OF_ROWS_QUERY + ")")
diff --git a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
index bd4be490f3..0338bff519 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
@@ -19,6 +19,7 @@
import org.apache.calcite.avatica.util.DateTimeUtils;
import org.apache.calcite.linq4j.Enumerable;
import org.apache.calcite.linq4j.function.Function1;
+import org.apache.calcite.linq4j.function.Predicate1;
import org.apache.calcite.runtime.CalciteException;
import org.apache.calcite.runtime.FlatLists;
import org.apache.calcite.runtime.SqlFunctions;
@@ -1004,6 +1005,31 @@ private void checkCeil(int x, int y, int result) {
assertThat(Utilities.compare(empty, empty), is(0));
}
+ /** Composite null keys compare equally for sorting, but must not match in a
merge join. */
+ @Test void testCompareCompositeNullKeys() {
+ final Object key = new Object[]{1, null};
+ final Predicate1<Object> containsNull = row -> ((Object[]) row)[1] == null;
+ assertThat(Utilities.compareCompositeNullsFirst(null, null), is(0));
+ assertThat(Utilities.compareCompositeNullsLast(null, null), is(0));
+ assertThat(Utilities.compareCompositeNullsLastForMergeJoin(null, key),
is(1));
+ assertThat(Utilities.compareCompositeNullsLastForMergeJoin(key, null),
is(-1));
+ assertThat(Utilities.compareCompositeNullsLast(key, new Object[]{1,
null}), is(0));
+ // The exception class is private; obtain it from the scalar comparator
+ // and verify that composite comparisons use the same merge join signal.
+ final RuntimeException exception =
+ assertThrows(RuntimeException.class,
+ () -> Utilities.compareNullsLastForMergeJoin(null, null));
+ assertThrows(exception.getClass(),
+ () -> Utilities.compareCompositeNullsLastForMergeJoin(null, null));
+ assertThrows(exception.getClass(),
+ () -> Utilities.compareCompositeNullsLastForMergeJoin(key, new
Object[]{1, null},
+ containsNull));
+ // A NULL field must not change the ordering of unequal keys.
+ final Object nextKey = new Object[]{2, null};
+ assertThat(Utilities.compareCompositeNullsLastForMergeJoin(key, nextKey,
containsNull), is(-1));
+ assertThat(Utilities.compareCompositeNullsLastForMergeJoin(nextKey, key,
containsNull), is(1));
+ }
+
@Test void testTruncateLong() {
assertThat(SqlFunctions.truncate(12345L, 1000L), is(12000L));
assertThat(SqlFunctions.truncate(12000L, 1000L), is(12000L));
diff --git a/core/src/test/resources/sql/sort.iq
b/core/src/test/resources/sql/sort.iq
index a532047425..c79bb45c91 100644
--- a/core/src/test/resources/sql/sort.iq
+++ b/core/src/test/resources/sql/sort.iq
@@ -583,4 +583,74 @@ order by all;
!ok
+# [CALCITE-7919] Enumerable comparators fail with ClassCastException on ROW,
ARRAY, MULTISET and MAP keys
+SELECT id
+FROM (
+ VALUES
+ (1, ARRAY[2, 0]),
+ (2, ARRAY[1, 2]),
+ (3, ARRAY[1, 1])
+) AS t(id, k)
+ORDER BY k, id;
++----+
+| ID |
++----+
+| 3 |
+| 2 |
+| 1 |
++----+
+(3 rows)
+
+!ok
+
+# Equal ROW keys are ordered by the second sort column.
+SELECT id
+FROM (VALUES (1, ROW(2, 0)), (2, ROW(1, 2)),
+ (3, ROW(1, 1)), (4, ROW(1, 1))) AS t(id, k)
+ORDER BY k, id DESC;
++----+
+| ID |
++----+
+| 4 |
+| 3 |
+| 2 |
+| 1 |
++----+
+(4 rows)
+
+!ok
+
+# MAP comparison is independent of entry insertion order.
+SELECT id
+FROM (VALUES (1, MAP[1, 10, 2, 20]), (2, MAP[1, 5, 2, 20]),
+ (3, MAP[2, 20, 1, 10])) AS t(id, k)
+ORDER BY k, id;
++----+
+| ID |
++----+
+| 2 |
+| 1 |
+| 3 |
++----+
+(3 rows)
+
+!ok
+
+# Descending ARRAY ordering with a null element, a null key, OFFSET and FETCH.
+SELECT id
+FROM (VALUES (1, ARRAY[2, 0]), (2, ARRAY[1, 2]),
+ (3, ARRAY[1, CAST(NULL AS INTEGER)]),
+ (4, CAST(NULL AS INTEGER ARRAY)), (5, ARRAY[1, 2])) AS t(id, k)
+ORDER BY k DESC NULLS LAST, id
+OFFSET 1 ROWS FETCH NEXT 2 ROWS ONLY;
++----+
+| ID |
++----+
+| 3 |
+| 2 |
++----+
+(2 rows)
+
+!ok
+
# End sort.iq
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 dbfe7d3bd6..a611d0d3ad 100644
--- a/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
+++ b/linq4j/src/main/java/org/apache/calcite/linq4j/EnumerableDefaults.java
@@ -5424,6 +5424,30 @@ private static class BothValuesAreNullException extends
RuntimeException {
}
}
+ /** Compares merge join keys, optionally rejecting equal keys containing
NULL fields.
+ * Checks fields only after comparing keys, so unequal keys retain their
sort order. */
+ public static <T> int compareObjectsNullsLastForMergeJoin(@Nullable T v0,
+ @Nullable T v1, Comparator<? super T> comparator,
+ @Nullable Predicate1<? super T> containsNull) {
+ if (v0 == null && v1 == null) {
+ throw new BothValuesAreNullException();
+ }
+ if (v0 == null) {
+ return 1;
+ }
+ if (v1 == null) {
+ return -1;
+ }
+ final int c = comparator.compare(v0, v1);
+ // Equal keys have matching NULL fields, so checking one side is enough.
+ if (c == 0 && containsNull != null && containsNull.apply(v0)) {
+ // A ROW containing NULL cannot match: use the same signal as two NULL
keys
+ // so merge join advances without emitting a match.
+ throw new BothValuesAreNullException();
+ }
+ return c;
+ }
+
public static int compareNullsLastForMergeJoin(@Nullable Comparable v0,
@Nullable Comparable v1) {
return compareNullsLastForMergeJoin(v0, v1, null);
}