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

szehon-ho pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 0bfdb323c3 Spark 4.2: Implement SupportsSchemaEvolution for automatic 
schema evolution (#17957)
0bfdb323c3 is described below

commit 0bfdb323c3db7828be93a33bad52c42db4d011ce
Author: Rahul Shivu Mahadev <[email protected]>
AuthorDate: Wed Oct 14 15:31:22 2026 -0700

    Spark 4.2: Implement SupportsSchemaEvolution for automatic schema evolution 
(#17957)
---
 .../spark/extensions/TestMergeSchemaEvolution.java |  30 +++
 .../iceberg/spark/source/SparkSchemaEvolution.java | 170 ++++++++++++++
 .../apache/iceberg/spark/source/SparkTable.java    |  15 +-
 .../iceberg/spark/source/TestSparkTable.java       | 243 +++++++++++++++++++++
 .../spark/sql/TestInsertSchemaEvolution.java       | 178 +++++++++++++++
 5 files changed, 635 insertions(+), 1 deletion(-)

diff --git 
a/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java
 
b/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java
index fa8314ecae..ad438aa78b 100644
--- 
a/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java
+++ 
b/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java
@@ -27,6 +27,7 @@ import org.apache.iceberg.ParameterizedTestExtension;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.apache.iceberg.spark.SparkTableProperties;
+import org.apache.spark.sql.types.DataTypes;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.TestTemplate;
@@ -262,6 +263,35 @@ public class TestMergeSchemaEvolution extends 
SparkRowLevelOperationsTestBase {
         sql("SELECT id, value FROM %s ORDER BY id", selectTarget()));
   }
 
+  @TestTemplate
+  void mergeWithSchemaEvolutionCastsSmallIntToInt() {
+    assumeThat(branch).as("Schema evolution does not work for branches 
currently").isNull();
+
+    createAndInitTable(
+        "id INT, value INT", "{ \"id\": 1, \"value\": 100 }\n" + "{ \"id\": 2, 
\"value\": 200 }");
+
+    createOrReplaceView(
+        "source",
+        "id INT, value SMALLINT",
+        "{ \"id\": 1, \"value\": 10 }\n" + "{ \"id\": 3, \"value\": 30 }");
+
+    sql(
+        "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s "
+            + "ON t.id == s.id "
+            + "WHEN MATCHED THEN "
+            + "  UPDATE SET * "
+            + "WHEN NOT MATCHED THEN "
+            + "  INSERT *",
+        commitTarget());
+
+    assertThat(spark.table(tableName).schema().apply("value").dataType())
+        .isEqualTo(DataTypes.IntegerType);
+    assertEquals(
+        "Should cast SMALLINT source values to INT",
+        ImmutableList.of(row(1, 10), row(2, 200), row(3, 30)),
+        sql("SELECT id, value FROM %s ORDER BY id", selectTarget()));
+  }
+
   @TestTemplate
   public void testMergeWithSchemaEvolutionDisabledByTableProperty() {
     assumeThat(branch).as("Schema evolution does not work for branches 
currently").isNull();
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkSchemaEvolution.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkSchemaEvolution.java
new file mode 100644
index 0000000000..4cad991668
--- /dev/null
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkSchemaEvolution.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.spark.source;
+
+import java.util.Arrays;
+import java.util.Set;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
+import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.spark.SparkSchemaUtil;
+import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.apache.spark.sql.connector.catalog.TableChange;
+import org.apache.spark.sql.types.DataType;
+
+/**
+ * Decides which column changes Spark may apply to a table through automatic 
schema evolution.
+ *
+ * <p>The changes are checked against the schema pinned when the table was 
loaded.
+ */
+class SparkSchemaEvolution {
+  private final Table table;
+  private final Schema schema;
+  private volatile Set<Integer> mapKeyFieldIds;
+
+  SparkSchemaEvolution(Table table, Schema schema) {
+    this.table = table;
+    this.schema = schema;
+  }
+
+  boolean supportsColumnChange(TableChange.ColumnChange change) {
+    if (isMapKeyChange(change)) {
+      return false;
+    }
+
+    if (change instanceof TableChange.AddColumn) {
+      TableChange.AddColumn add = (TableChange.AddColumn) change;
+      if (!add.isNullable() || add.defaultValue() != null) {
+        return false;
+      }
+
+      Type type = tryConvert(add.dataType());
+      return type != null && isSupportedAtFormatVersion(type);
+    } else if (change instanceof TableChange.UpdateColumnType) {
+      return supportsTypeUpdate((TableChange.UpdateColumnType) change);
+    } else if (change instanceof TableChange.UpdateColumnNullability) {
+      return supportsNullabilityUpdate((TableChange.UpdateColumnNullability) 
change);
+    } else if (change instanceof TableChange.DeleteColumn) {
+      return supportsDeleteColumn((TableChange.DeleteColumn) change);
+    } else {
+      return change instanceof TableChange.RenameColumn
+          || change instanceof TableChange.UpdateColumnComment
+          || change instanceof TableChange.UpdateColumnPosition;
+    }
+  }
+
+  private Set<Integer> mapKeyFieldIds() {
+    if (mapKeyFieldIds == null) {
+      this.mapKeyFieldIds = mapKeyFieldIds(schema);
+    }
+
+    return mapKeyFieldIds;
+  }
+
+  private static Set<Integer> mapKeyFieldIds(Schema schema) {
+    Set<Integer> keyFieldIds = Sets.newHashSet();
+    for (Types.NestedField field : 
TypeUtil.indexById(schema.asStruct()).values()) {
+      if (field.type().isMapType()) {
+        Types.MapType map = field.type().asMapType();
+        
keyFieldIds.addAll(TypeUtil.indexById(Types.StructType.of(map.fields().get(0))).keySet());
+      }
+    }
+
+    return keyFieldIds;
+  }
+
+  private boolean isMapKeyChange(TableChange.ColumnChange change) {
+    String[] fieldNames = change.fieldNames();
+    int pathLength =
+        change instanceof TableChange.AddColumn ? fieldNames.length - 1 : 
fieldNames.length;
+    if (pathLength == 0) {
+      return false;
+    }
+
+    Types.NestedField field =
+        schema.findField(String.join(".", Arrays.copyOf(fieldNames, 
pathLength)));
+    return field != null && mapKeyFieldIds().contains(field.fieldId());
+  }
+
+  private boolean supportsTypeUpdate(TableChange.UpdateColumnType update) {
+    Types.NestedField field = schema.findField(String.join(".", 
update.fieldNames()));
+    if (field == null) {
+      return false;
+    }
+
+    Type newType = tryConvert(update.newDataType());
+    return newType != null
+        && newType.isPrimitiveType()
+        && TypeUtil.isPromotionAllowed(field.type(), newType.asPrimitiveType())
+        && SparkSchemaUtil.convert(newType).equals(update.newDataType());
+  }
+
+  private boolean 
supportsNullabilityUpdate(TableChange.UpdateColumnNullability update) {
+    Types.NestedField field = schema.findField(String.join(".", 
update.fieldNames()));
+    return update.nullable() && field != null && 
!containsIdentifierField(field);
+  }
+
+  private boolean supportsDeleteColumn(TableChange.DeleteColumn delete) {
+    Types.NestedField field = schema.findField(String.join(".", 
delete.fieldNames()));
+    if (field == null || isCollectionElement(delete.fieldNames())) {
+      return false;
+    }
+
+    // Iceberg rejects dropping an identifier field or a field whose subtree 
contains one
+    return !containsIdentifierField(field);
+  }
+
+  // a list element or a map key or value can only be dropped together with 
its collection
+  private boolean isCollectionElement(String[] fieldNames) {
+    if (fieldNames.length < 2) {
+      return false;
+    }
+
+    Types.NestedField parent =
+        schema.findField(String.join(".", Arrays.copyOf(fieldNames, 
fieldNames.length - 1)));
+    return parent != null && (parent.type().isListType() || 
parent.type().isMapType());
+  }
+
+  private boolean containsIdentifierField(Types.NestedField field) {
+    Set<Integer> identifierFieldIds = schema.identifierFieldIds();
+    return TypeUtil.indexById(Types.StructType.of(field)).keySet().stream()
+        .anyMatch(identifierFieldIds::contains);
+  }
+
+  private boolean isSupportedAtFormatVersion(Type type) {
+    try {
+      Schema typeSchema = new Schema(Types.NestedField.optional(-1, "value", 
type));
+      Schema.checkCompatibility(typeSchema, TableUtil.formatVersion(table));
+      return true;
+    } catch (IllegalArgumentException | IllegalStateException e) {
+      return false;
+    }
+  }
+
+  private static Type tryConvert(DataType sparkType) {
+    try {
+      return SparkSchemaUtil.convert(sparkType);
+    } catch (IllegalArgumentException | UnsupportedOperationException e) {
+      return null;
+    }
+  }
+}
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
index 1e767f2e8c..2162d825e0 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java
@@ -60,8 +60,10 @@ import org.apache.iceberg.util.SnapshotUtil;
 import org.apache.spark.sql.connector.catalog.SupportsDeleteV2;
 import org.apache.spark.sql.connector.catalog.SupportsRead;
 import org.apache.spark.sql.connector.catalog.SupportsRowLevelOperations;
+import org.apache.spark.sql.connector.catalog.SupportsSchemaEvolution;
 import org.apache.spark.sql.connector.catalog.SupportsWrite;
 import org.apache.spark.sql.connector.catalog.TableCapability;
+import org.apache.spark.sql.connector.catalog.TableChange;
 import org.apache.spark.sql.connector.catalog.constraints.Constraint;
 import 
org.apache.spark.sql.connector.catalog.constraints.Constraint.ValidationStatus;
 import org.apache.spark.sql.connector.expressions.filter.Predicate;
@@ -80,7 +82,11 @@ import org.slf4j.LoggerFactory;
  * <p>Note the table state (e.g. schema, snapshot) is pinned upon loading and 
must not change.
  */
 public class SparkTable extends BaseSparkTable
-    implements SupportsRead, SupportsWrite, SupportsDeleteV2, 
SupportsRowLevelOperations {
+    implements SupportsRead,
+        SupportsWrite,
+        SupportsDeleteV2,
+        SupportsRowLevelOperations,
+        SupportsSchemaEvolution {
 
   private static final Logger LOG = LoggerFactory.getLogger(SparkTable.class);
 
@@ -94,6 +100,7 @@ public class SparkTable extends BaseSparkTable
           TableCapability.OVERWRITE_DYNAMIC);
 
   private final Schema schema; // effective schema (not necessarily current 
table schema)
+  private final SparkSchemaEvolution schemaEvolution;
   private final Snapshot snapshot; // always set unless table is empty
   private final String branch; // set if table is loaded for specific branch
   private final TimeTravel timeTravel; // set if table is loaded for time 
travel
@@ -125,6 +132,7 @@ public class SparkTable extends BaseSparkTable
       Table table, Schema schema, Snapshot snapshot, String branch, TimeTravel 
timeTravel) {
     super(table, schema);
     this.schema = schema;
+    this.schemaEvolution = new SparkSchemaEvolution(table, schema);
     this.snapshot = snapshot;
     this.branch = branch;
     this.timeTravel = timeTravel;
@@ -163,6 +171,11 @@ public class SparkTable extends BaseSparkTable
     return capabilities;
   }
 
+  @Override
+  public boolean supportsColumnChange(TableChange.ColumnChange change) {
+    return schemaEvolution.supportsColumnChange(change);
+  }
+
   @Override
   public Constraint[] constraints() {
     List<Constraint> constraints = Lists.newArrayList();
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java
index 106d908b49..4b1d2e5f65 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java
@@ -29,13 +29,21 @@ import org.apache.iceberg.TableProperties;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
 import org.apache.iceberg.spark.CatalogTestBase;
 import org.apache.iceberg.spark.SparkSQLProperties;
+import org.apache.iceberg.spark.SparkTableProperties;
+import org.apache.iceberg.types.Types;
 import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
 import org.apache.spark.sql.connector.catalog.CatalogManager;
 import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCapability;
 import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.connector.catalog.TableChange;
 import org.apache.spark.sql.connector.catalog.constraints.Constraint;
 import org.apache.spark.sql.connector.catalog.constraints.PrimaryKey;
 import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructType;
+import org.apache.spark.sql.types.VariantType$;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.TestTemplate;
@@ -56,6 +64,241 @@ public class TestSparkTable extends CatalogTestBase {
     sql("DROP TABLE IF EXISTS %s", tableName);
   }
 
+  @TestTemplate
+  public void testSupportedSchemaEvolutionChanges() {
+    SparkTable table = loadSparkTable();
+
+    
assertThat(table.capabilities()).contains(TableCapability.AUTOMATIC_SCHEMA_EVOLUTION);
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.addColumn(new String[] {"new_col"}, 
DataTypes.IntegerType, true)))
+        .isTrue();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.addColumn(new String[] {"new_col"}, 
DataTypes.IntegerType, false)))
+        .isFalse();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"id"}, 
DataTypes.LongType)))
+        .isTrue();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"id"}, 
DataTypes.IntegerType)))
+        .isFalse();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnNullability(new String[] {"data"}, 
true)))
+        .isTrue();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnNullability(new String[] {"data"}, 
false)))
+        .isFalse();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnDefaultValue(new String[] 
{"data"}, "'default'")))
+        .isFalse();
+  }
+
+  @TestTemplate
+  void addColumnsRespectFormatVersion() {
+    List<DataType> types =
+        Arrays.asList(
+            VariantType$.MODULE$,
+            DataTypes.NullType,
+            new StructType().add("v", VariantType$.MODULE$),
+            DataTypes.createMapType(DataTypes.StringType, 
VariantType$.MODULE$),
+            DataTypes.createArrayType(VariantType$.MODULE$));
+
+    for (int formatVersion : new int[] {2, 3}) {
+      sql(
+          "ALTER TABLE %s SET TBLPROPERTIES ('%s' = '%s')",
+          tableName, TableProperties.FORMAT_VERSION, formatVersion);
+      SparkTable table = loadSparkTable();
+
+      for (DataType type : types) {
+        assertThat(
+                table.supportsColumnChange(
+                    (TableChange.ColumnChange)
+                        TableChange.addColumn(new String[] {"new_col"}, type, 
true)))
+            .as("Adding %s to a v%s table", type, formatVersion)
+            .isEqualTo(formatVersion >= 3);
+      }
+    }
+  }
+
+  @TestTemplate
+  void variantTypeUpdatesAreNotSupported() {
+    for (int formatVersion : new int[] {2, 3}) {
+      sql(
+          "ALTER TABLE %s SET TBLPROPERTIES ('%s' = '%s')",
+          tableName, TableProperties.FORMAT_VERSION, formatVersion);
+      SparkTable table = loadSparkTable();
+
+      assertThat(
+              table.supportsColumnChange(
+                  (TableChange.ColumnChange)
+                      TableChange.updateColumnType(new String[] {"data"}, 
VariantType$.MODULE$)))
+          .as("Updating a column to variant in a v%s table", formatVersion)
+          .isFalse();
+    }
+  }
+
+  @TestTemplate
+  void identifierColumnCannotBecomeNullable() {
+    SparkTable table = loadSparkTable();
+    
table.table().updateSchema().allowIncompatibleChanges().setIdentifierFields("id").commit();
+    table = loadSparkTable();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnNullability(new String[] {"id"}, 
true)))
+        .isFalse();
+  }
+
+  @TestTemplate
+  void identifierStructCannotBecomeNullable() {
+    SparkTable table = loadSparkTable();
+    table
+        .table()
+        .updateSchema()
+        .allowIncompatibleChanges()
+        .addRequiredColumn(
+            "parent",
+            Types.StructType.of(Types.NestedField.required(1, "id", 
Types.LongType.get())))
+        .setIdentifierFields("parent.id")
+        .commit();
+    table = loadSparkTable();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnNullability(new String[] 
{"parent"}, true)))
+        .isFalse();
+  }
+
+  @TestTemplate
+  void missingColumnCannotBecomeNullable() {
+    SparkTable table = loadSparkTable();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnNullability(new String[] 
{"missing"}, true)))
+        .isFalse();
+  }
+
+  @TestTemplate
+  void collectionElementsCannotBeDeleted() {
+    sql("ALTER TABLE %s ADD COLUMN items array<struct<x: int, y: int>>", 
tableName);
+    sql("ALTER TABLE %s ADD COLUMN m map<int, string>", tableName);
+    SparkTable table = loadSparkTable();
+
+    for (String[] path : new String[][] {{"items", "element"}, {"m", "key"}, 
{"m", "value"}}) {
+      assertThat(
+              table.supportsColumnChange(
+                  (TableChange.ColumnChange) TableChange.deleteColumn(path, 
false)))
+          .as("Deleting %s", String.join(".", path))
+          .isFalse();
+    }
+
+    // a field of a list element struct can still be deleted
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.deleteColumn(new String[] {"items", "element", 
"x"}, false)))
+        .isTrue();
+  }
+
+  @TestTemplate
+  public void testMapKeySchemaEvolutionChanges() {
+    sql("ALTER TABLE %s ADD COLUMN m map<int, string>", tableName);
+    sql("ALTER TABLE %s ADD COLUMN value_map map<int, int>", tableName);
+    sql("ALTER TABLE %s ADD COLUMN struct_key_map map<struct<key_field: int>, 
string>", tableName);
+    sql("ALTER TABLE %s ADD COLUMN struct_value_map map<int, 
struct<value_field: int>>", tableName);
+    SparkTable table = loadSparkTable();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"m", "key"}, 
DataTypes.LongType)))
+        .isFalse();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(
+                        new String[] {"struct_key_map", "key", "key_field"}, 
DataTypes.LongType)))
+        .isFalse();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.addColumn(
+                        new String[] {"struct_key_map", "key", "new_field"},
+                        DataTypes.IntegerType,
+                        true)))
+        .isFalse();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(
+                        new String[] {"value_map", "value"}, 
DataTypes.LongType)))
+        .isTrue();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.addColumn(
+                        new String[] {"struct_value_map", "value", 
"new_field"},
+                        DataTypes.IntegerType,
+                        true)))
+        .isTrue();
+  }
+
+  @TestTemplate
+  void typeUpdatesMustRoundTripToSparkType() {
+    sql("ALTER TABLE %s ADD COLUMN int_col int", tableName);
+    SparkTable table = loadSparkTable();
+
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"int_col"}, 
DataTypes.ShortType)))
+        .isFalse();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"int_col"}, 
DataTypes.ByteType)))
+        .isFalse();
+    assertThat(
+            table.supportsColumnChange(
+                (TableChange.ColumnChange)
+                    TableChange.updateColumnType(new String[] {"int_col"}, 
DataTypes.LongType)))
+        .isTrue();
+  }
+
+  @TestTemplate
+  void schemaEvolutionWithAcceptAnySchema() {
+    sql(
+        "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')",
+        tableName, SparkTableProperties.WRITE_ACCEPT_ANY_SCHEMA);
+    SparkTable table = loadSparkTable();
+    TableChange.ColumnChange change =
+        (TableChange.ColumnChange)
+            TableChange.addColumn(new String[] {"new_col"}, 
DataTypes.IntegerType, true);
+
+    assertThat(table.supportsColumnChange(change)).isTrue();
+    assertThat(table.supportsColumnChange(change)).isTrue();
+  }
+
   @TestTemplate
   public void testTableEquality() {
     SparkTable table1 = loadSparkTable();
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestInsertSchemaEvolution.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestInsertSchemaEvolution.java
new file mode 100644
index 0000000000..c190f4e087
--- /dev/null
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/sql/TestInsertSchemaEvolution.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.spark.sql;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.iceberg.Parameter;
+import org.apache.iceberg.ParameterizedTestExtension;
+import org.apache.iceberg.Parameters;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.spark.CatalogTestBase;
+import org.apache.iceberg.spark.SparkSQLProperties;
+import org.apache.iceberg.spark.SparkTableProperties;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(ParameterizedTestExtension.class)
+class TestInsertSchemaEvolution extends CatalogTestBase {
+
+  @Parameter(index = 3)
+  private boolean byName;
+
+  @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, 
byName = {3}")
+  protected static Object[][] parameters() {
+    List<Object[]> parameters = Lists.newArrayList();
+    for (Object[] catalogParams : CatalogTestBase.parameters()) {
+      for (boolean byName : new boolean[] {false, true}) {
+        parameters.add(new Object[] {catalogParams[0], catalogParams[1], 
catalogParams[2], byName});
+      }
+    }
+
+    return parameters.toArray(new Object[0][]);
+  }
+
+  @AfterEach
+  void removeTable() {
+    sql("DROP TABLE IF EXISTS %s", tableName);
+  }
+
+  @TestTemplate
+  void addColumnWhileCastingSmallintToInt() {
+    sql(
+        "CREATE TABLE %s (id INT, data STRING) USING iceberg TBLPROPERTIES 
('%s' = 'false')",
+        tableName, SparkTableProperties.WRITE_ACCEPT_ANY_SCHEMA);
+    sql("INSERT INTO %s VALUES (1, 'old')", tableName);
+
+    sql(
+        "INSERT WITH SCHEMA EVOLUTION INTO %s %s SELECT %s "
+            + "FROM VALUES (CAST(2 AS SMALLINT), 'new', 'added') AS source(id, 
data, extra)",
+        tableName, byName ? "BY NAME" : "", byName ? "extra, data, id" : "id, 
data, extra");
+
+    Schema expectedSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+            Types.NestedField.optional(2, "data", Types.StringType.get()),
+            Types.NestedField.optional(3, "extra", Types.StringType.get()));
+    assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct())
+        .isEqualTo(expectedSchema.asStruct());
+    assertEquals(
+        "Should retain INT values and add a nullable column",
+        Lists.newArrayList(row(1, "old", null), row(2, "new", "added")),
+        sql("SELECT * FROM %s ORDER BY id", tableName));
+  }
+
+  @TestTemplate
+  void widenIntToBigint() {
+    sql(
+        "CREATE TABLE %s (id INT, data STRING) USING iceberg TBLPROPERTIES 
('%s' = 'false')",
+        tableName, SparkTableProperties.WRITE_ACCEPT_ANY_SCHEMA);
+    sql("INSERT INTO %s VALUES (1, 'old')", tableName);
+
+    long newId = (long) Integer.MAX_VALUE + 1;
+    sql(
+        "INSERT WITH SCHEMA EVOLUTION INTO %s %s SELECT %s "
+            + "FROM VALUES (CAST(%d AS BIGINT), 'new') AS source(id, data)",
+        tableName, byName ? "BY NAME" : "", byName ? "data, id" : "id, data", 
newId);
+
+    Schema expectedSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.LongType.get()),
+            Types.NestedField.optional(2, "data", Types.StringType.get()));
+    assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct())
+        .isEqualTo(expectedSchema.asStruct());
+    assertEquals(
+        "Should preserve old and new values after widening to BIGINT",
+        Lists.newArrayList(row(1L, "old"), row(newId, "new")),
+        sql("SELECT * FROM %s ORDER BY id", tableName));
+  }
+
+  @TestTemplate
+  void castBigintMapKeysToInt() {
+    sql(
+        "CREATE TABLE %s (id INT, data MAP<INT, STRING>) USING iceberg "
+            + "TBLPROPERTIES ('%s' = 'false')",
+        tableName, SparkTableProperties.WRITE_ACCEPT_ANY_SCHEMA);
+    sql("INSERT INTO %s SELECT 1, map(1, 'old')", tableName);
+
+    sql(
+        "INSERT WITH SCHEMA EVOLUTION INTO %s %s SELECT %s "
+            + "FROM VALUES (2, map(CAST(2 AS BIGINT), 'new', CAST(3 AS 
BIGINT), 'other')) "
+            + "AS source(id, data)",
+        tableName, byName ? "BY NAME" : "", byName ? "data, id" : "id, data");
+
+    Schema expectedSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+            Types.NestedField.optional(
+                2,
+                "data",
+                Types.MapType.ofOptional(3, 4, Types.IntegerType.get(), 
Types.StringType.get())));
+    assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct())
+        .isEqualTo(expectedSchema.asStruct());
+    Map<Integer, String> oldMap = Maps.newHashMap();
+    oldMap.put(1, "old");
+    Map<Integer, String> newMap = Maps.newHashMap();
+    newMap.put(2, "new");
+    newMap.put(3, "other");
+    assertEquals(
+        "Should preserve map values and retain INT keys",
+        Lists.newArrayList(row(1, oldMap), row(2, newMap)),
+        sql("SELECT * FROM %s ORDER BY id", tableName));
+  }
+
+  @TestTemplate
+  void evolveSchemaWithAcceptAnySchemaAndMergeSchema() {
+    sql(
+        "CREATE TABLE %s (id INT, data STRING) USING iceberg TBLPROPERTIES 
('%s' = 'true')",
+        tableName, SparkTableProperties.WRITE_ACCEPT_ANY_SCHEMA);
+    sql("INSERT INTO %s SELECT 1 AS id, 'old' AS data", tableName);
+
+    long newId = (long) Integer.MAX_VALUE + 1;
+    Map<String, String> conf = Maps.newHashMap();
+    conf.put(SparkSQLProperties.MERGE_SCHEMA, "true");
+    // accept-any-schema skips Spark's by-name column alignment and Iceberg 
checks the column order
+    // by default, so the source columns stay in table order for both insert 
modes
+    withSQLConf(
+        conf,
+        () ->
+            sql(
+                "INSERT WITH SCHEMA EVOLUTION INTO %s %s SELECT id, data, 
extra "
+                    + "FROM VALUES (CAST(%d AS BIGINT), 'new', 'added') AS 
source(id, data, extra)",
+                tableName, byName ? "BY NAME" : "", newId));
+
+    Schema expectedSchema =
+        new Schema(
+            Types.NestedField.optional(1, "id", Types.LongType.get()),
+            Types.NestedField.optional(2, "data", Types.StringType.get()),
+            Types.NestedField.optional(3, "extra", Types.StringType.get()));
+    assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct())
+        .isEqualTo(expectedSchema.asStruct());
+    assertEquals(
+        "Should widen and add columns with native and legacy evolution 
enabled",
+        Lists.newArrayList(row(1L, "old", null), row(newId, "new", "added")),
+        sql("SELECT * FROM %s ORDER BY id", tableName));
+  }
+}

Reply via email to