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 cce8840bbfc [FLINK-39125][table] Support injective casts from 
BINARY/VARBINARY to CHAR/VARCHAR for upsert keys
cce8840bbfc is described below

commit cce8840bbfc8825ea76510855c4323679f68ed8d
Author: Gustavo de Morais <[email protected]>
AuthorDate: Mon Jun 22 11:27:56 2026 +0200

    [FLINK-39125][table] Support injective casts from BINARY/VARBINARY to 
CHAR/VARCHAR for upsert keys
    
    This closes #28480.
---
 .../types/logical/utils/LogicalTypeCasts.java      | 25 ++++++++++
 .../flink/table/types/LogicalTypeCastsTest.java    | 33 +++++++++++--
 .../plan/metadata/FlinkRelMdUniqueKeys.scala       | 36 +++++++++-----
 .../plan/metadata/FlinkRelMdUpsertKeys.scala       |  5 +-
 .../plan/metadata/FlinkRelMdUpsertKeysTest.scala   | 57 ++++++++++++++++++++--
 5 files changed, 136 insertions(+), 20 deletions(-)

diff --git 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
index 85de267a368..a0afca2caa0 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
@@ -167,6 +167,29 @@ public final class LogicalTypeCasts {
                 return (long) getLength(target) >= (long) getLength(source) * 
4;
             };
 
+    /**
+     * Injective when the target char length can hold a UTF-8 byte string (at 
most one char per
+     * byte).
+     */
+    private static final BiPredicate<LogicalType, LogicalType> 
WHEN_CHAR_LENGTH_FITS_UTF8 =
+            (source, target) -> {
+                // Only CHAR with max length is safe.
+                // Bounded CHAR right-pads short values with spaces, so 
distinct inputs collide.
+                // Example of collision for CHAR(3):
+                //   bytes [0x61] -> "a" -> "a  "
+                //   bytes [0x61,0x20,0x20] -> "a  "
+                if (target.is(CHAR) && !hasMaxLength(target)) {
+                    return false;
+                }
+                if (hasMaxLength(target)) {
+                    return true;
+                }
+                if (hasMaxLength(source)) {
+                    return false;
+                }
+                return getLength(target) >= getLength(source);
+            };
+
     static {
         implicitCastingRules = new HashMap<>();
         explicitCastingRules = new HashMap<>();
@@ -188,6 +211,7 @@ public final class LogicalTypeCasts {
                 .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP)
                 .injectiveFrom(WHEN_LENGTH_FITS, CHAR)
                 .injectiveFrom(WHEN_MAX_CHAR_LENGTH_FITS, 
STRING_INJECTIVE_SOURCES)
+                .injectiveFrom(WHEN_CHAR_LENGTH_FITS_UTF8, BINARY, VARBINARY)
                 .build();
 
         castTo(VARCHAR)
@@ -196,6 +220,7 @@ public final class LogicalTypeCasts {
                 .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP)
                 .injectiveFrom(WHEN_LENGTH_FITS, CHAR, VARCHAR)
                 .injectiveFrom(WHEN_MAX_CHAR_LENGTH_FITS, 
STRING_INJECTIVE_SOURCES)
+                .injectiveFrom(WHEN_CHAR_LENGTH_FITS_UTF8, BINARY, VARBINARY)
                 .build();
 
         // 
-----------------------------------------------------------------------------------------
diff --git 
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
 
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
index e6ac53f79bd..3d0dec23997 100644
--- 
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
+++ 
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
@@ -340,9 +340,9 @@ class LogicalTypeCastsTest {
                 // DECIMAL to STRING is NOT considered injective
                 Arguments.of(new DecimalType(10, 2), VarCharType.STRING_TYPE, 
false),
 
-                // BYTES to STRING is NOT injective (invalid UTF-8 sequences 
collapse)
-                Arguments.of(new VarBinaryType(100), VarCharType.STRING_TYPE, 
false),
-                Arguments.of(new BinaryType(100), VarCharType.STRING_TYPE, 
false),
+                // BYTES to STRING is injective: UTF-8 decodes to at most one 
char per byte
+                Arguments.of(new VarBinaryType(100), VarCharType.STRING_TYPE, 
true),
+                Arguments.of(new BinaryType(100), VarCharType.STRING_TYPE, 
true),
 
                 // TIMESTAMP_WITH_TIME_ZONE to STRING is NOT injective
                 // (theory: two timestamps with different zones could produce 
same string
@@ -507,7 +507,32 @@ class LogicalTypeCastsTest {
                 // CHAR(10) → VARBINARY(40): fixed char to var binary
                 Arguments.of(new CharType(10), new VarBinaryType(40), true),
                 // VARCHAR(10) → BINARY(40): var char to fixed binary
-                Arguments.of(new VarCharType(10), new BinaryType(40), true));
+                Arguments.of(new VarCharType(10), new BinaryType(40), true),
+
+                // ---- Binary to string injective casts (UTF-8: at most one 
char per byte) ----
+
+                // VARBINARY(MAX) → VARCHAR(MAX): both unbounded
+                Arguments.of(
+                        new VarBinaryType(VarBinaryType.MAX_LENGTH), 
VarCharType.STRING_TYPE, true),
+                // BINARY(MAX) → VARCHAR(MAX): both unbounded
+                Arguments.of(new BinaryType(BinaryType.MAX_LENGTH), 
VarCharType.STRING_TYPE, true),
+                // VARBINARY(10) → VARCHAR(10): exact fit
+                Arguments.of(new VarBinaryType(10), new VarCharType(10), true),
+                // VARBINARY(10) → VARCHAR(9): one char short
+                Arguments.of(new VarBinaryType(10), new VarCharType(9), false),
+                // VARBINARY(10) → VARCHAR(MAX): bounded source, unbounded 
target
+                Arguments.of(new VarBinaryType(10), VarCharType.STRING_TYPE, 
true),
+                // VARBINARY(MAX) → VARCHAR(100): unbounded source, bounded 
target
+                Arguments.of(
+                        new VarBinaryType(VarBinaryType.MAX_LENGTH), new 
VarCharType(100), false),
+                // BINARY(10) → VARCHAR(10): fixed binary to var char, exact 
fit
+                Arguments.of(new BinaryType(10), new VarCharType(10), true),
+                // VARBINARY(10) → CHAR(10): bounded CHAR pads short values, 
NOT injective
+                Arguments.of(new VarBinaryType(10), new CharType(10), false),
+                // VARBINARY(10) → CHAR(MAX): unbounded CHAR does not pad, 
injective
+                Arguments.of(new VarBinaryType(10), new 
CharType(CharType.MAX_LENGTH), true),
+                // BINARY(10) → CHAR(20): bounded CHAR pads even when wider, 
NOT injective
+                Arguments.of(new BinaryType(10), new CharType(20), false));
     }
 
     @ParameterizedTest(name = "{index}: [From: {0}, To: {1}, Injective: {2}]")
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUniqueKeys.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUniqueKeys.scala
index c445d8b2949..da15806cb98 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUniqueKeys.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUniqueKeys.scala
@@ -17,6 +17,7 @@
  */
 package org.apache.flink.table.planner.plan.metadata
 
+import org.apache.flink.table.api.config.ExecutionConfigOptions
 import org.apache.flink.table.catalog.{CatalogTable, ResolvedCatalogBaseTable}
 import org.apache.flink.table.connector.ChangelogMode
 import org.apache.flink.table.planner._
@@ -27,8 +28,10 @@ import 
org.apache.flink.table.planner.plan.nodes.physical.common.CommonPhysicalL
 import org.apache.flink.table.planner.plan.nodes.physical.stream._
 import org.apache.flink.table.planner.plan.schema.{FlinkPreparingTableBase, 
TableSourceTable}
 import org.apache.flink.table.planner.plan.utils.{ChangelogPlanUtils, 
FlinkRelMdUtil, RankUtil}
+import org.apache.flink.table.planner.utils.ShortcutUtils.unwrapTableConfig
 import org.apache.flink.table.runtime.groupwindow.NamedWindowProperty
 import org.apache.flink.table.runtime.operators.rank.RankType
+import org.apache.flink.table.types.logical.LogicalTypeFamily
 import org.apache.flink.table.types.logical.utils.LogicalTypeCasts
 import org.apache.flink.types.RowKind
 
@@ -36,7 +39,6 @@ import com.google.common.collect.ImmutableSet
 import org.apache.calcite.plan.RelOptTable
 import org.apache.calcite.plan.hep.HepRelVertex
 import org.apache.calcite.plan.volcano.RelSubset
-import org.apache.calcite.rel.`type`.RelDataTypeFactory
 import org.apache.calcite.rel.{RelNode, SingleRel}
 import org.apache.calcite.rel.core._
 import org.apache.calcite.rel.metadata._
@@ -126,19 +128,15 @@ class FlinkRelMdUniqueKeys private extends 
MetadataHandler[BuiltInMetadata.Uniqu
       projects: JList[RexNode],
       input: RelNode,
       mq: RelMetadataQuery,
-      ignoreNulls: Boolean): JSet[ImmutableBitSet] = {
-    getProjectUniqueKeys(
-      projects,
-      input.getCluster.getTypeFactory,
-      () => mq.getUniqueKeys(input, ignoreNulls),
-      ignoreNulls)
-  }
+      ignoreNulls: Boolean): JSet[ImmutableBitSet] =
+    getProjectUniqueKeys(projects, input, () => mq.getUniqueKeys(input, 
ignoreNulls), ignoreNulls)
 
   def getProjectUniqueKeys(
       projects: JList[RexNode],
-      typeFactory: RelDataTypeFactory,
+      input: RelNode,
       getInputUniqueKeys: () => util.Set[ImmutableBitSet],
       ignoreNulls: Boolean): JSet[ImmutableBitSet] = {
+    val typeFactory = input.getCluster.getTypeFactory
     // LogicalProject maps a set of rows to a different set;
     // Without knowledge of the mapping function(whether it
     // preserves uniqueness), it is only safe to derive uniqueness
@@ -175,7 +173,8 @@ class FlinkRelMdUniqueKeys private extends 
MetadataHandler[BuiltInMetadata.Uniqu
             }
           // rename or key-preserving cast (fidelity or injective)
           case a: RexCall
-              if (a.getKind.equals(SqlKind.AS) || isKeyPreservingCast(a)) &&
+              if (a.getKind.equals(SqlKind.AS) ||
+                isKeyPreservingCast(a, legacyBytesToStringCast(input))) &&
                 a.getOperands.get(0).isInstanceOf[RexInputRef] =>
             
appendMapInToOutPos(a.getOperands.get(0).asInstanceOf[RexInputRef].getIndex, i)
           case _ => // ignore
@@ -219,16 +218,31 @@ class FlinkRelMdUniqueKeys private extends 
MetadataHandler[BuiltInMetadata.Uniqu
    *
    * An injective cast is one where each distinct input maps to a distinct 
output, ensuring that
    * unique keys remain unique after the cast.
+   *
+   * In legacy bytes-to-string mode, invalid UTF-8 is replaced by U+FFFD, so 
distinct byte arrays
+   * can map to the same string. A BINARY/VARBINARY -> CHAR/VARCHAR cast is 
therefore not injective
+   * under that mode.
    */
-  private def isKeyPreservingCast(call: RexCall): Boolean = {
+  private def isKeyPreservingCast(call: RexCall, legacyBytesToStringCast: 
Boolean): Boolean = {
     if (call.getKind != SqlKind.CAST) {
       return false
     }
     val originalType = 
FlinkTypeFactory.toLogicalType(call.getOperands.get(0).getType)
     val newType = FlinkTypeFactory.toLogicalType(call.getType)
+    if (
+      legacyBytesToStringCast && 
originalType.is(LogicalTypeFamily.BINARY_STRING) &&
+      newType.is(LogicalTypeFamily.CHARACTER_STRING)
+    ) {
+      return false
+    }
     LogicalTypeCasts.supportsInjectiveCast(originalType, newType)
   }
 
+  // Can be removed together with the legacyBytesToStringCast parameter once
+  // TABLE_EXEC_LEGACY_BYTES_TO_STRING_CAST is dropped.
+  private def legacyBytesToStringCast(input: RelNode): Boolean =
+    
unwrapTableConfig(input).get(ExecutionConfigOptions.TABLE_EXEC_LEGACY_BYTES_TO_STRING_CAST)
+
   def getUniqueKeys(
       rel: Expand,
       mq: RelMetadataQuery,
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeys.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeys.scala
index 7890b116b26..702359d3ee5 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeys.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeys.scala
@@ -74,9 +74,10 @@ class FlinkRelMdUpsertKeys private extends 
MetadataHandler[UpsertKeys] {
       mq: RelMetadataQuery): JSet[ImmutableBitSet] =
     FlinkRelMdUniqueKeys.INSTANCE.getProjectUniqueKeys(
       projects,
-      input.getCluster.getTypeFactory,
+      input,
       () => FlinkRelMetadataQuery.reuseOrCreate(mq).getUpsertKeys(input),
-      ignoreNulls = false)
+      ignoreNulls = false
+    )
 
   def getUpsertKeys(rel: Expand, mq: RelMetadataQuery): JSet[ImmutableBitSet] =
     FlinkRelMdUniqueKeys.INSTANCE.getExpandUniqueKeys(
diff --git 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeysTest.scala
 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeysTest.scala
index 4ae6f389e51..3fdb049dab8 100644
--- 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeysTest.scala
+++ 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/metadata/FlinkRelMdUpsertKeysTest.scala
@@ -17,6 +17,7 @@
  */
 package org.apache.flink.table.planner.plan.metadata
 
+import org.apache.flink.table.api.config.ExecutionConfigOptions
 import org.apache.flink.table.planner.plan.`trait`.FlinkRelDistribution
 import org.apache.flink.table.planner.plan.nodes.calcite.LogicalExpand
 import 
org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalOverAggregate
@@ -27,12 +28,13 @@ import 
org.apache.flink.table.runtime.operators.rank.{ConstantRankRange, RankTyp
 
 import com.google.common.collect.{ImmutableList, ImmutableSet}
 import org.apache.calcite.prepare.CalciteCatalogReader
-import org.apache.calcite.rel.`type`.RelDataTypeFieldImpl
+import org.apache.calcite.rel.`type`.{RelDataType, RelDataTypeFieldImpl}
 import org.apache.calcite.rel.{RelCollations, RelFieldCollation, RelNode}
 import org.apache.calcite.rel.core.{JoinRelType, Window}
 import org.apache.calcite.rel.hint.RelHint
+import org.apache.calcite.rel.logical.LogicalProject
 import org.apache.calcite.rex.{RexInputRef, RexNode, RexWindowBounds}
-import org.apache.calcite.sql.`type`.SqlTypeName.VARCHAR
+import org.apache.calcite.sql.`type`.SqlTypeName.{VARBINARY, VARCHAR}
 import org.apache.calcite.sql.SqlWindow
 import org.apache.calcite.sql.fun.SqlStdOperatorTable.{EQUALS, LESS_THAN, MAX}
 import org.apache.calcite.sql.parser.SqlParserPos
@@ -42,7 +44,7 @@ import org.junit.jupiter.api.Test
 
 import java.util.Collections
 
-import scala.collection.JavaConverters.setAsJavaSetConverter
+import scala.collection.JavaConverters.{seqAsJavaListConverter, 
setAsJavaSetConverter}
 
 class FlinkRelMdUpsertKeysTest extends FlinkRelMdHandlerTestBase {
 
@@ -174,6 +176,55 @@ class FlinkRelMdUpsertKeysTest extends 
FlinkRelMdHandlerTestBase {
     assertEquals(toBitSet(), mq.getUpsertKeys(narrowedProject))
   }
 
+  @Test
+  def testGetUpsertKeysOnProjectInjectiveBytesToStringCast(): Unit = {
+    // VARCHAR(100) -> VARBINARY(400) is injective: 400 bytes covers the 
worst-case UTF-8
+    // expansion (100 chars * 4 bytes), so the key survives.
+    val keyedByString = castField(studentLogicalScan, 0, 
typeFactory.createSqlType(VARCHAR, 100))
+    val keyedByBytes = castField(keyedByString, 0, 
typeFactory.createSqlType(VARBINARY, 400))
+    assertEquals(toBitSet(Array(0)), mq.getUpsertKeys(keyedByBytes))
+
+    // VARBINARY(400) -> VARCHAR(400) is injective, so the key survives.
+    val keyedByStringAgain = castField(keyedByBytes, 0, 
typeFactory.createSqlType(VARCHAR, 400))
+    assertEquals(toBitSet(Array(0)), mq.getUpsertKeys(keyedByStringAgain))
+  }
+
+  @Test
+  def testGetUpsertKeysOnProjectBytesToStringCastLosesKeyInLegacyMode(): Unit 
= {
+    // Legacy mode replaces invalid UTF-8 with U+FFFD, so distinct bytes can 
collide and the cast
+    // is no longer injective.
+    tableConfig.set(
+      ExecutionConfigOptions.TABLE_EXEC_LEGACY_BYTES_TO_STRING_CAST,
+      Boolean.box(true))
+
+    val keyedByString = castField(studentLogicalScan, 0, 
typeFactory.createSqlType(VARCHAR, 100))
+    val keyedByBytes = castField(keyedByString, 0, 
typeFactory.createSqlType(VARBINARY, 400))
+    val keyedByStringAgain = castField(keyedByBytes, 0, 
typeFactory.createSqlType(VARCHAR, 400))
+
+    assertEquals(toBitSet(), mq.getUpsertKeys(keyedByStringAgain))
+  }
+
+  /**
+   * Projects `input` with the field at `fieldIndex` cast to `targetType` and 
all other fields
+   * passed through unchanged, preserving the input field names.
+   */
+  private def castField(
+      input: RelNode,
+      fieldIndex: Int,
+      targetType: RelDataType): LogicalProject = {
+    val rowType = input.getRowType
+    val exprs: Seq[RexNode] = (0 until rowType.getFieldCount).map {
+      i =>
+        val ref = RexInputRef.of(i, rowType)
+        if (i == fieldIndex) rexBuilder.makeCast(targetType, ref) else ref
+    }
+    LogicalProject.create(
+      input,
+      Collections.emptyList[RelHint](),
+      exprs.asJava,
+      rowType.getFieldNames)
+  }
+
   @Test
   def testGetUpsertKeysOnFilter(): Unit = {
     assertEquals(toBitSet(Array(0)), mq.getUpsertKeys(logicalFilter))

Reply via email to