This is an automated email from the ASF dual-hosted git repository.
gustavodemorais 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 b3f4d60249a [FLINK-40968][table] Fix Table API map() op_mapping for TO
and FROM_CHANGELOG PTFs
b3f4d60249a is described below
commit b3f4d60249a60a6337030f3aac07a4dec8440537
Author: Timo Theusner <[email protected]>
AuthorDate: Wed Oct 21 16:41:41 2026 +0200
[FLINK-40968][table] Fix Table API map() op_mapping for TO and
FROM_CHANGELOG PTFs
Fixed in #29474.
---
.../resolver/rules/ResolveCallByArgumentsRule.java | 35 ++++-
.../strategies/FromChangelogTypeStrategy.java | 118 +++++----------
.../strategies/ToChangelogTypeStrategy.java | 52 ++-----
.../FromChangelogInputTypeStrategyTest.java | 32 +++-
.../ToChangelogInputTypeStrategyTest.java | 23 +++
.../exec/stream/FromChangelogSemanticTests.java | 4 +
.../exec/stream/FromChangelogTestPrograms.java | 162 +++++++++++++++++++++
.../exec/stream/ToChangelogSemanticTests.java | 6 +
.../nodes/exec/stream/ToChangelogTestPrograms.java | 154 ++++++++++++++++++++
9 files changed, 469 insertions(+), 117 deletions(-)
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
index 9f28484f585..07930a97875 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
@@ -60,6 +60,7 @@ import
org.apache.flink.table.types.inference.TypeInferenceUtil.Result;
import
org.apache.flink.table.types.inference.TypeInferenceUtil.SurroundingInfo;
import org.apache.flink.table.types.inference.TypeStrategies;
import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.LogicalTypeFamily;
import org.apache.flink.table.types.utils.DataTypeUtils;
import javax.annotation.Nullable;
@@ -67,6 +68,7 @@ import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -607,7 +609,9 @@ final class ResolveCallByArgumentsRule implements
ResolverRule {
@Override
public boolean isArgumentLiteral(int pos) {
final ResolvedExpression arg = getArgument(pos);
- return arg instanceof ValueLiteralExpression || arg instanceof
TypeLiteralExpression;
+ return arg instanceof ValueLiteralExpression
+ || arg instanceof TypeLiteralExpression
+ || isLiteralMap(arg);
}
@Override
@@ -637,6 +641,9 @@ final class ResolveCallByArgumentsRule implements
ResolverRule {
final ValueLiteralExpression literal =
(ValueLiteralExpression) arg;
return literal.getValueAs(clazz);
}
+ if (clazz == Map.class && isLiteralMap(arg)) {
+ return Optional.of((T) toStringMap(arg));
+ }
return Optional.empty();
}
@@ -715,6 +722,32 @@ final class ResolveCallByArgumentsRule implements
ResolverRule {
return isGroupedAggregation;
}
+ private static boolean isLiteralMap(ResolvedExpression arg) {
+ return isFunction(arg, BuiltInFunctionDefinitions.MAP)
+ && arg.getResolvedChildren().stream()
+
.allMatch(TableApiCallContext::isNonNullStringLiteral);
+ }
+
+ private static boolean isNonNullStringLiteral(ResolvedExpression
expression) {
+ return expression instanceof ValueLiteralExpression
+ && ((ValueLiteralExpression)
expression).getValueAs(String.class).isPresent()
+ && expression
+ .getOutputDataType()
+ .getLogicalType()
+ .is(LogicalTypeFamily.CHARACTER_STRING);
+ }
+
+ private static Map<String, String> toStringMap(ResolvedExpression
literalMap) {
+ final List<ResolvedExpression> entries =
literalMap.getResolvedChildren();
+ final Map<String, String> map = new LinkedHashMap<>();
+ for (int i = 0; i < entries.size(); i += 2) {
+ map.put(
+ ExpressionUtils.stringValue(entries.get(i)),
+ ExpressionUtils.stringValue(entries.get(i + 1)));
+ }
+ return map;
+ }
+
private ResolvedExpression getArgument(int pos) {
if (pos >= resolvedArgs.size()) {
throw new IndexOutOfBoundsException(
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/FromChangelogTypeStrategy.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/FromChangelogTypeStrategy.java
index 13fa1245e62..14babdd82ea 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/FromChangelogTypeStrategy.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/FromChangelogTypeStrategy.java
@@ -157,68 +157,43 @@ public final class FromChangelogTypeStrategy {
private static Optional<List<DataType>> validateInputs(
final CallContext callContext, final boolean throwOnFailure) {
- Optional<List<DataType>> error;
-
- error = validateTableArg(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
- error = validateOpDescriptor(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
- error = validateOpColumn(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
- error = validateOpMapping(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
- error = validateErrorHandling(callContext, throwOnFailure);
+ final Optional<String> error =
+ validateTableArg(callContext)
+ .or(() -> validateOpDescriptor(callContext))
+ .or(() -> validateOpColumn(callContext))
+ .or(() -> validateOpMapping(callContext))
+ .or(() -> validateErrorHandling(callContext));
if (error.isPresent()) {
- return error;
+ return callContext.fail(throwOnFailure, error.get());
}
-
return Optional.of(callContext.getArgumentDataTypes());
}
- private static Optional<List<DataType>> validateTableArg(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateTableArg(final CallContext
callContext) {
if (callContext.getTableSemantics(ARG_TABLE).isEmpty()) {
- return callContext.fail(
- throwOnFailure, "First argument must be a table for
FROM_CHANGELOG.");
+ return Optional.of("First argument must be a table for
FROM_CHANGELOG.");
}
return Optional.empty();
}
- private static Optional<List<DataType>> validateOpDescriptor(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateOpDescriptor(final CallContext
callContext) {
final Optional<ColumnList> opDescriptor =
callContext.getArgumentValue(ARG_OP, ColumnList.class);
if (opDescriptor.isPresent() && opDescriptor.get().getNames().size()
!= 1) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
"The descriptor for argument 'op' must contain exactly one
column name.");
}
return Optional.empty();
}
/** Validates that the op column exists in the input schema and is of
STRING type. */
- private static Optional<List<DataType>> validateOpColumn(
- final CallContext callContext, final boolean throwOnFailure) {
-
+ private static Optional<String> validateOpColumn(final CallContext
callContext) {
final TableSemantics tableSemantics =
callContext.getTableSemantics(ARG_TABLE).get();
final String opColumnName =
ChangelogTypeStrategyUtils.resolveOpColumnName(callContext);
final OptionalInt opIndex =
ChangelogTypeStrategyUtils.resolveOpColumnIndex(tableSemantics, opColumnName);
if (opIndex.isEmpty()) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"The op column '%s' does not exist in the input
schema.",
opColumnName));
@@ -228,8 +203,7 @@ public final class FromChangelogTypeStrategy {
.get(opIndex.getAsInt())
.getLogicalType();
if (!opFieldType.is(LogicalTypeFamily.CHARACTER_STRING)) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"The op column '%s' must be of STRING type, but
was '%s'.",
opColumnName, opFieldType));
@@ -239,35 +213,32 @@ public final class FromChangelogTypeStrategy {
/** Validates op_mapping is a literal and its values are valid change
operation names. */
@SuppressWarnings("unchecked")
- private static Optional<List<DataType>> validateOpMapping(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateOpMapping(final CallContext
callContext) {
final boolean hasMappingArgProvided =
!callContext.isArgumentNull(ARG_OP_MAPPING);
final boolean isMappingArgLiteral =
callContext.isArgumentLiteral(ARG_OP_MAPPING);
if (hasMappingArgProvided && !isMappingArgLiteral) {
- return callContext.fail(
- throwOnFailure, "The 'op_mapping' argument must be a
constant MAP literal.");
+ return Optional.of("The 'op_mapping' argument must be a constant
MAP literal.");
}
final Optional<Map> opMapping =
callContext.getArgumentValue(ARG_OP_MAPPING, Map.class);
- if (opMapping.isPresent()) {
- final Map<String, String> mapping = opMapping.get();
- final Optional<List<DataType>> validationError =
- validateOpMappingValues(callContext, mapping,
throwOnFailure);
- if (validationError.isPresent()) {
- return validationError;
- }
- // A mapping that produces UPDATE_AFTER without UPDATE_BEFORE
describes an upsert
- // changelog. Upsert mode requires a key, so PARTITION BY must be
present on the
- // table argument; otherwise the call would produce key-less
updates.
- if (describesUpsert(mapping) &&
!isPartitioned(callContext::getTableSemantics)) {
- return callContext.fail(
- throwOnFailure,
- "An 'op_mapping' that produces UPDATE_AFTER without
UPDATE_BEFORE "
- + "describes an upsert changelog and requires
a key. Add "
- + "PARTITION BY to the input table to define
the upsert key, "
- + "or include UPDATE_BEFORE in the mapping for
retract "
- + "semantics.");
- }
+ if (opMapping.isEmpty()) {
+ return Optional.empty();
+ }
+ final Map<String, String> mapping = opMapping.get();
+ final Optional<String> valuesError = validateOpMappingValues(mapping);
+ if (valuesError.isPresent()) {
+ return valuesError;
+ }
+ // A mapping that produces UPDATE_AFTER without UPDATE_BEFORE
describes an upsert
+ // changelog. Upsert mode requires a key, so PARTITION BY must be
present on the
+ // table argument; otherwise the call would produce key-less updates.
+ if (describesUpsert(mapping) &&
!isPartitioned(callContext::getTableSemantics)) {
+ return Optional.of(
+ "An 'op_mapping' that produces UPDATE_AFTER without
UPDATE_BEFORE "
+ + "describes an upsert changelog and requires a
key. Add "
+ + "PARTITION BY to the input table to define the
upsert key, "
+ + "or include UPDATE_BEFORE in the mapping for
retract "
+ + "semantics.");
}
return Optional.empty();
}
@@ -276,17 +247,13 @@ public final class FromChangelogTypeStrategy {
* Validates op_mapping values. Values must be valid Flink change
operation names. Each name
* must appear at most once across all entries.
*/
- private static Optional<List<DataType>> validateOpMappingValues(
- final CallContext callContext,
- final Map<String, String> opMapping,
- final boolean throwOnFailure) {
+ private static Optional<String> validateOpMappingValues(final Map<String,
String> opMapping) {
final Set<String> allRowKindsSeen = new HashSet<>();
for (final String value : opMapping.values()) {
final String rowKindName = value.trim();
if (!VALID_ROW_KIND_NAMES.contains(rowKindName)) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"Invalid target mapping for argument
'op_mapping'. "
+ "Unknown change operation: '%s'.
Valid values are: %s.",
@@ -294,8 +261,7 @@ public final class FromChangelogTypeStrategy {
}
final boolean isDuplicate = !allRowKindsSeen.add(rowKindName);
if (isDuplicate) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"Invalid target mapping for argument
'op_mapping'. "
+ "Duplicate change operation: '%s'. "
@@ -307,14 +273,11 @@ public final class FromChangelogTypeStrategy {
return Optional.empty();
}
- private static Optional<List<DataType>> validateErrorHandling(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateErrorHandling(final CallContext
callContext) {
final boolean hasErrorHandlingArgProvided =
!callContext.isArgumentNull(ARG_ERROR_HANDLING);
final boolean isErrorHandlingArgLiteral =
callContext.isArgumentLiteral(ARG_ERROR_HANDLING);
if (hasErrorHandlingArgProvided && !isErrorHandlingArgLiteral) {
- return callContext.fail(
- throwOnFailure,
- "The 'error_handling' argument must be a constant STRING
literal.");
+ return Optional.of("The 'error_handling' argument must be a
constant STRING literal.");
}
final Optional<String> optionalErrorHandlingArg =
@@ -322,8 +285,7 @@ public final class FromChangelogTypeStrategy {
if (optionalErrorHandlingArg.isPresent()) {
final String errorHandlingMode = optionalErrorHandlingArg.get();
if (ErrorHandlingMode.fromName(errorHandlingMode).isEmpty()) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"Invalid value for argument 'error_handling':
'%s'. Valid values are: %s.",
errorHandlingMode,
ErrorHandlingMode.validNames()));
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/ToChangelogTypeStrategy.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/ToChangelogTypeStrategy.java
index a2b8e13681b..194c5301329 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/ToChangelogTypeStrategy.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/ToChangelogTypeStrategy.java
@@ -102,42 +102,28 @@ public final class ToChangelogTypeStrategy {
private static Optional<List<DataType>> validateInputs(
final CallContext callContext, final boolean throwOnFailure) {
- Optional<List<DataType>> error;
-
- error = validateTableArg(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
- error = validateOpDescriptor(callContext, throwOnFailure);
+ final Optional<String> error =
+ validateTableArg(callContext)
+ .or(() -> validateOpDescriptor(callContext))
+ .or(() -> validateOpMapping(callContext));
if (error.isPresent()) {
- return error;
+ return callContext.fail(throwOnFailure, error.get());
}
-
- error = validateOpMapping(callContext, throwOnFailure);
- if (error.isPresent()) {
- return error;
- }
-
return Optional.of(callContext.getArgumentDataTypes());
}
- private static Optional<List<DataType>> validateTableArg(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateTableArg(final CallContext
callContext) {
if (callContext.getTableSemantics(ARG_TABLE).isEmpty()) {
- return callContext.fail(
- throwOnFailure, "First argument must be a table for
TO_CHANGELOG.");
+ return Optional.of("First argument must be a table for
TO_CHANGELOG.");
}
return Optional.empty();
}
- private static Optional<List<DataType>> validateOpDescriptor(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateOpDescriptor(final CallContext
callContext) {
final Optional<ColumnList> opDescriptor =
callContext.getArgumentValue(ARG_OP, ColumnList.class);
if (opDescriptor.isPresent() && opDescriptor.get().getNames().size()
!= 1) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
"The descriptor for argument 'op' must contain exactly one
column name.");
}
return Optional.empty();
@@ -145,19 +131,16 @@ public final class ToChangelogTypeStrategy {
/** Validates op_mapping is a constant literal and that its keys are
well-formed. */
@SuppressWarnings("rawtypes")
- private static Optional<List<DataType>> validateOpMapping(
- final CallContext callContext, final boolean throwOnFailure) {
+ private static Optional<String> validateOpMapping(final CallContext
callContext) {
final boolean hasMappingArgProvided =
!callContext.isArgumentNull(ARG_OP_MAPPING);
final boolean isMappingArgLiteral =
callContext.isArgumentLiteral(ARG_OP_MAPPING);
if (hasMappingArgProvided && !isMappingArgLiteral) {
- return callContext.fail(
- throwOnFailure, "The 'op_mapping' argument must be a
constant MAP literal.");
+ return Optional.of("The 'op_mapping' argument must be a constant
MAP literal.");
}
final Optional<Map> opMapping =
callContext.getArgumentValue(ARG_OP_MAPPING, Map.class);
if (opMapping.isPresent()) {
- return validateOpMappingKeys(
- callContext, (Map<String, String>) opMapping.get(),
throwOnFailure);
+ return validateOpMappingKeys((Map<String, String>)
opMapping.get());
}
return Optional.empty();
}
@@ -168,18 +151,14 @@ public final class ToChangelogTypeStrategy {
* trimmed. Names are case-sensitive and must match exactly (e.g., {@code
INSERT}, not {@code
* insert}). Each name must be valid and appear at most once across all
entries.
*/
- private static Optional<List<DataType>> validateOpMappingKeys(
- final CallContext callContext,
- final Map<String, String> opMapping,
- final boolean throwOnFailure) {
+ private static Optional<String> validateOpMappingKeys(final Map<String,
String> opMapping) {
final Set<String> allRowKindsSeen = new HashSet<>();
for (final String key : opMapping.keySet()) {
final String[] rowKindNames = key.split(",");
for (final String rawName : rowKindNames) {
final String rowKindName = rawName.trim();
if (!VALID_ROW_KIND_NAMES.contains(rowKindName)) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"Invalid target mapping for argument
'op_mapping'. "
+ "Unknown change operation: '%s'.
Operations are case-sensitive. Valid values are: %s.",
@@ -187,8 +166,7 @@ public final class ToChangelogTypeStrategy {
}
final boolean isDuplicate = !allRowKindsSeen.add(rowKindName);
if (isDuplicate) {
- return callContext.fail(
- throwOnFailure,
+ return Optional.of(
String.format(
"Invalid target mapping for argument
'op_mapping'. "
+ "Duplicate change operation:
'%s'.",
diff --git
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/FromChangelogInputTypeStrategyTest.java
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/FromChangelogInputTypeStrategyTest.java
index 70595f1c597..41ff7e9941b 100644
---
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/FromChangelogInputTypeStrategyTest.java
+++
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/FromChangelogInputTypeStrategyTest.java
@@ -20,14 +20,23 @@ package org.apache.flink.table.types.inference.strategies;
import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.inference.InputTypeStrategies;
import org.apache.flink.table.types.inference.InputTypeStrategiesTestBase;
+import org.apache.flink.table.types.inference.utils.CallContextMock;
import org.apache.flink.table.types.inference.utils.TableSemanticsMock;
+import org.apache.flink.table.types.utils.DataTypeFactoryMock;
import org.apache.flink.types.ColumnList;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Stream;
import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.FROM_CHANGELOG_INPUT_TYPE_STRATEGY;
+import static org.assertj.core.api.Assertions.assertThat;
/** Tests for {@link FromChangelogTypeStrategy#INPUT_TYPE_STRATEGY}. */
class FromChangelogInputTypeStrategyTest extends InputTypeStrategiesTestBase {
@@ -233,6 +242,27 @@ class FromChangelogInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
"d", "DELETE"))
.calledWithLiteralAt(3, "Fail")
.expectErrorMessage(
- "Invalid value for argument 'error_handling':
'Fail'. Valid values are: FAIL, SKIP."));
+ "Invalid value for argument 'error_handling':
'Fail'. Valid values are: FAIL, SKIP."),
+ TestSpec.forStrategy(
+ "Nested call is typed without the table
argument",
+ InputTypeStrategies.WILDCARD)
+
.surroundingStrategy(FROM_CHANGELOG_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(STRING_TYPE)
+ .expectArgumentTypes(STRING_TYPE));
+ }
+
+ @Test
+ void testReportsFailureWithoutThrowingWhenTableArgumentIsMissing() {
+ final CallContextMock callContext = new CallContextMock();
+ callContext.typeFactory = new DataTypeFactoryMock();
+ callContext.name = "FROM_CHANGELOG";
+ callContext.argumentDataTypes = List.of(TABLE_TYPE, DESCRIPTOR_TYPE,
MAP_TYPE, STRING_TYPE);
+ callContext.argumentLiterals = Collections.nCopies(4, false);
+ callContext.argumentNulls = Collections.nCopies(4, false);
+ callContext.argumentValues = Collections.nCopies(4, Optional.empty());
+ callContext.tableSemantics = Collections.emptyMap();
+
+
assertThat(FROM_CHANGELOG_INPUT_TYPE_STRATEGY.inferInputTypes(callContext,
false))
+ .isEmpty();
}
}
diff --git
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/ToChangelogInputTypeStrategyTest.java
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/ToChangelogInputTypeStrategyTest.java
index 36607885bd8..65991e64fa8 100644
---
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/ToChangelogInputTypeStrategyTest.java
+++
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/ToChangelogInputTypeStrategyTest.java
@@ -21,10 +21,17 @@ package org.apache.flink.table.types.inference.strategies;
import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.inference.InputTypeStrategiesTestBase;
+import org.apache.flink.table.types.inference.utils.CallContextMock;
import org.apache.flink.table.types.inference.utils.TableSemanticsMock;
+import org.apache.flink.table.types.utils.DataTypeFactoryMock;
import org.apache.flink.types.ColumnList;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Stream;
import static
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies.TO_CHANGELOG_INPUT_TYPE_STRATEGY;
@@ -32,6 +39,7 @@ import static
org.apache.flink.table.types.inference.strategies.ToChangelogTypeS
import static
org.apache.flink.table.types.inference.strategies.ToChangelogTypeStrategy.ARG_OP_MAPPING;
import static
org.apache.flink.table.types.inference.strategies.ToChangelogTypeStrategy.ARG_PRODUCES_FULL_DELETES;
import static
org.apache.flink.table.types.inference.strategies.ToChangelogTypeStrategy.ARG_TABLE;
+import static org.assertj.core.api.Assertions.assertThat;
/** Tests for {@link ToChangelogTypeStrategy#INPUT_TYPE_STRATEGY}. */
class ToChangelogInputTypeStrategyTest extends InputTypeStrategiesTestBase {
@@ -130,4 +138,19 @@ class ToChangelogInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
ARG_OP_MAPPING, Map.of("INSERT, DELETE", "A",
"DELETE", "B"))
.expectErrorMessage("Duplicate change operation:
'DELETE'"));
}
+
+ @Test
+ void testReportsFailureWithoutThrowingWhenTableArgumentIsMissing() {
+ final CallContextMock callContext = new CallContextMock();
+ callContext.typeFactory = new DataTypeFactoryMock();
+ callContext.name = "TO_CHANGELOG";
+ callContext.argumentDataTypes =
+ List.of(TABLE_TYPE, DESCRIPTOR_TYPE, MAP_TYPE, BOOLEAN_TYPE);
+ callContext.argumentLiterals = Collections.nCopies(4, false);
+ callContext.argumentNulls = Collections.nCopies(4, false);
+ callContext.argumentValues = Collections.nCopies(4, Optional.empty());
+ callContext.tableSemantics = Collections.emptyMap();
+
+
assertThat(TO_CHANGELOG_INPUT_TYPE_STRATEGY.inferInputTypes(callContext,
false)).isEmpty();
+ }
}
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogSemanticTests.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogSemanticTests.java
index ac0901853fb..3041b1e005d 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogSemanticTests.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogSemanticTests.java
@@ -39,6 +39,10 @@ public class FromChangelogSemanticTests extends
SemanticTestBase {
FromChangelogTestPrograms.SKIP_NULL_OP_CODE,
FromChangelogTestPrograms.TABLE_API_DEFAULT,
FromChangelogTestPrograms.TABLE_API_RETRACT_PARTITION_BY,
+ FromChangelogTestPrograms.TABLE_API_CUSTOM_OP_MAPPING,
+ FromChangelogTestPrograms.TABLE_API_UPSERT_PARTITION_BY,
+ FromChangelogTestPrograms.TABLE_API_SKIP_INVALID_OP_HANDLING,
+ FromChangelogTestPrograms.TABLE_API_ORDER_BY_CUSTOM_OP_MAPPING,
FromChangelogTestPrograms.ROUND_TRIP,
FromChangelogTestPrograms.INVALID_OP_CODE,
FromChangelogTestPrograms.NULL_OP_CODE);
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogTestPrograms.java
index 5fa3956de3b..f29d2e23d95 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/FromChangelogTestPrograms.java
@@ -26,7 +26,12 @@ import org.apache.flink.table.test.program.TableTestProgram;
import org.apache.flink.types.Row;
import org.apache.flink.types.RowKind;
+import java.time.Instant;
+
import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.descriptor;
+import static org.apache.flink.table.api.Expressions.lit;
+import static org.apache.flink.table.api.Expressions.map;
/** {@link TableTestProgram} definitions for testing the built-in
FROM_CHANGELOG PTF. */
public class FromChangelogTestPrograms {
@@ -313,6 +318,163 @@ public class FromChangelogTestPrograms {
"sink")
.build();
+ public static final TableTestProgram TABLE_API_CUSTOM_OP_MAPPING =
+ TableTestProgram.of(
+ "from-changelog-table-api-custom-op-mapping",
+ "Table.fromChangelog() with a map() op_mapping
using comma-separated keys")
+ .setupTableSource(
+ SourceTestStep.newBuilder("cdc_stream")
+ .addSchema(SIMPLE_CDC_SCHEMA)
+ .producedValues(
+ Row.of(1, "c", "Alice"),
+ Row.of(2, "r", "Bob"),
+ Row.of(1, "ub", "Alice"),
+ Row.of(1, "ua", "Alice2"),
+ Row.of(2, "d", "Bob"))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("id INT", "name STRING")
+ .consumedValues(
+ Row.ofKind(RowKind.INSERT, 1,
"Alice"),
+ Row.ofKind(RowKind.INSERT, 2,
"Bob"),
+ Row.ofKind(RowKind.UPDATE_BEFORE,
1, "Alice"),
+ Row.ofKind(RowKind.UPDATE_AFTER,
1, "Alice2"),
+ Row.ofKind(RowKind.DELETE, 2,
"Bob"))
+ .build())
+ .runTableApi(
+ env ->
+ env.from("cdc_stream")
+ .fromChangelog(
+
descriptor("op").asArgument("op"),
+ map(
+ "c, r",
+ "INSERT",
+ "ub",
+
"UPDATE_BEFORE",
+ "ua",
+
"UPDATE_AFTER",
+ "d",
+ "DELETE")
+
.asArgument("op_mapping")),
+ "sink")
+ .build();
+
+ public static final TableTestProgram TABLE_API_UPSERT_PARTITION_BY =
+ TableTestProgram.of(
+ "from-changelog-table-api-upsert-partition-by",
+ "PartitionedTable.fromChangelog() with a map()
op_mapping without "
+ + "UPDATE_BEFORE produces an upsert
changelog")
+ .setupTableSource(
+ SourceTestStep.newBuilder("cdc_stream")
+ .addSchema("name STRING", "id INT", "op
STRING")
+ .producedValues(
+ Row.of("Alice", 1, "INSERT"),
+ Row.of("Bob", 2, "INSERT"),
+ Row.of("Alice2", 1,
"UPDATE_AFTER"),
+ Row.of("Bob", 2, "DELETE"))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("id INT PRIMARY KEY NOT
ENFORCED", "name STRING")
+ .consumedValues(
+ Row.ofKind(RowKind.INSERT, 1,
"Alice"),
+ Row.ofKind(RowKind.INSERT, 2,
"Bob"),
+ Row.ofKind(RowKind.UPDATE_AFTER,
1, "Alice2"),
+ Row.ofKind(RowKind.DELETE, 2,
"Bob"))
+ .build())
+ .runTableApi(
+ env ->
+ env.from("cdc_stream")
+ .partitionBy($("id"))
+ .fromChangelog(
+ map(
+ "INSERT",
+ "INSERT",
+
"UPDATE_AFTER",
+
"UPDATE_AFTER",
+ "DELETE",
+ "DELETE")
+
.asArgument("op_mapping")),
+ "sink")
+ .build();
+
+ public static final TableTestProgram TABLE_API_SKIP_INVALID_OP_HANDLING =
+ TableTestProgram.of(
+ "from-changelog-table-api-unmapped-codes-dropped",
+ "Table.fromChangelog() with error_handling='SKIP'
drops unmapped op codes")
+ .setupTableSource(
+ SourceTestStep.newBuilder("cdc_stream")
+ .addSchema(SIMPLE_CDC_SCHEMA)
+ .producedValues(
+ Row.of(1, "INSERT", "Alice"),
+ Row.of(2, "INSERT", "Bob"),
+ Row.of(1, "UNKNOWN", "Alice2"),
+ Row.of(2, "DELETE", "Bob"))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("id INT", "name STRING")
+ .consumedValues(
+ Row.ofKind(RowKind.INSERT, 1,
"Alice"),
+ Row.ofKind(RowKind.INSERT, 2,
"Bob"),
+ Row.ofKind(RowKind.DELETE, 2,
"Bob"))
+ .build())
+ .runTableApi(
+ env ->
+ env.from("cdc_stream")
+ .fromChangelog(
+
lit("SKIP").asArgument("error_handling")),
+ "sink")
+ .build();
+
+ public static final TableTestProgram TABLE_API_ORDER_BY_CUSTOM_OP_MAPPING =
+ TableTestProgram.of(
+
"from-changelog-table-api-order-by-custom-op-mapping",
+ "PartitionedTable.orderBy().fromChangelog() with a
map() op_mapping "
+ + "applies out-of-order events per key in
event-time order")
+ .setupTableSource(
+ SourceTestStep.newBuilder("cdc_stream")
+ .addSchema(
+ "id INT",
+ "op STRING",
+ "name STRING",
+ "event_time TIMESTAMP_LTZ(3)",
+ "WATERMARK FOR event_time AS
event_time - INTERVAL '1' MINUTE")
+ // Each key's events arrive in reverse
event-time order. The
+ // watermark delay keeps them all on time.
+ .producedValues(
+ Row.of(1, "ua", "Alice2",
Instant.ofEpochMilli(2)),
+ Row.of(1, "c", "Alice",
Instant.ofEpochMilli(1)),
+ Row.of(2, "d", "Bob",
Instant.ofEpochMilli(4)),
+ Row.of(2, "c", "Bob",
Instant.ofEpochMilli(3)),
+ Row.of(3, "c", "Carol",
Instant.ofEpochMilli(5)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("id INT PRIMARY KEY NOT
ENFORCED", "name STRING")
+ // Arrival order would leave [1, Alice]
and [2, Bob].
+ .consumedValues("+I[1, Alice2]", "+I[3,
Carol]")
+ .testMaterializedData()
+ .build())
+ .runTableApi(
+ env ->
+ env.from("cdc_stream")
+ .partitionBy($("id"))
+ .orderBy($("event_time").asc())
+ .fromChangelog(
+ map(
+ "c",
+ "INSERT",
+ "ua",
+
"UPDATE_AFTER",
+ "d",
+ "DELETE")
+
.asArgument("op_mapping"))
+ .select($("id"), $("name")),
+ "sink")
+ .build();
+
//
--------------------------------------------------------------------------------------------
// Round-trip test: FROM_CHANGELOG(TO_CHANGELOG(table))
//
--------------------------------------------------------------------------------------------
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogSemanticTests.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogSemanticTests.java
index 24f248f01ce..35b91c54331 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogSemanticTests.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogSemanticTests.java
@@ -39,12 +39,18 @@ public class ToChangelogSemanticTests extends
SemanticTestBase {
ToChangelogTestPrograms.CUSTOM_OP_NAME,
ToChangelogTestPrograms.TABLE_API_DEFAULT,
ToChangelogTestPrograms.TABLE_API_RETRACT_PARTITION_BY,
+ ToChangelogTestPrograms.TABLE_API_CUSTOM_OP_MAPPING,
+ ToChangelogTestPrograms.TABLE_API_DELETION_FLAG,
+ ToChangelogTestPrograms.TABLE_API_PRODUCES_PARTIAL_DELETES,
ToChangelogTestPrograms.LAG_ON_UPSERT_VIA_CHANGELOG,
ToChangelogTestPrograms.LAG_ON_RETRACT_VIA_CHANGELOG,
ToChangelogTestPrograms.DELETION_FLAG,
ToChangelogTestPrograms.INVALID_DESCRIPTOR,
ToChangelogTestPrograms.INVALID_OP_MAPPING,
ToChangelogTestPrograms.DUPLICATE_ROW_KIND,
+ ToChangelogTestPrograms.TABLE_API_OP_MAPPING_WITH_NESTED_CALL,
+
ToChangelogTestPrograms.TABLE_API_OP_MAPPING_WITH_COLUMN_REFERENCE,
+ ToChangelogTestPrograms.TABLE_API_OP_MAPPING_WITH_NULL_ENTRY,
ToChangelogTestPrograms.RETRACT_PRODUCES_PARTIAL_DELETES,
ToChangelogTestPrograms.UPSERT_PRODUCES_FULL_DELETES,
ToChangelogTestPrograms.UPSERT_PRODUCES_PARTIAL_DELETES,
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogTestPrograms.java
index a14b259b068..853e2e1d04a 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ToChangelogTestPrograms.java
@@ -18,6 +18,7 @@
package org.apache.flink.table.planner.plan.nodes.exec.stream;
+import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.api.ValidationException;
import org.apache.flink.table.connector.ChangelogMode;
import org.apache.flink.table.test.program.SinkTestStep;
@@ -29,6 +30,10 @@ import org.apache.flink.types.RowKind;
import java.time.Instant;
import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.descriptor;
+import static org.apache.flink.table.api.Expressions.lit;
+import static org.apache.flink.table.api.Expressions.map;
+import static org.apache.flink.table.api.Expressions.nullOf;
/** {@link TableTestProgram} definitions for testing the built-in TO_CHANGELOG
PTF. */
public class ToChangelogTestPrograms {
@@ -481,6 +486,107 @@ public class ToChangelogTestPrograms {
.runTableApi(env ->
env.from("t").partitionBy($("id")).toChangelog(), "sink")
.build();
+ public static final TableTestProgram TABLE_API_CUSTOM_OP_MAPPING =
+ TableTestProgram.of(
+ "to-changelog-table-api-custom-op-mapping",
+ "Table.toChangelog() with a map() op_mapping maps
change operations to user-defined codes")
+ .setupTableSource(
+ SourceTestStep.newBuilder("t")
+ .addSchema(
+ "name STRING PRIMARY KEY NOT
ENFORCED", "score BIGINT")
+ .addMode(ChangelogMode.all())
+ .producedValues(
+ Row.ofKind(RowKind.INSERT,
"Alice", 10L),
+ Row.ofKind(RowKind.INSERT, "Bob",
20L),
+ Row.ofKind(RowKind.UPDATE_BEFORE,
"Alice", 10L),
+ Row.ofKind(RowKind.UPDATE_AFTER,
"Alice", 30L),
+ Row.ofKind(RowKind.DELETE, "Bob",
20L))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("op_code STRING", "name
STRING", "score BIGINT")
+ .consumedValues(
+ "+I[I, Alice, 10]",
+ "+I[I, Bob, 20]",
+ "+I[U, Alice, 30]")
+ .build())
+ .runTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+
descriptor("op_code").asArgument("op"),
+ map("INSERT", "I",
"UPDATE_AFTER", "U")
+
.asArgument("op_mapping")),
+ "sink")
+ .build();
+
+ public static final TableTestProgram TABLE_API_DELETION_FLAG =
+ TableTestProgram.of(
+ "to-changelog-table-api-deletion-flag",
+ "Table.toChangelog() with a map() op_mapping
produces deletion flag output")
+ .setupTableSource(
+ SourceTestStep.newBuilder("t")
+ .addSchema(
+ "name STRING PRIMARY KEY NOT
ENFORCED", "score BIGINT")
+ .addMode(ChangelogMode.all())
+ .producedValues(
+ Row.ofKind(RowKind.INSERT,
"Alice", 10L),
+ Row.ofKind(RowKind.INSERT, "Bob",
20L),
+ Row.ofKind(RowKind.UPDATE_BEFORE,
"Alice", 10L),
+ Row.ofKind(RowKind.UPDATE_AFTER,
"Alice", 30L),
+ Row.ofKind(RowKind.DELETE, "Bob",
20L))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("deleted STRING", "name
STRING", "score BIGINT")
+ .consumedValues(
+ "+I[false, Alice, 10]",
+ "+I[false, Bob, 20]",
+ "+I[false, Alice, 30]",
+ "+I[true, Bob, 20]")
+ .build())
+ .runTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+
descriptor("deleted").asArgument("op"),
+ map(
+ "INSERT,
UPDATE_AFTER",
+ "false",
+ "DELETE",
+ "true")
+
.asArgument("op_mapping")),
+ "sink")
+ .build();
+
+ public static final TableTestProgram TABLE_API_PRODUCES_PARTIAL_DELETES =
+ TableTestProgram.of(
+ "to-changelog-table-api-produces-partial-deletes",
+ "Table.toChangelog() with
produces_full_deletes=false keeps only the "
+ + "upsert key on DELETE")
+ .setupTableSource(
+ SourceTestStep.newBuilder("t")
+ .addSchema(
+ "name STRING PRIMARY KEY NOT
ENFORCED", "score BIGINT")
+ .addMode(ChangelogMode.upsert())
+ .producedValues(
+ Row.ofKind(RowKind.INSERT,
"Alice", 10L),
+ Row.ofKind(RowKind.DELETE,
"Alice", 10L))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema("op STRING", "name STRING",
"score BIGINT")
+ .consumedValues(
+ "+I[INSERT, Alice, 10]",
"+I[DELETE, Alice, null]")
+ .build())
+ .runTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+
lit(false).asArgument("produces_full_deletes")),
+ "sink")
+ .build();
+
//
--------------------------------------------------------------------------------------------
// Use case: LAG on updating streams via TO_CHANGELOG
//
--------------------------------------------------------------------------------------------
@@ -718,6 +824,54 @@ public class ToChangelogTestPrograms {
"Duplicate change operation: 'DELETE'")
.build();
+ public static final TableTestProgram TABLE_API_OP_MAPPING_WITH_NESTED_CALL
=
+ TableTestProgram.of(
+
"to-changelog-table-api-op-mapping-with-nested-call",
+ "fails when a map() op_mapping entry is a nested
call")
+ .setupTableSource(SIMPLE_SOURCE)
+ .runFailingTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+ map("INSERT",
lit("i").upperCase())
+
.asArgument("op_mapping")),
+ "sink",
+ ValidationException.class,
+ "The 'op_mapping' argument must be a constant MAP
literal.")
+ .build();
+
+ public static final TableTestProgram
TABLE_API_OP_MAPPING_WITH_COLUMN_REFERENCE =
+ TableTestProgram.of(
+
"to-changelog-table-api-op-mapping-with-column-reference",
+ "fails when a map() op_mapping entry is a
non-literal column reference")
+ .setupTableSource(SIMPLE_SOURCE)
+ .runFailingTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+ map("INSERT", $("name"))
+
.asArgument("op_mapping")),
+ "sink",
+ ValidationException.class,
+ "Cannot resolve field [name], input field
list:[].")
+ .build();
+
+ public static final TableTestProgram TABLE_API_OP_MAPPING_WITH_NULL_ENTRY =
+ TableTestProgram.of(
+
"to-changelog-table-api-op-mapping-with-null-entry",
+ "fails when a map() op_mapping entry is NULL")
+ .setupTableSource(SIMPLE_SOURCE)
+ .runFailingTableApi(
+ env ->
+ env.from("t")
+ .toChangelog(
+ map("INSERT",
nullOf(DataTypes.STRING()))
+
.asArgument("op_mapping")),
+ "sink",
+ ValidationException.class,
+ "The 'op_mapping' argument must be a constant MAP
literal.")
+ .build();
+
//
--------------------------------------------------------------------------------------------
// Full vs partial deletes matrix (input kind x PARTITION BY x
produces_full_deletes)
//
--------------------------------------------------------------------------------------------