This is an automated email from the ASF dual-hosted git repository.

mihaibudiu 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 b9686ea14a [CALCITE-7689] MAP equality compares maps by insertion order
b9686ea14a is described below

commit b9686ea14aeab549124cbea51d1b05060aff17c2
Author: Mihai Budiu <[email protected]>
AuthorDate: Tue Aug 4 16:39:17 2026 -0700

    [CALCITE-7689] MAP equality compares maps by insertion order
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 core/src/test/resources/sql/blank.iq               |  10 +-
 core/src/test/resources/sql/map-equality.iq        | 256 +++++++++++++++++++++
 .../apache/calcite/linq4j/function/Functions.java  |  20 +-
 .../calcite/linq4j/function/FunctionTest.java      |  52 +++++
 site/_docs/reference.md                            |   6 +
 5 files changed, 333 insertions(+), 11 deletions(-)

diff --git a/core/src/test/resources/sql/blank.iq 
b/core/src/test/resources/sql/blank.iq
index a84952e7f9..9a1062876f 100644
--- a/core/src/test/resources/sql/blank.iq
+++ b/core/src/test/resources/sql/blank.iq
@@ -224,11 +224,11 @@ select
     min(m) as min_m,
     min(r) as min_r
 from complex_t;
-+-----------------+----------------------------------------------------------------------------------------------+-----------------------+-----------------+------------------------------------------------------------------------------------------+------------------------+
-| MAX_A           | MAX_M                                                      
                                  | MAX_R                 | MIN_A           | 
MIN_M                                                                           
         | MIN_R                  |
-+-----------------+----------------------------------------------------------------------------------------------+-----------------------+-----------------+------------------------------------------------------------------------------------------+------------------------+
-| [100, 200, 300] | {physics         =96.2, chemistry       =91.8, biology     
    =89.5, computer_science=98.7} | {Charlie Chen, 35, c} | [1, 2, 3, 4, 5] | 
{leadership     =88.9, teamwork       =94.2, communication  =91.5, 
problem_solving=97.8} | {Alice Johnson, 30, a} |
-+-----------------+----------------------------------------------------------------------------------------------+-----------------------+-----------------+------------------------------------------------------------------------------------------+------------------------+
++-----------------+--------------------------------------------+-----------------------+-----------------+----------------------------------------------------------------------------------------------+------------------------+
+| MAX_A           | MAX_M                                      | MAX_R         
        | MIN_A           | MIN_M                                               
                                         | MIN_R                  |
++-----------------+--------------------------------------------+-----------------------+-----------------+----------------------------------------------------------------------------------------------+------------------------+
+| [100, 200, 300] | {math   =95.5, science=88.0, english=92.3} | {Charlie 
Chen, 35, c} | [1, 2, 3, 4, 5] | {physics         =96.2, chemistry       =91.8, 
biology         =89.5, computer_science=98.7} | {Alice Johnson, 30, a} |
++-----------------+--------------------------------------------+-----------------------+-----------------+----------------------------------------------------------------------------------------------+------------------------+
 (1 row)
 
 !ok
diff --git a/core/src/test/resources/sql/map-equality.iq 
b/core/src/test/resources/sql/map-equality.iq
new file mode 100644
index 0000000000..eae2a1bf92
--- /dev/null
+++ b/core/src/test/resources/sql/map-equality.iq
@@ -0,0 +1,256 @@
+# map-equality.iq - Tests for comparison of MAP values
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+!use scott
+!set outputformat mysql
+
+# [CALCITE-7689] MAP equality compares maps by insertion order
+# Two maps are equal exactly when they contain the same keys and map them to 
equal values.
+
+SELECT MAP[1, 2, 3, 4] = MAP[1, 2, 3, 4] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# Same contents in a different insertion order: equal
+SELECT MAP[1, 2, 3, 4] = MAP[3, 4, 1, 2] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+SELECT MAP['a', 1, 'b', 2] = MAP['b', 2, 'a', 1] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+SELECT MAP[1, 2, 3, 4] <> MAP[3, 4, 1, 2] AS ne;
++-------+
+| NE    |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# Different value for key 3: not equal
+SELECT MAP[1, 2, 3, 4] = MAP[1, 2, 3, 5] AS eq;
++-------+
+| EQ    |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# Different keys: not equal
+SELECT MAP[1, 2, 3, 4] = MAP[1, 2, 4, 4] AS eq;
++-------+
+| EQ    |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# Different sizes: not equal
+SELECT MAP[1, 2, 3, 4] = MAP[1, 2] AS eq;
++-------+
+| EQ    |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# IS NOT DISTINCT FROM agrees with =
+SELECT MAP[1, 2, 3, 4] IS NOT DISTINCT FROM MAP[3, 4, 1, 2] AS x;
++------+
+| X    |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+SELECT MAP[1, 2, 3, 4] IS DISTINCT FROM MAP[3, 4, 1, 2] AS x;
++-------+
+| X     |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# IN is defined in terms of =
+SELECT MAP[3, 4, 1, 2] IN (MAP[1, 2, 3, 4], MAP[5, 6, 7, 8]) AS x;
++------+
+| X    |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# NULL values inside a map follow IS NOT DISTINCT FROM semantics, as for
+# arrays: two NULL values are considered equal
+SELECT MAP[1, NULL, 3, 4] = MAP[3, 4, 1, NULL] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+SELECT MAP[1, NULL, 3, 4] = MAP[3, 4, 1, 2] AS eq;
++-------+
+| EQ    |
++-------+
+| false |
++-------+
+(1 row)
+
+!ok
+
+# A map nested inside an array is also compared by contents
+SELECT ARRAY[MAP[1, 2, 3, 4]] = ARRAY[MAP[3, 4, 1, 2]] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# Maps whose values are arrays
+SELECT MAP[1, ARRAY[1, 2], 3, ARRAY[3]] = MAP[3, ARRAY[3], 1, ARRAY[1, 2]] AS 
eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# Maps whose values are maps
+SELECT MAP[1, MAP[1, 2, 3, 4], 2, MAP[5, 6]] = MAP[2, MAP[5, 6], 1, MAP[3, 4, 
1, 2]] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# Maps whose values are rows
+SELECT MAP['a', ROW(1, 2), 'b', ROW(3, 4)] = MAP['b', ROW(3, 4), 'a', ROW(1, 
2)] AS eq;
++------+
+| EQ   |
++------+
+| true |
++------+
+(1 row)
+
+!ok
+
+# = in a WHERE clause
+SELECT COUNT(*) AS c FROM (VALUES 1) WHERE MAP[1, 2, 3, 4] = MAP[3, 4, 1, 2];
++---+
+| C |
++---+
+| 1 |
++---+
+(1 row)
+
+!ok
+
+# = in a CASE condition
+SELECT CASE WHEN MAP[1, 2, 3, 4] = MAP[3, 4, 1, 2] THEN 'same' ELSE 
'different' END AS x;
++-----------+
+| X         |
++-----------+
+| same      |
++-----------+
+(1 row)
+
+!ok
+
+# DISTINCT reaches the same verdict as =: one map, not two
+SELECT COUNT(*) AS c
+FROM (SELECT DISTINCT m FROM (VALUES MAP[1, 2, 3, 4], MAP[3, 4, 1, 2]) AS 
t(m));
++---+
+| C |
++---+
+| 1 |
++---+
+(1 row)
+
+!ok
+
+# GROUP BY reaches the same verdict as =: one group of two rows
+SELECT COUNT(*) AS c
+FROM (VALUES MAP[1, 2, 3, 4], MAP[3, 4, 1, 2]) AS t(m)
+GROUP BY m;
++---+
+| C |
++---+
+| 2 |
++---+
+(1 row)
+
+!ok
+
+# Joining on a map key matches reordered maps
+SELECT COUNT(*) AS c
+FROM (VALUES MAP[1, 2, 3, 4]) AS l(m)
+JOIN (VALUES MAP[3, 4, 1, 2]) AS r(m) ON l.m = r.m;
++---+
+| C |
++---+
+| 1 |
++---+
+(1 row)
+
+!ok
+
+# End map-equality.iq
diff --git 
a/linq4j/src/main/java/org/apache/calcite/linq4j/function/Functions.java 
b/linq4j/src/main/java/org/apache/calcite/linq4j/function/Functions.java
index f61f65a6d2..89ee4699e1 100644
--- a/linq4j/src/main/java/org/apache/calcite/linq4j/function/Functions.java
+++ b/linq4j/src/main/java/org/apache/calcite/linq4j/function/Functions.java
@@ -735,17 +735,14 @@ public static int compareLists(List<?> b0, List<?> b1) {
   /**
    * Compares two maps.
    *
-   * <p>Since maps in Calcite are implemented using {@link 
java.util.LinkedHashMap},
-   * which guarantees insertion order, this method follows DuckDB's behavior by
-   * comparing entries in order. For each entry, it first compares the key and
-   * then the value.
+   * <p>Entries are compared in a canonical order, sorted by key and then by 
value.
    */
   public static int compareMaps(Map<?, ?> b0, Map<?, ?> b1) {
     if (b0 == b1) {
       return 0;
     }
-    final Iterator<? extends Map.Entry<?, ?>> i0 = b0.entrySet().iterator();
-    final Iterator<? extends Map.Entry<?, ?>> i1 = b1.entrySet().iterator();
+    final Iterator<? extends Map.Entry<?, ?>> i0 = 
sortedEntries(b0).iterator();
+    final Iterator<? extends Map.Entry<?, ?>> i1 = 
sortedEntries(b1).iterator();
     while (i0.hasNext() && i1.hasNext()) {
       Map.Entry<?, ?> e0 = i0.next();
       Map.Entry<?, ?> e1 = i1.next();
@@ -767,6 +764,17 @@ public static int compareMaps(Map<?, ?> b0, Map<?, ?> b1) {
     return 0;
   }
 
+  /** Returns the entries of a map in a canonical order that does not depend
+   * on the map's iteration order: sorted by key, ties broken by value. */
+  private static List<Map.Entry<?, ?>> sortedEntries(Map<?, ?> map) {
+    final List<Map.Entry<?, ?>> entries = new ArrayList<>(map.entrySet());
+    entries.sort((e0, e1) -> {
+      final int c = compareListItems(e0.getKey(), e1.getKey());
+      return c != 0 ? c : compareListItems(e0.getValue(), e1.getValue());
+    });
+    return entries;
+  }
+
   private static BigDecimal toBigDecimal(Number number) {
     return number instanceof BigDecimal ? (BigDecimal) number
         : number instanceof BigInteger ? new BigDecimal((BigInteger) number)
diff --git 
a/linq4j/src/test/java/org/apache/calcite/linq4j/function/FunctionTest.java 
b/linq4j/src/test/java/org/apache/calcite/linq4j/function/FunctionTest.java
index 8fba589350..8c6f758b1b 100644
--- a/linq4j/src/test/java/org/apache/calcite/linq4j/function/FunctionTest.java
+++ b/linq4j/src/test/java/org/apache/calcite/linq4j/function/FunctionTest.java
@@ -20,9 +20,12 @@
 
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.function.IntFunction;
 
+import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.hasToString;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -80,6 +83,55 @@ class FunctionTest {
         Functions.all(empty, Functions.truePredicate1()));
   }
 
+  /** Unit test for {@link Functions#compareMaps}. Maps are unordered, so the
+   * comparison must not depend on insertion order, and must return 0 exactly
+   * when the maps have equal contents; see
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7689";>[CALCITE-7689]
+   * MAP equality compares maps by insertion order</a>. */
+  @Test void testCompareMaps() {
+    // Equal contents, same and different insertion order
+    assertThat(Functions.compareMaps(map(1, 2, 3, 4), map(1, 2, 3, 4)), is(0));
+    assertThat(Functions.compareMaps(map(1, 2, 3, 4), map(3, 4, 1, 2)), is(0));
+    assertThat(Functions.compareMaps(map(3, 4, 1, 2), map(1, 2, 3, 4)), is(0));
+
+    // Different value for one key: unequal, antisymmetric
+    final Map<Object, Object> a = map(1, 2, 3, 4);
+    final Map<Object, Object> c = map(1, 2, 3, 5);
+    assertTrue(Functions.compareMaps(a, c) < 0);
+    assertTrue(Functions.compareMaps(c, a) > 0);
+
+    // Different keys and different sizes
+    assertTrue(Functions.compareMaps(map(1, 2), map(2, 2)) != 0);
+    assertTrue(Functions.compareMaps(map(1, 2), map(1, 2, 3, 4)) < 0);
+    assertTrue(Functions.compareMaps(map(1, 2, 3, 4), map(1, 2)) > 0);
+
+    // The order must be transitive: b equals a, so b and a must compare to c
+    // with the same sign
+    final Map<Object, Object> b = map(3, 4, 1, 2);
+    assertTrue(Functions.compareMaps(b, c) < 0);
+
+    // Null values compare equal to each other and follow the same rule
+    assertThat(Functions.compareMaps(map(1, null, 3, 4), map(3, 4, 1, null)),
+        is(0));
+    assertTrue(Functions.compareMaps(map(1, null, 3, 4), map(1, 2, 3, 4)) != 
0);
+
+    // Nested maps as values are also compared by contents
+    assertThat(
+        Functions.compareMaps(map("k", map(1, 2, 3, 4)),
+            map("k", map(3, 4, 1, 2))),
+        is(0));
+  }
+
+  /** Creates a {@link LinkedHashMap} whose iteration order is the order of
+   * the given alternating keys and values. */
+  private static Map<Object, Object> map(Object... kv) {
+    final Map<Object, Object> result = new LinkedHashMap<>();
+    for (int i = 0; i < kv.length; i += 2) {
+      result.put(kv[i], kv[i + 1]);
+    }
+    return result;
+  }
+
   /** Unit test for {@link Functions#generate}. */
   @Test void testGenerate() {
     final IntFunction<String> xx =
diff --git a/site/_docs/reference.md b/site/_docs/reference.md
index 08938ec364..71c067e27b 100644
--- a/site/_docs/reference.md
+++ b/site/_docs/reference.md
@@ -1508,6 +1508,12 @@ ### Comparison operators
   collection is therefore compared the way `IS NOT DISTINCT FROM` compares it,
   and a NULL inside a collection does *not* make a comparison of the enclosing
   `ROW` value UNKNOWN.
+* A `MAP` value is an unordered mapping of keys to values: two maps are equal
+  exactly when they contain the same keys and map each key to equal values,
+  regardless of the order in which the entries were written. Keys and values
+  are compared using `<=>`, so a NULL value equals a NULL value, and the
+  result is never UNKNOWN. For example, `MAP[1, 2, 3, 4] = MAP[3, 4, 1, 2]`
+  is TRUE, and so is `MAP[1, NULL] = MAP[1, NULL]`.
 * `GROUP BY`, `DISTINCT` and the set operators (`UNION`, `INTERSECT`, `EXCEPT`)
   compare values as `IS NOT DISTINCT FROM` does.
 

Reply via email to