This is an automated email from the ASF dual-hosted git repository.
snuyanzin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new bbf37d09946 [FLINK-40989][table-planner] Compare `MAP` values by value
instead of by reference
bbf37d09946 is described below
commit bbf37d099464a01facdd3eea944975e4ab04ce4d
Author: Ramin Gharib <[email protected]>
AuthorDate: Sat Oct 17 21:44:00 2026 +0200
[FLINK-40989][table-planner] Compare `MAP` values by value instead of by
reference
---
.../planner/codegen/calls/ScalarOperatorGens.scala | 151 +++++++++----
.../table/planner/functions/MapFunctionITCase.java | 250 ++++++++++++++++++++-
2 files changed, 351 insertions(+), 50 deletions(-)
diff --git
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/ScalarOperatorGens.scala
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/ScalarOperatorGens.scala
index 643e1f9b9dd..bff1d79dd1c 100644
---
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/ScalarOperatorGens.scala
+++
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/codegen/calls/ScalarOperatorGens.scala
@@ -20,7 +20,6 @@ package org.apache.flink.table.planner.codegen.calls
import org.apache.flink.table.api.{TableRuntimeException, ValidationException}
import org.apache.flink.table.api.config.ExecutionConfigOptions
import org.apache.flink.table.data.binary.BinaryArrayData
-import org.apache.flink.table.data.util.MapDataUtil
import org.apache.flink.table.data.utils.CastExecutor
import org.apache.flink.table.data.writer.{BinaryArrayWriter, BinaryRowWriter}
import org.apache.flink.table.planner.codegen.{CodeGeneratorContext,
CodeGenException, EqualiserCodeGenerator, GeneratedExpression}
@@ -1948,64 +1947,118 @@ object ScalarOperatorGens {
val rightTerm = args(1)
val resultTerm = newName(ctx, "compareResult")
+ val sizeTerm = newName(ctx, "size")
+
+ val leftKeysTerm = newName(ctx, "leftKeys")
+ val leftValuesTerm = newName(ctx, "leftValues")
+ val rightKeysTerm = newName(ctx, "rightKeys")
+ val rightValuesTerm = newName(ctx, "rightValues")
+ val rightMatchedTerm = newName(ctx, "rightMatched")
+
+ val leftIndexTerm = newName(ctx, "leftIndex")
+ val rightIndexTerm = newName(ctx, "rightIndex")
+ val unmatchedIndexTerm = newName(ctx, "unmatchedIndex")
+ val laterIndexTerm = newName(ctx, "laterIndex")
+
+ // Reads an element into a typed local. A boxed element would make
generateEquals compare
+ // references.
+ def readElement(
+ arrayTerm: String,
+ indexTerm: String,
+ elementType: LogicalType,
+ name: String): (String, GeneratedExpression) = {
+ val elementTerm = newName(ctx, name)
+ val elementNullTerm = newName(ctx, s"${name}IsNull")
+ val code =
+ s"""
+ |${primitiveTypeTermForType(elementType)} $elementTerm =
+ | ${primitiveDefaultValue(elementType)};
+ |boolean $elementNullTerm = $arrayTerm.isNullAt($indexTerm);
+ |if (!$elementNullTerm) {
+ | $elementTerm = ${rowFieldReadAccess(indexTerm, arrayTerm,
elementType)};
+ |}
+ |""".stripMargin
+ (code, GeneratedExpression(elementTerm, elementNullTerm, "",
elementType))
+ }
- val mapCls = className[java.util.Map[_, _]]
- val keyCls = boxedTypeTermForType(keyType)
- val valueCls = boxedTypeTermForType(valueType)
-
- val leftMapTerm = newName(ctx, "leftMap")
- val leftKeyTerm = newName(ctx, "leftKey")
- val leftValueTerm = newName(ctx, "leftValue")
- val leftValueNullTerm = newName(ctx, "leftValueIsNull")
- val leftValueExpr =
- GeneratedExpression(leftValueTerm, leftValueNullTerm, "", valueType)
-
- val rightMapTerm = newName(ctx, "rightMap")
- val rightValueTerm = newName(ctx, "rightValue")
- val rightValueNullTerm = newName(ctx, "rightValueIsNull")
- val rightValueExpr =
- GeneratedExpression(rightValueTerm, rightValueNullTerm, "",
valueType)
-
- val entryTerm = newName(ctx, "entry")
- val entryCls = classOf[java.util.Map.Entry[AnyRef,
AnyRef]].getCanonicalName
- val valueEqualsExpr =
- generateEquals(ctx, leftValueExpr, rightValueExpr, new
BooleanType(valueType.isNullable))
+ // Two keys match when they are equal or both NULL.
+ def generateKeysMatch(
+ key: GeneratedExpression,
+ otherKey: GeneratedExpression): (String, String) = {
+ val keysEqual = generateEquals(ctx, key, otherKey, new
BooleanType(keyType.isNullable))
+ (keysEqual.code, s"${keysEqual.resultTerm} || (${key.nullTerm} &&
${otherKey.nullTerm})")
+ }
- val internalTypeCls = classOf[LogicalType].getCanonicalName
- val keyTypeTerm = ctx.addReusableObject(keyType, "keyType",
internalTypeCls)
- val valueTypeTerm = ctx.addReusableObject(valueType, "valueType",
internalTypeCls)
- val mapDataUtil = className[MapDataUtil]
+ val (leftKeyCode, leftKey) = readElement(leftKeysTerm, leftIndexTerm,
keyType, "leftKey")
+ val (rightKeyCode, rightKey) =
+ readElement(rightKeysTerm, rightIndexTerm, keyType, "rightKey")
+ val (keysMatchCode, keysMatch) = generateKeysMatch(leftKey, rightKey)
+ val (leftValueCode, leftValue) =
+ readElement(leftValuesTerm, leftIndexTerm, valueType, "leftValue")
+ val (rightValueCode, rightValue) =
+ readElement(rightValuesTerm, rightIndexTerm, valueType, "rightValue")
+ val valueEqualsExpr =
+ generateEquals(ctx, leftValue, rightValue, new
BooleanType(valueType.isNullable))
+
+ val (unmatchedKeyCode, unmatchedKey) =
+ readElement(rightKeysTerm, unmatchedIndexTerm, keyType,
"unmatchedKey")
+ val (laterKeyCode, laterKey) =
+ readElement(rightKeysTerm, laterIndexTerm, keyType, "laterKey")
+ val (laterKeysMatchCode, laterKeysMatch) =
generateKeysMatch(unmatchedKey, laterKey)
+
+ // Keys are compared with SQL '=' like the values, so each left key
scans the right keys.
+ // A map can hold a key more than once, and then its last entry
counts. Both scans run from
+ // the end, so a key first finds its last entry, and later visits find
it already matched.
+ // An unmatched right entry must be an earlier entry of a key that
comes later.
val stmt =
s"""
- |boolean $resultTerm;
- |if ($leftTerm.size() == $rightTerm.size()) {
- | $resultTerm = true;
- | $mapCls $leftMapTerm = $mapDataUtil
- | .convertToJavaMap($leftTerm, $keyTypeTerm, $valueTypeTerm);
- | $mapCls $rightMapTerm = $mapDataUtil
- | .convertToJavaMap($rightTerm, $keyTypeTerm,
$valueTypeTerm);
+ |int $sizeTerm = $leftTerm.size();
+ |boolean $resultTerm = $sizeTerm == $rightTerm.size();
+ |$ARRAY_DATA $leftKeysTerm = $leftTerm.keyArray();
+ |$ARRAY_DATA $leftValuesTerm = $leftTerm.valueArray();
+ |$ARRAY_DATA $rightKeysTerm = $rightTerm.keyArray();
+ |$ARRAY_DATA $rightValuesTerm = $rightTerm.valueArray();
+ |boolean[] $rightMatchedTerm = new boolean[$sizeTerm];
|
- | for ($entryCls $entryTerm : $leftMapTerm.entrySet()) {
- | $keyCls $leftKeyTerm = ($keyCls) $entryTerm.getKey();
- | if ($rightMapTerm.containsKey($leftKeyTerm)) {
- | $valueCls $leftValueTerm = ($valueCls)
$entryTerm.getValue();
- | $valueCls $rightValueTerm = ($valueCls)
$rightMapTerm.get($leftKeyTerm);
- | boolean $leftValueNullTerm = ($leftValueTerm == null);
- | boolean $rightValueNullTerm = ($rightValueTerm == null);
+ |for (int $leftIndexTerm = $sizeTerm - 1; $resultTerm &&
$leftIndexTerm >= 0;
+ | $leftIndexTerm--) {
+ | $leftKeyCode
+ | int $rightIndexTerm = $sizeTerm - 1;
+ | for (; $rightIndexTerm >= 0; $rightIndexTerm--) {
+ | $rightKeyCode
+ | $keysMatchCode
+ | if ($keysMatch) {
+ | break;
+ | }
+ | }
+ | if ($rightIndexTerm < 0) {
+ | $resultTerm = false;
+ | } else if (!$rightMatchedTerm[$rightIndexTerm]) {
+ | $rightMatchedTerm[$rightIndexTerm] = true;
|
- | ${valueEqualsExpr.code}
- | if (!${valueEqualsExpr.resultTerm}) {
- | $resultTerm = false;
- | break;
- | }
- | } else {
- | $resultTerm = false;
+ | $leftValueCode
+ | $rightValueCode
+ | ${valueEqualsExpr.code}
+ | $resultTerm = ${valueEqualsExpr.resultTerm};
+ | }
+ |}
+ |
+ |for (int $unmatchedIndexTerm = 0; $resultTerm &&
$unmatchedIndexTerm < $sizeTerm;
+ | $unmatchedIndexTerm++) {
+ | if ($rightMatchedTerm[$unmatchedIndexTerm]) {
+ | continue;
+ | }
+ | $unmatchedKeyCode
+ | int $laterIndexTerm = $unmatchedIndexTerm + 1;
+ | for (; $laterIndexTerm < $sizeTerm; $laterIndexTerm++) {
+ | $laterKeyCode
+ | $laterKeysMatchCode
+ | if ($laterKeysMatch) {
| break;
| }
| }
- |} else {
- | $resultTerm = false;
+ | $resultTerm = $laterIndexTerm < $sizeTerm;
|}
""".stripMargin
(stmt, resultTerm)
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/MapFunctionITCase.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/MapFunctionITCase.java
index 7d24b7995fd..1b02ffc587b 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/MapFunctionITCase.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/MapFunctionITCase.java
@@ -30,11 +30,13 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Period;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;
import static org.apache.flink.table.api.DataTypes.BIGINT;
import static org.apache.flink.table.api.DataTypes.BOOLEAN;
+import static org.apache.flink.table.api.DataTypes.BYTES;
import static org.apache.flink.table.api.DataTypes.DATE;
import static org.apache.flink.table.api.DataTypes.DECIMAL;
import static org.apache.flink.table.api.DataTypes.DOUBLE;
@@ -79,7 +81,8 @@ public class MapFunctionITCase extends
BuiltInFunctionTestBase {
mapFromArraysTestCases(),
mapContainsKeyTestCases(),
mapFromEntriesTestCases(),
- mapUnionTestCases())
+ mapUnionTestCases(),
+ mapEqualsTestCases())
.flatMap(s -> s);
}
@@ -889,4 +892,249 @@ public class MapFunctionITCase extends
BuiltInFunctionTestBase {
"Invalid input arguments. Expected signatures
are:\n"
+ "MAP_UNION(<COMMON>, <COMMON>...)"));
}
+
+ private Stream<TestSetSpec> mapEqualsTestCases() {
+ return Stream.of(
+ // f0-f2 DOUBLE, f3-f5 INT, f6-f8 DATE, f9-f11 DECIMAL,
f12-f14 MAP. In each group
+ // the second map reverses the first and the third differs in
one value.
+ TestSetSpec.forFunction(BuiltInFunctionDefinitions.EQUALS,
"MAP value types")
+ .onFieldsWithData(
+ orderedMap(A, 1.5, B, 2.5),
+ orderedMap(B, 2.5, A, 1.5),
+ orderedMap(A, 1.5, B, 1.5),
+ orderedMap(A, 7, B, 5000),
+ orderedMap(B, 5000, A, 7),
+ orderedMap(A, 7, B, 6000),
+ orderedMap(A, TEST_DATE_1, B, TEST_DATE_2),
+ orderedMap(B, TEST_DATE_2, A, TEST_DATE_1),
+ orderedMap(A, TEST_DATE_1, B, TEST_DATE_1),
+ orderedMap(A, new BigDecimal("1.50"), B, new
BigDecimal("2.50")),
+ orderedMap(B, new BigDecimal("2.50"), A, new
BigDecimal("1.50")),
+ orderedMap(A, new BigDecimal("1.50"), B, new
BigDecimal("1.50")),
+ orderedMap(
+ A, orderedMap(A, 5000, B, 6000),
+ B, orderedMap(A, 7000, B, 8000)),
+ orderedMap(
+ B, orderedMap(B, 8000, A, 7000),
+ A, orderedMap(B, 6000, A, 5000)),
+ orderedMap(
+ A, orderedMap(A, 5000, B, 6000),
+ B, orderedMap(A, 7000, B, 9000)))
+ .andDataTypes(
+ MAP(STRING(), DOUBLE()),
+ MAP(STRING(), DOUBLE()),
+ MAP(STRING(), DOUBLE()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), DATE()),
+ MAP(STRING(), DATE()),
+ MAP(STRING(), DATE()),
+ MAP(STRING(), DECIMAL(10, 2)),
+ MAP(STRING(), DECIMAL(10, 2)),
+ MAP(STRING(), DECIMAL(10, 2)),
+ MAP(STRING(), MAP(STRING(), INT())),
+ MAP(STRING(), MAP(STRING(), INT())),
+ MAP(STRING(), MAP(STRING(), INT())))
+ .testResult(
+ resultSpec($("f0").isEqual($("f1")), "f0 =
f1", true, BOOLEAN()),
+ resultSpec(
+ $("f0").isNotEqual($("f1")), "f0 <>
f1", false, BOOLEAN()),
+ resultSpec($("f0").isEqual($("f2")), "f0 =
f2", false, BOOLEAN()),
+ resultSpec(
+ $("f0").isNotEqual($("f2")), "f0 <>
f2", true, BOOLEAN()),
+ resultSpec($("f3").isEqual($("f4")), "f3 =
f4", true, BOOLEAN()),
+ resultSpec(
+ $("f3").isNotEqual($("f4")), "f3 <>
f4", false, BOOLEAN()),
+ resultSpec($("f3").isEqual($("f5")), "f3 =
f5", false, BOOLEAN()),
+ resultSpec(
+ $("f3").isNotEqual($("f5")), "f3 <>
f5", true, BOOLEAN()),
+ resultSpec($("f6").isEqual($("f7")), "f6 =
f7", true, BOOLEAN()),
+ resultSpec(
+ $("f6").isNotEqual($("f7")), "f6 <>
f7", false, BOOLEAN()),
+ resultSpec($("f6").isEqual($("f8")), "f6 =
f8", false, BOOLEAN()),
+ resultSpec(
+ $("f6").isNotEqual($("f8")), "f6 <>
f8", true, BOOLEAN()),
+ resultSpec($("f9").isEqual($("f10")), "f9 =
f10", true, BOOLEAN()),
+ resultSpec(
+ $("f9").isNotEqual($("f10")),
+ "f9 <> f10",
+ false,
+ BOOLEAN()),
+ resultSpec($("f9").isEqual($("f11")), "f9 =
f11", false, BOOLEAN()),
+ resultSpec(
+ $("f9").isNotEqual($("f11")), "f9 <>
f11", true, BOOLEAN()),
+ resultSpec(
+ $("f12").isEqual($("f13")), "f12 =
f13", true, BOOLEAN()),
+ resultSpec(
+ $("f12").isNotEqual($("f13")),
+ "f12 <> f13",
+ false,
+ BOOLEAN()),
+ resultSpec(
+ $("f12").isEqual($("f14")), "f12 =
f14", false, BOOLEAN()),
+ resultSpec(
+ $("f12").isNotEqual($("f14")),
+ "f12 <> f14",
+ true,
+ BOOLEAN())),
+ // f0-f2 differ in keys or size. f3-f5 hold a NULL value,
which equals nothing.
+ // f6 and LOWER(f7) are both 'x' only at runtime, so the maps
hold 'x' twice and the
+ // last entry counts. Keys compare like SQL '=', so the bytes
of f8 and f9 match and
+ // f11 * -1E0 is -0.0, which equals 0.0.
+ TestSetSpec.forFunction(
+ BuiltInFunctionDefinitions.EQUALS, "MAP keys
and NULL values")
+ .onFieldsWithData(
+ orderedMap(A, 5000, B, 6000),
+ orderedMap(A, 5000, "c", 6000),
+ Collections.singletonMap(A, 5000),
+ orderedMap(A, 1.5, B, lit(null, DOUBLE())),
+ orderedMap(B, lit(null, DOUBLE()), A, 1.5),
+ orderedMap(A, 1.5, B, 2.5),
+ "x",
+ "X",
+ new byte[] {1, 2},
+ new byte[] {1, 2},
+ new byte[] {3},
+ 0.0)
+ .andDataTypes(
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), DOUBLE()),
+ MAP(STRING(), DOUBLE()),
+ MAP(STRING(), DOUBLE()),
+ STRING(),
+ STRING(),
+ BYTES(),
+ BYTES(),
+ BYTES(),
+ DOUBLE())
+ .testResult(
+ resultSpec($("f0").isEqual($("f1")), "f0 =
f1", false, BOOLEAN()),
+ resultSpec(
+ $("f0").isNotEqual($("f1")), "f0 <>
f1", true, BOOLEAN()),
+ resultSpec($("f0").isEqual($("f2")), "f0 =
f2", false, BOOLEAN()),
+ resultSpec(
+ $("f0").isNotEqual($("f2")), "f0 <>
f2", true, BOOLEAN()),
+ resultSpec($("f3").isEqual($("f4")), "f3 =
f4", false, BOOLEAN()),
+ resultSpec(
+ $("f3").isNotEqual($("f4")), "f3 <>
f4", true, BOOLEAN()),
+ resultSpec($("f3").isEqual($("f5")), "f3 =
f5", false, BOOLEAN()),
+ resultSpec(
+ $("f3").isNotEqual($("f5")), "f3 <>
f5", true, BOOLEAN()),
+ resultSpec(
+ map($("f6"), 1, $("f7").lowerCase(),
2).cardinality(),
+ "CARDINALITY(MAP[f6, 1, LOWER(f7),
2])",
+ 2,
+ INT().notNull()),
+ resultSpec(
+ map($("f6"), 1, $("f7").lowerCase(), 2)
+
.isEqual(map($("f7").lowerCase(), 1, $("f6"), 2)),
+ "MAP[f6, 1, LOWER(f7), 2] =
MAP[LOWER(f7), 1, f6, 2]",
+ true,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f6"), 1, $("f7").lowerCase(), 2)
+
.isEqual(map($("f7").lowerCase(), 2, $("f6"), 1)),
+ "MAP[f6, 1, LOWER(f7), 2] =
MAP[LOWER(f7), 2, f6, 1]",
+ false,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f6"), nullOf(INT()),
$("f7").lowerCase(), 2)
+
.isEqual(map($("f7").lowerCase(), 1, $("f6"), 2)),
+ "MAP[f6, CAST(NULL AS INT), LOWER(f7),
2]"
+ + " = MAP[LOWER(f7), 1, f6,
2]",
+ true,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f6"), 1, $("f7").lowerCase(),
1, "y", 2)
+ .isEqual(map("x", 1, "y", 2,
"z", 3)),
+ "MAP[f6, 1, LOWER(f7), 1, 'y', 2] =
MAP['x', 1, 'y', 2, 'z', 3]",
+ false,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f6"), 1, $("f7").lowerCase(), 2)
+ .isEqual(map("x", 2)),
+ "MAP[f6, 1, LOWER(f7), 2] = MAP['x',
2]",
+ false,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f8"), 1, $("f10"), 2)
+ .isEqual(map($("f10"), 2,
$("f9"), 1)),
+ "MAP[f8, 1, f10, 2] = MAP[f10, 2, f9,
1]",
+ true,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f8"), 1, $("f10"), 2)
+ .isEqual(map($("f10"), 1,
$("f9"), 2)),
+ "MAP[f8, 1, f10, 2] = MAP[f10, 1, f9,
2]",
+ false,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map($("f11"),
1).isEqual(map($("f11").times(-1.0), 1)),
+ "MAP[f11, 1] = MAP[f11 * -1E0, 1]",
+ true,
+ BOOLEAN().notNull())),
+ // CreateMultiset turns f0-f2 into multisets. f3 and f4 are
DOUBLE columns.
+ TestSetSpec.forFunction(
+ BuiltInFunctionDefinitions.EQUALS, "MULTISET
and MAP from columns")
+ .withFunction(JsonFunctionsITCase.CreateMultiset.class)
+ .onFieldsWithData(
+ orderedMap(A, 200, B, 300),
+ orderedMap(B, 300, A, 200),
+ orderedMap(A, 200, B, 301),
+ 3.0,
+ 1.5)
+ .andDataTypes(
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ MAP(STRING(), INT()),
+ DOUBLE(),
+ DOUBLE())
+ .testResult(
+ resultSpec(
+ call("CreateMultiset", $("f0"))
+
.isEqual(call("CreateMultiset", $("f1"))),
+ "CreateMultiset(f0) =
CreateMultiset(f1)",
+ true,
+ BOOLEAN()),
+ resultSpec(
+ call("CreateMultiset", $("f0"))
+
.isNotEqual(call("CreateMultiset", $("f1"))),
+ "CreateMultiset(f0) <>
CreateMultiset(f1)",
+ false,
+ BOOLEAN()),
+ resultSpec(
+ call("CreateMultiset", $("f0"))
+
.isEqual(call("CreateMultiset", $("f2"))),
+ "CreateMultiset(f0) =
CreateMultiset(f2)",
+ false,
+ BOOLEAN()),
+ resultSpec(
+ call("CreateMultiset", $("f0"))
+
.isNotEqual(call("CreateMultiset", $("f2"))),
+ "CreateMultiset(f0) <>
CreateMultiset(f2)",
+ true,
+ BOOLEAN()),
+ resultSpec(
+ map(lit(A), $("f3").dividedBy(2))
+ .isEqual(map(lit(A), $("f4"))),
+ "MAP['a', f3 / 2] = MAP['a', f4]",
+ true,
+ BOOLEAN().notNull()),
+ resultSpec(
+ map(lit(A),
$("f3")).isEqual(map(lit(A), $("f4"))),
+ "MAP['a', f3] = MAP['a', f4]",
+ false,
+ BOOLEAN().notNull())));
+ }
+
+ private static Map<String, Object> orderedMap(
+ String key1, Object value1, String key2, Object value2) {
+ final Map<String, Object> map = new LinkedHashMap<>();
+ map.put(key1, value1);
+ map.put(key2, value2);
+ return map;
+ }
}