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

fhueske 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 4644cabd8bf [FLINK-39538][table] Support upsert output mode for 
FROM_CHANGELOG (#28164)
4644cabd8bf is described below

commit 4644cabd8bfed1104a6a1dbea854fe45a90877cd
Author: Ramin Gharib <[email protected]>
AuthorDate: Wed May 20 10:54:05 2026 +0200

    [FLINK-39538][table] Support upsert output mode for FROM_CHANGELOG (#28164)
    
    FROM_CHANGELOG now emits an upsert changelog (INSERT, UPDATE_AFTER, full 
DELETE) when the input table is partitioned (set semantics via PARTITION BY) 
and the active op_mapping maps to UPDATE_AFTER without UPDATE_BEFORE. The 
partition key acts as the upsert key. In all other cases the output remains a 
retract changelog.
    
    Submitting an op_mapping with UPDATE_AFTER but no UPDATE_BEFORE without 
PARTITION BY is rejected at validation time, because upsert mode requires a key.
    
    To enable the strategy to inspect the resolved op_mapping and the input 
table's partition keys, ChangelogFunction.ChangelogContext is extended with two 
default methods: getArgumentValue(int, Class) and getTableSemantics(int). 
Defaults return Optional.empty() to preserve source compatibility for existing 
implementations.
    
    The planner-side wrapper in FlinkChangelogModeInferenceProgram delegates 
the two new methods to the underlying CallContext.
    
    Upsert mode uses full deletes (ChangelogMode.upsert(false)) because the 
runtime forwards each input delete row with all fields populated; only the 
RowKind is rewritten. This matches the runtime's behavior and avoids forcing 
downstream operators to reconstruct rows from state.
    
    The upsert key derivation in FlinkRelMdUniqueKeys.getPtfUniqueKeys already 
returns the partition columns when a PTF emits upsert, so no metadata changes 
are needed.
---
 .../docs/sql/reference/queries/changelog.md        | 35 ++++++++++
 flink-python/pyflink/table/table.py                |  6 ++
 .../apache/flink/table/api/PartitionedTable.java   | 12 ++++
 .../java/org/apache/flink/table/api/Table.java     |  5 ++
 .../functions/BuiltInFunctionDefinitions.java      |  4 +-
 .../flink/table/functions/ChangelogFunction.java   | 26 ++++++++
 .../strategies/FromChangelogTypeStrategy.java      | 74 +++++++++++++++++++---
 .../FromChangelogInputTypeStrategyTest.java        | 36 +++++++++--
 .../FlinkChangelogModeInferenceProgram.scala       | 14 +++-
 .../exec/stream/FromChangelogSemanticTests.java    |  1 +
 .../exec/stream/FromChangelogTestPrograms.java     | 32 ++++++++++
 .../planner/plan/stream/sql/FromChangelogTest.java | 26 ++++++++
 .../planner/plan/stream/sql/FromChangelogTest.xml  | 20 ++++++
 .../flink/table/planner/utils/TableTestBase.scala  | 35 ++++++++--
 14 files changed, 302 insertions(+), 24 deletions(-)

diff --git a/docs/content/docs/sql/reference/queries/changelog.md 
b/docs/content/docs/sql/reference/queries/changelog.md
index b02215129e5..3a7a97534c3 100644
--- a/docs/content/docs/sql/reference/queries/changelog.md
+++ b/docs/content/docs/sql/reference/queries/changelog.md
@@ -149,6 +149,41 @@ Prefer row semantics, when possible. `PARTITION BY` is 
only necessary when downs
 
 If you are producing an upsert table — that is, you are emitting 
`UPDATE_AFTER` but no `UPDATE_BEFORE` from your CDC input stream — the 
partition key you select here will be considered both the primary key and the 
upsert key by the engine. Make sure the `PARTITION BY` key matches your primary 
key exactly.
 
+#### Upsert table
+
+To generate an upsert table, two requirements must be met:
+
+* **Key partitioning**: use `PARTITION BY <key>`, where the partition key 
corresponds to the unique/primary key of the dataset.
+* **Op mapping configuration**: the `op_mapping` must include `UPDATE_AFTER` 
and must NOT include `UPDATE_BEFORE`.
+
+An `op_mapping` that produces `UPDATE_AFTER` without `UPDATE_BEFORE` describes 
an upsert changelog and requires a key. The engine assumes that the keys 
provided in the `PARTITION BY` clause function as the unique upsert keys. The 
resulting output changelog becomes an upsert table keyed on these partition 
columns. Each incoming row is evaluated and produces `INSERT`, `UPDATE_AFTER`, 
or `DELETE` events, using the partition key as the explicit upsert key. 
Therefore, if the incoming changelo [...]
+
+```sql
+-- Upsert input: INSERT / UPDATE_AFTER / DELETE only
+-- +I[id:1, op:'INSERT',       name:'Alice']
+-- +I[id:2, op:'INSERT',       name:'Bob']
+-- +I[id:1, op:'UPDATE_AFTER', name:'Alice2']
+-- +I[id:2, op:'DELETE',       name:'Bob']
+
+SELECT * FROM FROM_CHANGELOG(
+  input => TABLE cdc_stream PARTITION BY id,
+  op_mapping => MAP[
+    'INSERT',       'INSERT',
+    'UPDATE_AFTER', 'UPDATE_AFTER',
+    'DELETE',       'DELETE']
+)
+
+-- Output (upsert changelog, upsert key = id):
+-- +I[id:1, name:'Alice']
+-- +I[id:2, name:'Bob']
+-- +U[id:1, name:'Alice2']
+-- -D[id:2, name:'Bob']
+```
+
+The `FROM_CHANGELOG` PTF assumes events arrive in order. If the source itself 
does not guarantee ordering for events sharing the same `PARTITION BY` key, use 
[`ORDER BY`]({{< ref "docs/dev/table/functions/ptfs" >}}#ordering) to reorder 
them.
+
+By default, without `PARTITION BY`, or when the active `op_mapping` includes 
`UPDATE_BEFORE`, the output remains a retract changelog.
+
 #### Ordering CDC events with ORDER BY
 
 CDC streams can deliver events out of order. For example, a key's 
`UPDATE_AFTER` may arrive before its matching `UPDATE_BEFORE` when events are 
partitioned across upstream brokers. If the source itself does not guarantee 
ordering, applying such a changelog directly produces incorrect state.
diff --git a/flink-python/pyflink/table/table.py 
b/flink-python/pyflink/table/table.py
index bebf1cafa29..583167fa478 100644
--- a/flink-python/pyflink/table/table.py
+++ b/flink-python/pyflink/table/table.py
@@ -1234,6 +1234,12 @@ class Table(object):
         in the mapping; pass ``error_handling => 'SKIP'`` to silently drop 
those
         rows instead.
 
+        The output is a retract changelog. To emit an upsert changelog 
instead, combine
+        ``PARTITION BY`` (set semantics on the table argument) with an 
``op_mapping`` that
+        maps to ``UPDATE_AFTER`` without ``UPDATE_BEFORE``. The partition key 
becomes the
+        upsert key. An upsert mapping without ``PARTITION BY`` is rejected at 
validation
+        time, since upsert mode requires a key.
+
         Example:
         ::
 
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/PartitionedTable.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/PartitionedTable.java
index 3f65a22075b..51086f1edfe 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/PartitionedTable.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/PartitionedTable.java
@@ -226,6 +226,18 @@ public interface PartitionedTable {
      * <p>For row semantics (each row processed independently), use {@link 
Table#fromChangelog} on
      * the unpartitioned table.
      *
+     * <p>Output changelog mode:
+     *
+     * <ul>
+     *   <li><b>Retract</b> (default): the active {@code op_mapping} includes 
{@code UPDATE_BEFORE}
+     *       or no updates at all. The output possibly emits {@code INSERT}, 
{@code UPDATE_BEFORE},
+     *       {@code UPDATE_AFTER}, and {@code DELETE}.
+     *   <li><b>Upsert</b>: the {@code op_mapping} maps {@code UPDATE_AFTER} 
without {@code
+     *       UPDATE_BEFORE}. The output emits {@code INSERT}, {@code 
UPDATE_AFTER}, and {@code
+     *       DELETE}, keyed on the partition columns. An upsert mapping 
without {@code PARTITION BY}
+     *       is rejected at validation time, since upsert mode requires a key.
+     * </ul>
+     *
      * <p>Examples:
      *
      * <pre>{@code
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Table.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Table.java
index e7f690263a4..0ffb90c5fcc 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Table.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Table.java
@@ -1489,6 +1489,11 @@ public interface Table extends Explainable<Table>, 
Executable {
      *     .fromChangelog();
      * }</pre>
      *
+     * <p>The output is a retract changelog. To emit an upsert changelog 
instead, combine {@code
+     * PARTITION BY} with an {@code op_mapping} that maps to {@code 
UPDATE_AFTER} without {@code
+     * UPDATE_BEFORE}. The partition key becomes the upsert key. See {@link
+     * PartitionedTable#fromChangelog(Expression...)} for details.
+     *
      * <p>Optional arguments can be passed using named expressions:
      *
      * <pre>{@code
diff --git 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
index 7a25ae6058a..4bf07dc4337 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
@@ -27,7 +27,6 @@ import org.apache.flink.table.api.JsonQueryWrapper;
 import org.apache.flink.table.api.JsonType;
 import org.apache.flink.table.api.JsonValueOnEmptyOrError;
 import org.apache.flink.table.api.TableException;
-import org.apache.flink.table.connector.ChangelogMode;
 import org.apache.flink.table.expressions.TimeIntervalUnit;
 import org.apache.flink.table.expressions.TimePointUnit;
 import org.apache.flink.table.expressions.ValueLiteralExpression;
@@ -39,6 +38,7 @@ import 
org.apache.flink.table.types.inference.StaticArgumentTrait;
 import org.apache.flink.table.types.inference.TraitCondition;
 import org.apache.flink.table.types.inference.TypeStrategies;
 import 
org.apache.flink.table.types.inference.strategies.ArrayOfStringArgumentTypeStrategy;
+import 
org.apache.flink.table.types.inference.strategies.FromChangelogTypeStrategy;
 import 
org.apache.flink.table.types.inference.strategies.SpecificInputTypeStrategies;
 import 
org.apache.flink.table.types.inference.strategies.SpecificTypeStrategies;
 import org.apache.flink.table.types.logical.LocalZonedTimestampType;
@@ -854,7 +854,7 @@ public final class BuiltInFunctionDefinitions {
                                     DataTypes.MAP(DataTypes.STRING(), 
DataTypes.STRING()),
                                     true),
                             StaticArgument.scalar("error_handling", 
DataTypes.STRING(), true))
-                    .changelogModeStrategy(ctx -> ChangelogMode.all())
+                    
.changelogModeStrategy(FromChangelogTypeStrategy.CHANGELOG_MODE_STRATEGY)
                     .inputTypeStrategy(FROM_CHANGELOG_INPUT_TYPE_STRATEGY)
                     .outputTypeStrategy(FROM_CHANGELOG_OUTPUT_TYPE_STRATEGY)
                     .runtimeClass(
diff --git 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ChangelogFunction.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ChangelogFunction.java
index ad6e56d09d9..e9b16bff6cf 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ChangelogFunction.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ChangelogFunction.java
@@ -21,8 +21,11 @@ package org.apache.flink.table.functions;
 import org.apache.flink.annotation.PublicEvolving;
 import org.apache.flink.table.annotation.ArgumentTrait;
 import org.apache.flink.table.connector.ChangelogMode;
+import org.apache.flink.table.types.logical.LogicalType;
 import org.apache.flink.types.RowKind;
 
+import java.util.Optional;
+
 /**
  * An extension that allows a process table function (PTF) to emit results 
with changelog semantics.
  *
@@ -105,5 +108,28 @@ public interface ChangelogFunction extends 
FunctionDefinition {
          * are required and {@link ChangelogMode#keyOnlyDeletes()} are 
supported.
          */
         ChangelogMode getRequiredChangelogMode();
+
+        /**
+         * Returns the resolved literal value of the scalar argument at the 
given position.
+         *
+         * <p>Returns empty if the argument is absent, NULL, not a literal, or 
cannot be expressed
+         * as an instance of the provided class. Conversions follow the 
default conversion classes
+         * of {@link LogicalType LogicalTypes}.
+         */
+        default <T> Optional<T> getArgumentValue(int pos, Class<T> clazz) {
+            return Optional.empty();
+        }
+
+        /**
+         * Returns information about the table that has been passed to a table 
argument at the given
+         * position.
+         *
+         * <p>Semantics are only available for table arguments. They expose, 
for example, the
+         * partition-by columns when the table argument uses {@link 
ArgumentTrait#SET_SEMANTIC_TABLE
+         * set semantics}.
+         */
+        default Optional<TableSemantics> getTableSemantics(int pos) {
+            return Optional.empty();
+        }
     }
 }
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 f4d54f276d7..13fa1245e62 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
@@ -22,6 +22,9 @@ import org.apache.flink.annotation.Internal;
 import org.apache.flink.table.api.DataTypes;
 import org.apache.flink.table.api.DataTypes.Field;
 import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.connector.ChangelogMode;
+import org.apache.flink.table.functions.ChangelogFunction.ChangelogContext;
+import org.apache.flink.table.functions.ChangelogModeStrategy;
 import org.apache.flink.table.functions.TableSemantics;
 import org.apache.flink.table.types.DataType;
 import org.apache.flink.table.types.inference.CallContext;
@@ -36,9 +39,11 @@ import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Set;
+import java.util.function.IntFunction;
 import java.util.stream.Collectors;
 
 /** Type strategies for the {@code FROM_CHANGELOG} process table function. */
@@ -95,6 +100,57 @@ public final class FromChangelogTypeStrategy {
                 return Optional.of(DataTypes.ROW(outputFields).notNull());
             };
 
+    // 
--------------------------------------------------------------------------------------------
+    // Changelog mode inference
+    // 
--------------------------------------------------------------------------------------------
+
+    /**
+     * Emits an upsert changelog when the input is partitioned (set semantics) 
and the resolved
+     * {@code op_mapping} maps to {@code UPDATE_AFTER} without {@code 
UPDATE_BEFORE}. In all other
+     * cases the output is a retract changelog. When upsert mode is selected, 
the partition key acts
+     * as the upsert key.
+     */
+    public static final ChangelogModeStrategy CHANGELOG_MODE_STRATEGY =
+            ctx -> isUpsertConfig(ctx) ? ChangelogMode.upsert(false) : 
ChangelogMode.all();
+
+    /**
+     * Returns {@code true} when the FROM_CHANGELOG call should emit an upsert 
changelog: the input
+     * table is partitioned AND the resolved {@code op_mapping} contains 
{@code UPDATE_AFTER}
+     * without {@code UPDATE_BEFORE}. Falls back to {@code false} when the 
mapping is absent or
+     * cannot be resolved as a literal. The default mapping maps to a retract 
table.
+     */
+    private static boolean isUpsertConfig(final ChangelogContext ctx) {
+        if (!isPartitioned(ctx::getTableSemantics)) {
+            return false;
+        }
+        return ctx.getArgumentValue(ARG_OP_MAPPING, Map.class)
+                .map(FromChangelogTypeStrategy::describesUpsert)
+                .orElse(false);
+    }
+
+    /**
+     * Returns {@code true} if the mapping maps {@code UPDATE_AFTER} without 
{@code UPDATE_BEFORE},
+     * i.e. it describes an upsert changelog.
+     */
+    private static boolean describesUpsert(final Map<String, String> mapping) {
+        return mappingContains(mapping, UPDATE_AFTER) && 
!mappingContains(mapping, UPDATE_BEFORE);
+    }
+
+    private static boolean mappingContains(
+            final Map<String, String> mapping, final String rowKindName) {
+        return mapping.values().stream()
+                .filter(Objects::nonNull)
+                .anyMatch(v -> rowKindName.equals(v.trim()));
+    }
+
+    private static boolean isPartitioned(
+            final IntFunction<Optional<TableSemantics>> tableSemantics) {
+        return tableSemantics
+                .apply(ARG_TABLE)
+                .map(ts -> ts.partitionByColumns().length > 0)
+                .orElse(false);
+    }
+
     // 
--------------------------------------------------------------------------------------------
     // Helpers
     // 
--------------------------------------------------------------------------------------------
@@ -200,17 +256,17 @@ public final class FromChangelogTypeStrategy {
             if (validationError.isPresent()) {
                 return validationError;
             }
-
-            final boolean hasUpdateBefore =
-                    mapping.values().stream().anyMatch(v -> 
UPDATE_BEFORE.equals(v.trim()));
-            final boolean hasUpdateAfter =
-                    mapping.values().stream().anyMatch(v -> 
UPDATE_AFTER.equals(v.trim()));
-            if (hasUpdateAfter && !hasUpdateBefore) {
+            // 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,
-                        "The 'op_mapping' must include UPDATE_BEFORE for 
retract mode. "
-                                + "Upsert mode (without UPDATE_BEFORE) is not 
supported "
-                                + "in this version.");
+                        "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();
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 322cf94a49e..70595f1c597 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
@@ -139,11 +139,33 @@ class FromChangelogInputTypeStrategyTest extends 
InputTypeStrategiesTestBase {
                         .calledWithLiteralAt(3, "FAIL")
                         .expectArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE, 
MAP_TYPE, STRING_TYPE),
 
-                // Error: UPDATE_AFTER without UPDATE_BEFORE not supported
+                // Valid: upsert mapping (INSERT + UPDATE_AFTER + DELETE, no 
UPDATE_BEFORE) with
+                // PARTITION BY. FROM_CHANGELOG emits an upsert changelog 
keyed on the partition
+                // column.
                 TestSpec.forStrategy(
-                                "UPDATE_AFTER requires UPDATE_BEFORE",
+                                "Valid upsert mapping with PARTITION BY",
                                 FROM_CHANGELOG_INPUT_TYPE_STRATEGY)
-                        .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE, 
MAP_TYPE)
+                        .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE, 
MAP_TYPE, STRING_TYPE)
+                        .calledWithTableSemanticsAt(
+                                0,
+                                new TableSemanticsMock(
+                                        TABLE_TYPE, new int[] {0}, new int[0], 
-1, null))
+                        .calledWithLiteralAt(1, ColumnList.of("op"))
+                        .calledWithLiteralAt(
+                                2,
+                                Map.of(
+                                        "c", "INSERT",
+                                        "u", "UPDATE_AFTER",
+                                        "d", "DELETE"))
+                        .calledWithLiteralAt(3, "FAIL")
+                        .expectArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE, 
MAP_TYPE, STRING_TYPE),
+
+                // Error: upsert mapping (INSERT + UPDATE_AFTER + DELETE) 
without PARTITION BY.
+                // Upsert mode requires a key, so the call must use set 
semantics.
+                TestSpec.forStrategy(
+                                "Upsert mapping without PARTITION BY rejected",
+                                FROM_CHANGELOG_INPUT_TYPE_STRATEGY)
+                        .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE, 
MAP_TYPE, STRING_TYPE)
                         .calledWithTableSemanticsAt(0, new 
TableSemanticsMock(TABLE_TYPE))
                         .calledWithLiteralAt(1, ColumnList.of("op"))
                         .calledWithLiteralAt(
@@ -152,7 +174,13 @@ class FromChangelogInputTypeStrategyTest extends 
InputTypeStrategiesTestBase {
                                         "c", "INSERT",
                                         "u", "UPDATE_AFTER",
                                         "d", "DELETE"))
-                        .expectErrorMessage("must include UPDATE_BEFORE"),
+                        .calledWithLiteralAt(3, "FAIL")
+                        .expectErrorMessage(
+                                "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."),
 
                 // Error: Invalid error_handling mode
                 TestSpec.forStrategy(
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
index 6839cc1c28c..fd646a34148 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
@@ -23,7 +23,7 @@ import 
org.apache.flink.table.api.InsertConflictStrategy.ConflictBehavior
 import org.apache.flink.table.api.config.ExecutionConfigOptions
 import 
org.apache.flink.table.api.config.ExecutionConfigOptions.UpsertMaterialize
 import org.apache.flink.table.connector.ChangelogMode
-import org.apache.flink.table.functions.{BuiltInFunctionDefinition, 
ChangelogFunction}
+import org.apache.flink.table.functions.{BuiltInFunctionDefinition, 
ChangelogFunction, TableSemantics}
 import org.apache.flink.table.functions.ChangelogFunction.ChangelogContext
 import org.apache.flink.table.planner.calcite.{FlinkTypeFactory, 
RexTableArgCall}
 import org.apache.flink.table.planner.functions.bridging.BridgingSqlFunction
@@ -1678,8 +1678,8 @@ class FlinkChangelogModeInferenceProgram extends 
FlinkOptimizeProgram[StreamOpti
     val callContext =
       function.toCallContext(udfCall, inputTimeColumns, inputChangelogModes, 
outputChangelogMode)
 
-    // Expose a simplified context to let users focus on important 
characteristics.
-    // If necessary, we can expose the full CallContext in the future.
+    // Expose a simplified context focused on changelog-relevant inputs: 
changelog modes,
+    // resolved literal arguments, and table semantics (e.g., partition-by 
columns).
     new ChangelogContext {
       override def getTableChangelogMode(pos: Int): ChangelogMode = {
         val tableSemantics = callContext.getTableSemantics(pos).orElse(null)
@@ -1692,6 +1692,14 @@ class FlinkChangelogModeInferenceProgram extends 
FlinkOptimizeProgram[StreamOpti
       override def getRequiredChangelogMode: ChangelogMode = {
         callContext.getOutputChangelogMode.orElse(null)
       }
+
+      override def getArgumentValue[T](pos: Int, clazz: Class[T]): 
java.util.Optional[T] = {
+        callContext.getArgumentValue(pos, clazz)
+      }
+
+      override def getTableSemantics(pos: Int): 
java.util.Optional[TableSemantics] = {
+        callContext.getTableSemantics(pos)
+      }
     }
   }
 
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 c3922fb28ad..2892d22eda9 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
@@ -43,6 +43,7 @@ public class FromChangelogSemanticTests extends 
SemanticTestBase {
                 FromChangelogTestPrograms.CUSTOM_OP_MAPPING,
                 FromChangelogTestPrograms.CUSTOM_OP_NAME,
                 FromChangelogTestPrograms.RETRACT_PARTITION_BY,
+                FromChangelogTestPrograms.UPSERT_PARTITION_BY,
                 FromChangelogTestPrograms.DELETION_FLAG_PARTITION_BY,
                 FromChangelogTestPrograms.SKIP_INVALID_OP_HANDLING,
                 FromChangelogTestPrograms.SKIP_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 a964a57bdbe..2ab92f052d7 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
@@ -200,6 +200,38 @@ public class FromChangelogTestPrograms {
                                     + "input => TABLE cdc_stream PARTITION BY 
id)")
                     .build();
 
+    public static final TableTestProgram UPSERT_PARTITION_BY =
+            TableTestProgram.of(
+                            "from-changelog-upsert-partition-by",
+                            "PARTITION BY + op_mapping without UPDATE_BEFORE 
produces an "
+                                    + "upsert changelog keyed on the partition 
columns")
+                    .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())
+                    .runSql(
+                            "INSERT INTO sink SELECT * FROM FROM_CHANGELOG("
+                                    + "input => TABLE cdc_stream PARTITION BY 
id, "
+                                    + "op_mapping => MAP["
+                                    + "'INSERT', 'INSERT', "
+                                    + "'UPDATE_AFTER', 'UPDATE_AFTER', "
+                                    + "'DELETE', 'DELETE'])")
+                    .build();
+
     public static final TableTestProgram DELETION_FLAG_PARTITION_BY =
             TableTestProgram.of(
                             "from-changelog-deletion-flag-partition-by",
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.java
index 17d7f64fa48..100d7b3e505 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.java
@@ -20,6 +20,7 @@ package org.apache.flink.table.planner.plan.stream.sql;
 
 import org.apache.flink.table.api.ExplainDetail;
 import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.planner.utils.PlanKind;
 import org.apache.flink.table.planner.utils.TableTestBase;
 import org.apache.flink.table.planner.utils.TableTestUtil;
 
@@ -29,6 +30,8 @@ import org.junit.jupiter.api.Test;
 import java.util.Collections;
 import java.util.List;
 
+import scala.Enumeration;
+
 /**
  * Plan tests for the FROM_CHANGELOG built-in process table function. Uses 
{@link
  * ExplainDetail#CHANGELOG_MODE} to verify changelog mode propagation through 
the plan.
@@ -71,4 +74,27 @@ public class FromChangelogTest extends TableTestBase {
                 "SELECT * FROM FROM_CHANGELOG(input => TABLE cdc_stream 
PARTITION BY id)",
                 CHANGELOG_MODE);
     }
+
+    @Test
+    void testUpsertPartitionBy() {
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE cdc_stream ("
+                                + "  id INT,"
+                                + "  op STRING,"
+                                + "  name STRING"
+                                + ") WITH ('connector' = 'values')");
+        util.doVerifyPlan(
+                "SELECT * FROM FROM_CHANGELOG("
+                        + "input => TABLE cdc_stream PARTITION BY id, "
+                        + "op_mapping => MAP["
+                        + "'INSERT', 'INSERT', "
+                        + "'UPDATE_AFTER', 'UPDATE_AFTER', "
+                        + "'DELETE', 'DELETE'])",
+                new ExplainDetail[] {ExplainDetail.CHANGELOG_MODE},
+                false,
+                new Enumeration.Value[] {PlanKind.AST(), PlanKind.OPT_REL()},
+                false,
+                true);
+    }
 }
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.xml
index 2addfe9f5ff..5e632be5788 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/FromChangelogTest.xml
@@ -50,6 +50,26 @@ LogicalProject(id=[$0], name=[$1])
     <Resource name="optimized rel plan">
       <![CDATA[
 ProcessTableFunction(invocation=[FROM_CHANGELOG(TABLE(#0) PARTITION BY($0), 
DEFAULT(), DEFAULT(), DEFAULT(), DEFAULT(), DEFAULT())], uid=[FROM_CHANGELOG], 
select=[id,name], rowType=[RecordType(INTEGER id, VARCHAR(2147483647) name)], 
changelogMode=[I,UB,UA,D])
++- Exchange(distribution=[hash[id]], changelogMode=[I])
+   +- TableSourceScan(table=[[default_catalog, default_database, cdc_stream]], 
fields=[id, op, name], changelogMode=[I])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUpsertPartitionBy">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM FROM_CHANGELOG(input => TABLE cdc_stream 
PARTITION BY id, op_mapping => MAP['INSERT', 'INSERT', 'UPDATE_AFTER', 
'UPDATE_AFTER', 'DELETE', 'DELETE'])]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(id=[$0], name=[$1])
++- LogicalTableFunctionScan(invocation=[FROM_CHANGELOG(TABLE(#0) PARTITION 
BY($0), DEFAULT(), MAP(_UTF-16LE'INSERT':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'INSERT':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'UPDATE_AFTER':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'UPDATE_AFTER':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'DELETE':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'DELETE':VARCHAR(12) CHARACTER SET "UTF-16LE"), DEFAULT(), DEFAULT(), 
DEFAULT())], rowType=[R [...]
+   +- LogicalProject(id=[$0], op=[$1], name=[$2])
+      +- LogicalTableScan(table=[[default_catalog, default_database, 
cdc_stream]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+ProcessTableFunction(invocation=[FROM_CHANGELOG(TABLE(#0) PARTITION BY($0), 
DEFAULT(), MAP(_UTF-16LE'INSERT':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'INSERT':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'UPDATE_AFTER':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'UPDATE_AFTER':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'DELETE':VARCHAR(12) CHARACTER SET "UTF-16LE", 
_UTF-16LE'DELETE':VARCHAR(12) CHARACTER SET "UTF-16LE"), DEFAULT(), DEFAULT(), 
DEFAULT())], uid=[FROM_CHANGEL [...]
 +- Exchange(distribution=[hash[id]], changelogMode=[I])
    +- TableSourceScan(table=[[default_catalog, default_database, cdc_stream]], 
fields=[id, op, name], changelogMode=[I])
 ]]>
diff --git 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/utils/TableTestBase.scala
 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/utils/TableTestBase.scala
index 3e8d5729ee7..70ad0d426d7 100644
--- 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/utils/TableTestBase.scala
+++ 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/utils/TableTestBase.scala
@@ -614,7 +614,7 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
 
   /** Java-friendly overload that accepts a list of [[ExplainDetail]]s. */
   def verifyRelPlan(query: String, extraDetails: 
java.util.List[ExplainDetail]): Unit = {
-    verifyRelPlan(query, extraDetails.asScala.toSeq: _*)
+    verifyRelPlan(query, extraDetails.asScala: _*)
   }
 
   /**
@@ -1048,6 +1048,22 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
       withRowType: Boolean,
       expectedPlans: Array[PlanKind],
       withQueryBlockAlias: Boolean): Unit = {
+    doVerifyPlan(
+      query,
+      extraDetails,
+      withRowType,
+      expectedPlans,
+      withQueryBlockAlias,
+      withUpsertKey = false)
+  }
+
+  def doVerifyPlan(
+      query: String,
+      extraDetails: Array[ExplainDetail],
+      withRowType: Boolean,
+      expectedPlans: Array[PlanKind],
+      withQueryBlockAlias: Boolean,
+      withUpsertKey: Boolean): Unit = {
     val table = getTableEnv.sqlQuery(query)
     val relNode = TableTestUtil.toRelNode(table)
 
@@ -1057,7 +1073,9 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
       withRowType,
       expectedPlans,
       () => assertEqualsOrExpand("sql", query),
-      withQueryBlockAlias)
+      withQueryBlockAlias = withQueryBlockAlias,
+      withUpsertKey = withUpsertKey
+    )
   }
 
   /**
@@ -1192,7 +1210,8 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
       expectedPlans: Array[PlanKind],
       assertSqlEqualsOrExpandFunc: () => Unit,
       withQueryBlockAlias: Boolean = false,
-      withDuplicateChanges: Boolean = false): Unit = {
+      withDuplicateChanges: Boolean = false,
+      withUpsertKey: Boolean = false): Unit = {
 
     val expectedPlanKinds = new 
util.HashSet[PlanKind](expectedPlans.toSeq.asJava)
 
@@ -1219,7 +1238,8 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
         optimizedRels.toArray,
         extraDetails,
         withRowType = withRowType,
-        withDuplicateChanges = withDuplicateChanges)
+        withDuplicateChanges = withDuplicateChanges,
+        withUpsertKey = withUpsertKey)
 
     // build optimized exec plan if `expectedPlanKinds` contains OPT_EXEC
     val optimizedExecPlan = if (expectedPlanKinds.contains(PlanKind.OPT_EXEC)) 
{
@@ -1276,7 +1296,8 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
       optimizedRels: Array[RelNode],
       extraDetails: Array[ExplainDetail],
       withRowType: Boolean,
-      withDuplicateChanges: Boolean): String = {
+      withDuplicateChanges: Boolean,
+      withUpsertKey: Boolean = false): String = {
     require(optimizedRels.nonEmpty)
     val explainLevel = if 
(extraDetails.contains(ExplainDetail.ESTIMATED_COST)) {
       SqlExplainLevel.ALL_ATTRIBUTES
@@ -1303,7 +1324,9 @@ abstract class TableTestUtilBase(test: TableTestBase, 
isStreamingMode: Boolean)
                   detailLevel = explainLevel,
                   withChangelogTraits = withChangelogTraits,
                   withRowType = withRowType,
-                  withDuplicateChangesTrait = withDuplicateChanges)
+                  withUpsertKey = withUpsertKey,
+                  withDuplicateChangesTrait = withDuplicateChanges
+                )
             }
             .mkString("\n")
         }


Reply via email to