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

RocMarshal 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 befec0111b8 [FLINK-40924][table-planner] Enforce require-on-conflict 
under upsert-materialize FORCE
befec0111b8 is described below

commit befec0111b8ef3321c6472fb2370e68763eff414
Author: SEPURI-SAI-KRISHNA <[email protected]>
AuthorDate: Thu Oct 15 20:46:28 2026 +0530

    [FLINK-40924][table-planner] Enforce require-on-conflict under 
upsert-materialize FORCE
    
    The ON CONFLICT requirement was validated inside the AUTO branch of
    analyzeUpsertMaterializeStrategy, so setting
    table.exec.sink.upsert-materialize to FORCE skipped it and the query
    planned even though its upsert key differs from the sink's primary key
    and no ON CONFLICT clause was given.
    
    FORCE adds the sink upsert materializer, and the materializer is what
    applies the conflict strategy, so the clause does take effect under
    FORCE. The check that already runs under AUTO now runs there too, with
    the same condition.
    
    NONE keeps its current behaviour on purpose. It removes the
    materializer, so nothing is left to apply a conflict strategy and the
    clause would have no effect. Opting out that way is documented in
    docs/content/docs/sql/reference/dml/insert.md.
    
    Generated-by: Claude Code (Opus 5)
---
 .../FlinkChangelogModeInferenceProgram.scala       | 21 +++++++--
 .../planner/plan/stream/sql/TableSinkTest.xml      | 21 +++++++++
 .../planner/plan/stream/sql/TableSinkTest.scala    | 55 ++++++++++++++++++++++
 3 files changed, 94 insertions(+), 3 deletions(-)

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 f3f0df6e709..82cc30ddf8e 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
@@ -1172,7 +1172,9 @@ class FlinkChangelogModeInferenceProgram extends 
FlinkOptimizeProgram[StreamOpti
      *      contain upsertKeys of the input update stream, unless the input is 
insert only and the
      *      effective conflict strategy is DEDUPLICATE.
      *
-     * Also validates that ON CONFLICT clause is specified when upsert key 
differs from primary key.
+     * Also validates that an ON CONFLICT clause is specified when the upsert 
key differs from the
+     * primary key. That applies to every setting that can add the 
materializer, AUTO and FORCE,
+     * because the materializer is what applies the conflict strategy.
      */
     private def analyzeUpsertMaterializeStrategy(sink: StreamPhysicalSink): 
Boolean = {
       val tableConfig = unwrapTableConfig(sink)
@@ -1209,13 +1211,26 @@ class FlinkChangelogModeInferenceProgram extends 
FlinkOptimizeProgram[StreamOpti
         validateSourcesHaveWatermarks(sink)
       }
 
+      // An ON CONFLICT clause is only meaningful for an upsert sink with a 
primary key, because
+      // it is rejected for append and retract sinks above.
+      val sinkIsUpsertWithPk = primaryKeys.nonEmpty && !sinkIsAppend && 
!sinkIsRetract
+
       
tableConfig.get(ExecutionConfigOptions.TABLE_EXEC_SINK_UPSERT_MATERIALIZE) 
match {
-        case UpsertMaterialize.FORCE => primaryKeys.nonEmpty && !sinkIsRetract
+        case UpsertMaterialize.FORCE =>
+          // FORCE adds the materializer, which is what applies the conflict 
strategy, so an
+          // absent clause is rejected here just as it is under AUTO.
+          if (sinkIsUpsertWithPk) {
+            val upsertKeyDiffersFromPk = !sink.primaryKeysContainsUpsertKey
+            validateOnConflictSpecifiedIfRequired(sink, tableConfig, 
upsertKeyDiffersFromPk)
+          }
+          primaryKeys.nonEmpty && !sinkIsRetract
+        // NONE removes the materializer, so nothing is left to apply a 
conflict strategy and the
+        // clause is not required. This is the documented way to opt out of 
conflict handling.
         case UpsertMaterialize.NONE => false
         case UpsertMaterialize.AUTO =>
           // if the sink is not an UPSERT sink (has no PK, or is an APPEND or 
RETRACT sink)
           // we don't need to materialize results
-          if (primaryKeys.isEmpty || sinkIsAppend || sinkIsRetract) {
+          if (!sinkIsUpsertWithPk) {
             return false
           }
 
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.xml
index 493a6f1f932..019bfcc051f 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.xml
@@ -798,6 +798,27 @@ 
Sink(table=[default_catalog.default_database.sink_agg_with_int_pk], fields=[EXPR
       +- Exchange(distribution=[hash[c]])
          +- Calc(select=[c])
             +- DataStreamScan(table=[[default_catalog, default_database, 
MyTable]], fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testOnConflictIsNotRequiredWithoutMaterialize">
+    <Resource name="ast">
+      <![CDATA[
+LogicalSink(table=[default_catalog.default_database.sinkWithPkNoMaterialize], 
fields=[EXPR$0, EXPR$1])
++- LogicalProject(EXPR$0=[$1], EXPR$1=[$2])
+   +- LogicalAggregate(group=[{0}], EXPR$0=[MAX($1)], EXPR$1=[COUNT()])
+      +- LogicalProject(c=[$2], a=[$0])
+         +- LogicalTableScan(table=[[default_catalog, default_database, 
MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+Sink(table=[default_catalog.default_database.sinkWithPkNoMaterialize], 
fields=[EXPR$0, EXPR$1], changelogMode=[NONE])
++- Calc(select=[EXPR$0, EXPR$1], changelogMode=[I,UB,UA])
+   +- GroupAggregate(groupBy=[c], select=[c, MAX(a) AS EXPR$0, COUNT(*) AS 
EXPR$1], changelogMode=[I,UB,UA])
+      +- Exchange(distribution=[hash[c]], changelogMode=[I])
+         +- Calc(select=[c, a], changelogMode=[I])
+            +- DataStreamScan(table=[[default_catalog, default_database, 
MyTable]], fields=[a, b, c], changelogMode=[I])
 ]]>
     </Resource>
   </TestCase>
diff --git 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.scala
 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.scala
index 346db891909..4183f28f4f1 100644
--- 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.scala
+++ 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/TableSinkTest.scala
@@ -905,12 +905,67 @@ class TableSinkTest extends TableTestBase {
     util.verifyRelPlan(stmtSet, ExplainDetail.CHANGELOG_MODE)
   }
 
+  @Test
+  def testOnConflictIsRequiredWithForcedMaterialize(): Unit = {
+    util.tableEnv.getConfig.set(
+      ExecutionConfigOptions.TABLE_EXEC_SINK_UPSERT_MATERIALIZE,
+      ExecutionConfigOptions.UpsertMaterialize.FORCE)
+    util.addTable(s"""
+                     |CREATE TABLE sinkWithPkForcedMaterialize (
+                     |  `id` INT,
+                     |  `cnt` BIGINT,
+                     |  PRIMARY KEY (id) NOT ENFORCED
+                     |) WITH (
+                     |  'connector' = 'values',
+                     |  'sink-insert-only' = 'false'
+                     |)
+                     |""".stripMargin)
+    val stmtSet = util.tableEnv.createStatementSet()
+    // The upsert key is the grouping key c, which is not written to the sink, 
so it can never
+    // match the primary key. FORCE adds the materializer, so the clause takes 
effect and is
+    // required here for the same reason it is under AUTO.
+    stmtSet.addInsertSql(
+      "INSERT INTO sinkWithPkForcedMaterialize SELECT MAX(a), COUNT(*) FROM 
MyTable GROUP BY c")
+    assertThatThrownBy(() => util.verifyRelPlan(stmtSet, 
ExplainDetail.CHANGELOG_MODE))
+      .isInstanceOf(classOf[ValidationException])
+      .hasMessageContaining(
+        "The query has an upsert key that differs from the primary key of the 
sink table")
+  }
+
+  @Test
+  def testOnConflictIsNotRequiredWithoutMaterialize(): Unit = {
+    util.tableEnv.getConfig.set(
+      ExecutionConfigOptions.TABLE_EXEC_SINK_UPSERT_MATERIALIZE,
+      ExecutionConfigOptions.UpsertMaterialize.NONE)
+    util.addTable(s"""
+                     |CREATE TABLE sinkWithPkNoMaterialize (
+                     |  `id` INT,
+                     |  `cnt` BIGINT,
+                     |  PRIMARY KEY (id) NOT ENFORCED
+                     |) WITH (
+                     |  'connector' = 'values',
+                     |  'sink-insert-only' = 'false'
+                     |)
+                     |""".stripMargin)
+    val stmtSet = util.tableEnv.createStatementSet()
+    // NONE removes the materializer, the only thing that would apply a 
conflict strategy, so the
+    // clause stays optional under it. Keep this behaviour, it is documented 
with the option.
+    stmtSet.addInsertSql(
+      "INSERT INTO sinkWithPkNoMaterialize SELECT MAX(a), COUNT(*) FROM 
MyTable GROUP BY c")
+    util.verifyRelPlan(stmtSet, ExplainDetail.CHANGELOG_MODE)
+  }
+
   @Test
   def testForcedMaterializeWithAppendOnlyInput(): Unit = {
     util.getStreamEnv.setParallelism(1)
     util.tableEnv.getConfig.set(
       ExecutionConfigOptions.TABLE_EXEC_SINK_UPSERT_MATERIALIZE,
       ExecutionConfigOptions.UpsertMaterialize.FORCE)
+    // This test covers what FORCE materializes, not ON CONFLICT enforcement. 
The input is append
+    // only and carries no upsert key, so the clause would otherwise be 
required, exactly as it
+    // already is under AUTO in testAppendOnlyInputWithoutOnConflict.
+    util.tableEnv.getConfig
+      .set(ExecutionConfigOptions.TABLE_EXEC_SINK_REQUIRE_ON_CONFLICT, 
Boolean.box(false))
     util.addTable(s"""
                      |CREATE TABLE forcedSink (
                      |  `a` INT,

Reply via email to