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.