From da031e7e1e9d66ce25328a966c881889f03f7c4d Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Tue, 4 Aug 2026 16:39:17 -0700 Subject: [PATCH] [CALCITE-7689] MAP equality compares maps by insertion order Signed-off-by: Mihai Budiu --- core/src/test/resources/sql/blank.iq | 10 +- core/src/test/resources/sql/map-equality.iq | 256 ++++++++++++++++++ .../calcite/linq4j/function/Functions.java | 20 +- .../calcite/linq4j/function/FunctionTest.java | 52 ++++ site/_docs/reference.md | 6 + 5 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 core/src/test/resources/sql/map-equality.iq diff --git a/core/src/test/resources/sql/blank.iq b/core/src/test/resources/sql/blank.iq index a84952e7f933..9a1062876f08 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 000000000000..eae2a1bf9292 --- /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 f61f65a6d229..89ee4699e1e4 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. * - *

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. + *

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> i0 = b0.entrySet().iterator(); - final Iterator> i1 = b1.entrySet().iterator(); + final Iterator> i0 = sortedEntries(b0).iterator(); + final Iterator> 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> sortedEntries(Map map) { + final List> 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 8fba58935048..8c6f758b1b64 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 + * [CALCITE-7689] + * MAP equality compares maps by insertion order. */ + @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 a = map(1, 2, 3, 4); + final Map 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 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 map(Object... kv) { + final Map 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 xx = diff --git a/site/_docs/reference.md b/site/_docs/reference.md index 4b198449ae08..7a4c0fbe77f6 100644 --- a/site/_docs/reference.md +++ b/site/_docs/reference.md @@ -1506,6 +1506,12 @@ Note: 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.