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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4ef55adc67 [core] Reject merge-schema type widening for primary-key 
and partition columns (#10077)
4ef55adc67 is described below

commit 4ef55adc67214cb01cab7530a7382ac8d7e7ccea
Author: Arvin <[email protected]>
AuthorDate: Tue Oct 13 13:45:07 2026 +0800

    [core] Reject merge-schema type widening for primary-key and partition 
columns (#10077)
---
 .../apache/paimon/schema/SchemaMergingUtils.java   | 50 +++++++++++++
 .../paimon/schema/SchemaMergingUtilsTest.java      | 76 +++++++++++++++++++
 .../org/apache/paimon/spark/PaimonSinkTest.scala   | 13 +++-
 .../org/apache/paimon/spark/PaimonSinkTest.scala   | 13 +++-
 .../paimon/spark/sql/DataFrameWriteTestBase.scala  | 87 ++++++++++++++--------
 5 files changed, 198 insertions(+), 41 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaMergingUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaMergingUtils.java
index bb9f42fc79..88c5eb2f5c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaMergingUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaMergingUtils.java
@@ -68,6 +68,17 @@ public class SchemaMergingUtils {
             return currentTableSchema;
         }
 
+        // The automatic merge path must not relax the invariants enforced by 
the explicit schema
+        // change path (see SchemaManagerUtils#assertNotUpdatingPartitionKeys /
+        // #assertNotUpdatingPrimaryKeys). Widening a key column changes its 
value encoding, which
+        // silently rehashes it into another bucket, so the same logical key 
would land in two
+        // different buckets and bypass deduplication. Reject such merges 
instead of committing
+        // them.
+        assertNotUpdatingKeyColumn(
+                currentTableSchema, currentType, newRowType, "update", 
"partition column");
+        assertNotUpdatingKeyColumn(
+                currentTableSchema, currentType, newRowType, "update", 
"primary key");
+
         return new TableSchema(
                 currentTableSchema.id() + 1,
                 newRowType.getFields(),
@@ -270,6 +281,45 @@ public class SchemaMergingUtils {
         return DataTypeCasts.supportsCast(sourceType, targetType, 
allowExplicitCast);
     }
 
+    /**
+     * Rejects a merge that changes the type of a primary-key or partition 
column, mirroring the
+     * explicit schema change path's guards. Only top-level columns are 
considered, as key columns
+     * are required to be non-nested.
+     */
+    private static void assertNotUpdatingKeyColumn(
+            TableSchema currentTableSchema,
+            RowType currentType,
+            RowType mergedType,
+            String operation,
+            String keyDescription) {
+        List<String> keyColumns =
+                "primary key".equals(keyDescription)
+                        ? currentTableSchema.primaryKeys()
+                        : currentTableSchema.partitionKeys();
+        if (keyColumns.isEmpty()) {
+            return;
+        }
+
+        Map<String, DataField> mergedFieldMap =
+                buildFieldMap(mergedType.getFields(), /* caseSensitive */ 
true);
+        for (DataField currentField : currentType.getFields()) {
+            if (!keyColumns.contains(currentField.name())) {
+                continue;
+            }
+            DataField mergedField = mergedFieldMap.get(currentField.name());
+            if (mergedField != null && 
!mergedField.type().equals(currentField.type())) {
+                throw new UnsupportedOperationException(
+                        String.format(
+                                "Cannot %s %s type from %s to %s: [%s].",
+                                operation,
+                                keyDescription,
+                                currentField.type(),
+                                mergedField.type(),
+                                currentField.name()));
+            }
+        }
+    }
+
     private static DataField assignIdForNewField(DataField field, 
AtomicInteger highestFieldId) {
         DataType dataType = ReassignFieldId.reassign(field.type(), 
highestFieldId);
         return new DataField(
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaMergingUtilsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaMergingUtilsTest.java
index b5befc4158..8e3acb4047 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaMergingUtilsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaMergingUtilsTest.java
@@ -144,6 +144,82 @@ public class SchemaMergingUtilsTest {
         assertThat(fields.get(2).type()).isEqualTo(new IntType());
     }
 
+    @Test
+    public void testRejectTypeWideningOnPrimaryKeyColumn() {
+        // An INT primary key must not be widened to BIGINT by the automatic 
merge path: INT
+        // values hash into different buckets than their BIGINT counterparts, 
so the same logical
+        // key would end up in two buckets and bypass intra-bucket 
deduplication.
+        DataField a = new DataField(0, "a", new IntType());
+        DataField b = new DataField(1, "b", new 
VarCharType(VarCharType.MAX_LENGTH));
+        TableSchema current =
+                new TableSchema(
+                        0,
+                        Lists.newArrayList(a, b),
+                        1,
+                        new ArrayList<>(),
+                        Lists.newArrayList("a"),
+                        new HashMap<>(),
+                        "");
+
+        DataField aWidened = new DataField(-1, "a", new BigIntType());
+        RowType t = new RowType(Lists.newArrayList(aWidened, b));
+
+        assertThatThrownBy(() -> SchemaMergingUtils.mergeSchemas(current, t, 
true, false, true))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("Cannot update primary key type");
+    }
+
+    @Test
+    public void testRejectTypeWideningOnPartitionColumn() {
+        // Partition columns are equally encoded into the partition path; 
widening them would
+        // silently create a second partition layout for the same logical key. 
This uses a plain
+        // INT -> BIGINT widening with allowExplicitCast = false, so the 
rejection is exercised by
+        // the normal type-widening path and is independent of explicit-cast 
behavior.
+        DataField a = new DataField(0, "a", new IntType());
+        DataField b = new DataField(1, "b", new IntType());
+        TableSchema current =
+                new TableSchema(
+                        0,
+                        Lists.newArrayList(a, b),
+                        1,
+                        Lists.newArrayList("b"),
+                        new ArrayList<>(),
+                        new HashMap<>(),
+                        "");
+
+        DataField bWidened = new DataField(-1, "b", new BigIntType());
+        RowType t = new RowType(Lists.newArrayList(a, bWidened));
+
+        assertThatThrownBy(() -> SchemaMergingUtils.mergeSchemas(current, t, 
true, false, true))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("Cannot update partition column type");
+    }
+
+    @Test
+    public void testAllowTypeWideningOnNonKeyColumn() {
+        // Widening a non-key column is unaffected by the key-column guard.
+        DataField a = new DataField(0, "a", new IntType());
+        DataField b = new DataField(1, "b", new IntType());
+        TableSchema current =
+                new TableSchema(
+                        0,
+                        Lists.newArrayList(a, b),
+                        1,
+                        new ArrayList<>(),
+                        Lists.newArrayList("a"),
+                        new HashMap<>(),
+                        "");
+
+        DataField bWidened = new DataField(-1, "b", new BigIntType());
+        RowType t = new RowType(Lists.newArrayList(a, bWidened));
+
+        TableSchema merged = SchemaMergingUtils.mergeSchemas(current, t, true, 
false, true);
+        assertThat(merged.id()).isEqualTo(1);
+        assertThat(merged.primaryKeys()).containsExactly("a");
+        assertThat(merged.fields().get(0).type()).isEqualTo(new IntType());
+        assertThat(merged.fields().get(1).type()).isEqualTo(new BigIntType());
+    }
+
     @Test
     public void testMergeSchemas() {
         // This will test both `mergeSchemas` and `merge` methods.
diff --git 
a/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
 
b/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
index d213f257c7..045529d716 100644
--- 
a/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
+++ 
b/paimon-spark/paimon-spark-3.4/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
@@ -253,7 +253,12 @@ class PaimonSinkTest extends PaimonSparkTestBase with 
StreamTest {
             spark.sql("SELECT * FROM T ORDER BY a, b"),
             Row(1, "2023-08-09") :: Row(2, "2023-08-09") :: Nil)
 
-          val inputData = MemoryStream[(Long, Date, Int)]
+          // The primary key column `a` deliberately keeps its INT type: 
widening a
+          // primary-key column changes its bucket hash and is rejected by the 
automatic
+          // merge path (see 
SchemaMergingUtilsTest#testRejectTypeWideningOnPrimaryKeyColumn).
+          // This test focuses on the schema evolution that is allowed — 
widening a non-key
+          // column (`b`: STRING -> DATE) and adding a new column (`c`).
+          val inputData = MemoryStream[(Int, Date, Int)]
           val stream = inputData
             .toDS()
             .toDF("a", "b", "c")
@@ -268,13 +273,13 @@ class PaimonSinkTest extends PaimonSparkTestBase with 
StreamTest {
           val query = () => spark.sql("SELECT * FROM T ORDER BY a")
 
           try {
-            inputData.addData((1L, date, 123), (3L, date, 456))
+            inputData.addData((1, date, 123), (3, date, 456))
             stream.processAllAvailable()
 
             checkAnswer(
               query(),
-              Row(1L, date, 123) :: Row(2L, Date.valueOf("2023-08-09"), null) 
:: Row(
-                3L,
+              Row(1, date, 123) :: Row(2, Date.valueOf("2023-08-09"), null) :: 
Row(
+                3,
                 date,
                 456) :: Nil)
           } finally {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
index f22dab4b9c..206c2c0bcf 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSinkTest.scala
@@ -256,7 +256,12 @@ class PaimonSinkTest extends PaimonSparkTestBase with 
StreamTest {
             spark.sql("SELECT * FROM T ORDER BY a, b"),
             Row(1, "2023-08-09") :: Row(2, "2023-08-09") :: Nil)
 
-          val inputData = MemoryStream[(Long, Date, Int)]
+          // The primary key column `a` deliberately keeps its INT type: 
widening a
+          // primary-key column changes its bucket hash and is rejected by the 
automatic
+          // merge path (see 
SchemaMergingUtilsTest#testRejectTypeWideningOnPrimaryKeyColumn).
+          // This test focuses on the schema evolution that is allowed — 
widening a non-key
+          // column (`b`: STRING -> DATE) and adding a new column (`c`).
+          val inputData = MemoryStream[(Int, Date, Int)]
           val stream = inputData
             .toDS()
             .toDF("a", "b", "c")
@@ -271,13 +276,13 @@ class PaimonSinkTest extends PaimonSparkTestBase with 
StreamTest {
           val query = () => spark.sql("SELECT * FROM T ORDER BY a")
 
           try {
-            inputData.addData((1L, date, 123), (3L, date, 456))
+            inputData.addData((1, date, 123), (3, date, 456))
             stream.processAllAvailable()
 
             checkAnswer(
               query(),
-              Row(1L, date, 123) :: Row(2L, Date.valueOf("2023-08-09"), null) 
:: Row(
-                3L,
+              Row(1, date, 123) :: Row(2, Date.valueOf("2023-08-09"), null) :: 
Row(
+                3,
                 date,
                 456) :: Nil)
           } finally {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
index e4b91730ed..504ecfce7c 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DataFrameWriteTestBase.scala
@@ -723,10 +723,14 @@ abstract class DataFrameWriteTestBase extends 
PaimonSparkTestBase {
                 }
                 checkAnswer(spark.sql("SELECT * FROM T ORDER BY a, b"), 
expected2)
 
-                // Case 2: two fields with the evolved types: Int -> Long, 
Long -> Decimal
+                // Case 2: a non-key column evolves its type (Long -> 
Decimal). The primary key
+                // `a` deliberately keeps its INT type: widening a primary-key 
or partition column
+                // changes its bucket hash and is rejected by the merge path 
(see
+                // 
SchemaMergingUtilsTest#testRejectTypeWideningOnPrimaryKeyColumn). Key widening 
is
+                // exercised as a rejection case in its own test below.
                 val df3 = Seq(
-                  (2L, "b2", BigDecimal.decimal(234), Map("k" -> 22.2)),
-                  (4L, "d", BigDecimal.decimal(456), Map("k" -> 
44.4))).toDF("a", "b", "c", "d")
+                  (2, "b2", BigDecimal.decimal(234), Map("k" -> 22.2)),
+                  (4, "d", BigDecimal.decimal(456), Map("k" -> 
44.4))).toDF("a", "b", "c", "d")
                 df3.write
                   .format("paimon")
                   .mode("append")
@@ -734,42 +738,62 @@ abstract class DataFrameWriteTestBase extends 
PaimonSparkTestBase {
                   .option("write.merge-schema.type-widening", "true")
                   .save(location)
                 val expected3 = if (hasPk) {
-                  Row(1L, "a2", BigDecimal.decimal(123), Map("k" -> 11.1)) :: 
Row(
-                    2L,
+                  Row(1, "a2", BigDecimal.decimal(123), Map("k" -> 11.1)) :: 
Row(
+                    2,
                     "b2",
                     BigDecimal.decimal(234),
                     Map("k" -> 22.2)) :: Row(
-                    3L,
+                    3,
                     "c",
                     BigDecimal.decimal(345),
                     Map("k" -> 33.3)) :: Row(
-                    4L,
+                    4,
                     "d",
                     BigDecimal.decimal(456),
                     Map("k" -> 44.4)) :: Nil
                 } else {
-                  Row(1L, "a", null, null) :: Row(
-                    1L,
+                  Row(1, "a", null, null) :: Row(
+                    1,
                     "a2",
                     BigDecimal.decimal(123),
-                    Map("k" -> 11.1)) :: Row(2L, "b", null, null) :: Row(
-                    2L,
+                    Map("k" -> 11.1)) :: Row(2, "b", null, null) :: Row(
+                    2,
                     "b2",
                     BigDecimal.decimal(234),
                     Map("k" -> 22.2)) :: Row(
-                    3L,
+                    3,
                     "c",
                     BigDecimal.decimal(345),
                     Map("k" -> 33.3)) :: Row(
-                    4L,
+                    4,
                     "d",
                     BigDecimal.decimal(456),
                     Map("k" -> 44.4)) :: Nil
                 }
                 checkAnswer(spark.sql("SELECT * FROM T ORDER BY a, b"), 
expected3)
 
-                // Case 3: insert Decimal(20,18) to Decimal(38,18)
-                val df4 = Seq((99L, "df4", BigDecimal.decimal(4.0), Map("4" -> 
4.1)))
+                // Case 2b: widening a primary-key column must be rejected and 
leave the schema
+                // and the previously committed rows unchanged.
+                if (hasPk) {
+                  val schemaIdBefore = loadTable("T").schema().id()
+                  val dfKeyWiden = Seq((2L, "b2"), (4L, "d")).toDF("a", "b")
+                  val widMsg = intercept[Exception] {
+                    dfKeyWiden.write
+                      .format("paimon")
+                      .mode("append")
+                      .option("write.merge-schema", "true")
+                      .option("write.merge-schema.type-widening", "true")
+                      .save(location)
+                  }.getMessage
+                  assert(widMsg.contains("Cannot update primary key type"))
+                  // The rejected merge must not have persisted a schema 
change or new rows.
+                  assert(loadTable("T").schema().id() == schemaIdBefore)
+                  checkAnswer(spark.sql("SELECT * FROM T ORDER BY a, b"), 
expected3)
+                }
+
+                // Case 3: insert Decimal(20,18) to Decimal(38,18). The 
primary key `a` stays INT so
+                // the key-type guard does not fire; only the non-key column 
`c` is widened.
+                val df4 = Seq((99, "df4", BigDecimal.decimal(4.0), Map("4" -> 
4.1)))
                   .toDF("a", "b", "c", "d")
                   .selectExpr("a", "b", "cast(c as decimal(20,18)) as c", "d")
                 df4.write
@@ -779,7 +803,7 @@ abstract class DataFrameWriteTestBase extends 
PaimonSparkTestBase {
                   .option("write.merge-schema.type-widening", "true")
                   .save(location)
                 val expected4 =
-                  expected3 ++ Seq(Row(99L, "df4", BigDecimal.decimal(4.0), 
Map("4" -> 4.1)))
+                  expected3 ++ Seq(Row(99, "df4", BigDecimal.decimal(4.0), 
Map("4" -> 4.1)))
                 checkAnswer(spark.sql("SELECT * FROM T ORDER BY a, b"), 
expected4)
                 val decimalType =
                   
spark.table("T").schema.apply(2).dataType.asInstanceOf[DecimalType]
@@ -840,9 +864,10 @@ abstract class DataFrameWriteTestBase extends 
PaimonSparkTestBase {
             }
             checkAnswer(spark.sql("SELECT * FROM T ORDER BY a, b"), expected2)
 
-            // Case 2: a: Int -> Long, b: String -> Date, c: Long -> Int, d: 
Map -> String
+            // Case 2: a is kept INT (widening a primary-key column is 
rejected), b: String -> Date,
+            // c: Long -> Int, d: Map -> String are the evolved non-key 
columns.
             val date = java.sql.Date.valueOf("2023-07-31")
-            val df3 = Seq((2L, date, 234, null), (4L, date, 456, "2023-08-01 
11:00:00.0")).toDF(
+            val df3 = Seq((2, date, 234, null), (4, date, 456, "2023-08-01 
11:00:00.0")).toDF(
               "a",
               "b",
               "c",
@@ -866,26 +891,22 @@ abstract class DataFrameWriteTestBase extends 
PaimonSparkTestBase {
               .option("write.merge-schema.explicit-cast", "true")
               .save(location)
             val expected3 = if (hasPk) {
-              Row(1L, Date.valueOf("2023-08-01"), 12, ts.toString) :: Row(
-                2L,
-                date,
-                234,
-                null) :: Row(3L, Date.valueOf("2023-08-03"), 34, ts.toString) 
:: Row(
-                4L,
-                date,
-                456,
-                "2023-08-01 11:00:00.0") :: Nil
+              Row(1, Date.valueOf("2023-08-01"), 12, ts.toString) :: Row(2, 
date, 234, null) :: Row(
+                3,
+                Date.valueOf("2023-08-03"),
+                34,
+                ts.toString) :: Row(4, date, 456, "2023-08-01 11:00:00.0") :: 
Nil
             } else {
-              Row(1L, Date.valueOf("2023-08-01"), null, null) :: Row(
-                1L,
+              Row(1, Date.valueOf("2023-08-01"), null, null) :: Row(
+                1,
                 Date.valueOf("2023-08-01"),
                 12,
-                ts.toString) :: Row(2L, date, 234, null) :: Row(
-                2L,
+                ts.toString) :: Row(2, date, 234, null) :: Row(
+                2,
                 Date.valueOf("2023-08-02"),
                 null,
-                null) :: Row(3L, Date.valueOf("2023-08-03"), 34, ts.toString) 
:: Row(
-                4L,
+                null) :: Row(3, Date.valueOf("2023-08-03"), 34, ts.toString) 
:: Row(
+                4,
                 date,
                 456,
                 "2023-08-01 11:00:00.0") :: Nil

Reply via email to