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);
   }

Reply via email to