This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 41231c73585 [Feature](iceberg) Support schema change for complex types
in Iceberg external tables (#60169)
41231c73585 is described below
commit 41231c73585d250be843df31bc22520bb71f5ffc
Author: Chenjunwei <[email protected]>
AuthorDate: Tue Mar 3 18:21:33 2026 +0800
[Feature](iceberg) Support schema change for complex types in Iceberg
external tables (#60169)
Problem Summary:
Currently, Doris does not support `ALTER TABLE ... MODIFY COLUMN` for
complex type columns (STRUCT/ARRAY/MAP) in Iceberg external tables. This
PR adds support for safe schema evolution of complex types in Iceberg
tables.
**Supported operations:**
| Complex Type | Allowed Operations | Prohibited Operations |
|--------------|-------------------|----------------------|
| STRUCT | Append new fields, safe type promotion for existing fields |
Field rename/delete |
| ARRAY | Element type safe promotion | Incompatible element type change
|
| MAP | Value type safe promotion | Key type change |
**Safe type promotions supported in nested types:**
- `INT` → `BIGINT`, `LARGEINT`
- `TINYINT` → `SMALLINT`, `INT`, `BIGINT`, `LARGEINT`
- `SMALLINT` → `INT`, `BIGINT`, `LARGEINT`
- `BIGINT` → `LARGEINT`
- `FLOAT` → `DOUBLE`
- `VARCHAR(n)` → `VARCHAR(m)` where m > n
**Constraints:**
- All new nested fields must be nullable
- Cannot change optional to required
- Complex type default values only support NULL
---
.../java/org/apache/doris/catalog/ColumnType.java | 65 ++++--
.../datasource/iceberg/IcebergMetadataOps.java | 216 +++++++++++++++++--
.../java/org/apache/doris/catalog/ColumnTest.java | 9 +-
.../iceberg/IcebergMetadataOpsValidationTest.java | 180 ++++++++++++++++
.../iceberg/iceberg_schema_change_ddl.out | 28 +--
.../iceberg/iceberg_schema_change_ddl.groovy | 3 +-
...test_iceberg_schema_change_complex_types.groovy | 232 +++++++++++++++++++++
7 files changed, 689 insertions(+), 44 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/ColumnType.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/ColumnType.java
index dd311d619c5..39e852a4b94 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/ColumnType.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/ColumnType.java
@@ -190,21 +190,62 @@ public abstract class ColumnType {
}
}
- // This method defines the char type
- // to support the schema-change behavior of length growth.
- // return true if the checkType and other are both char-type otherwise
return false,
- // which used in checkSupportSchemaChangeForComplexType
- private static boolean checkSupportSchemaChangeForCharType(Type checkType,
Type other) throws DdlException {
+ // This method checks if a primitive type change is allowed in nested
complex types.
+ // Supports:
+ // 1. VARCHAR length increase
+ // 2. Safe numeric type promotions (INT -> BIGINT, FLOAT -> DOUBLE, etc.)
+ // 3. Exact type match
+ private static boolean checkSupportSchemaChangeForNestedPrimitive(Type
checkType, Type other) throws DdlException {
+ // 1. Check VARCHAR length increase
if (checkType.getPrimitiveType() == PrimitiveType.VARCHAR
&& other.getPrimitiveType() == PrimitiveType.VARCHAR) {
- // currently nested types only support light schema change for
internal fields, for string types,
- // only varchar can do light schema change
checkForTypeLengthChange(checkType, other);
return true;
- } else {
- // types equal can return true
- return checkType.equals(other);
}
+
+ // 2. Check exact type match (including STRING == STRING)
+ if (checkType.equals(other)) {
+ return true;
+ }
+
+ // 3. Check safe numeric type promotions for nested types
+ // These are safe promotions that don't lose precision:
+ // - INT -> BIGINT, LARGEINT
+ // - FLOAT -> DOUBLE
+ PrimitiveType srcType = checkType.getPrimitiveType();
+ PrimitiveType dstType = other.getPrimitiveType();
+
+ // INT -> BIGINT, LARGEINT
+ if (srcType == PrimitiveType.INT
+ && (dstType == PrimitiveType.BIGINT || dstType ==
PrimitiveType.LARGEINT)) {
+ return true;
+ }
+
+ // TINYINT -> SMALLINT, INT, BIGINT, LARGEINT
+ if (srcType == PrimitiveType.TINYINT
+ && (dstType == PrimitiveType.SMALLINT || dstType ==
PrimitiveType.INT
+ || dstType == PrimitiveType.BIGINT || dstType ==
PrimitiveType.LARGEINT)) {
+ return true;
+ }
+
+ // SMALLINT -> INT, BIGINT, LARGEINT
+ if (srcType == PrimitiveType.SMALLINT
+ && (dstType == PrimitiveType.INT || dstType ==
PrimitiveType.BIGINT
+ || dstType == PrimitiveType.LARGEINT)) {
+ return true;
+ }
+
+ // BIGINT -> LARGEINT
+ if (srcType == PrimitiveType.BIGINT && dstType ==
PrimitiveType.LARGEINT) {
+ return true;
+ }
+
+ // FLOAT -> DOUBLE
+ if (srcType == PrimitiveType.FLOAT && dstType == PrimitiveType.DOUBLE)
{
+ return true;
+ }
+
+ return false;
}
private static void validateStructFieldCompatibility(StructField
originalField, StructField newField)
@@ -272,9 +313,9 @@ public abstract class ColumnType {
checkSupportSchemaChangeForComplexType(((MapType)
checkType).getValueType(),
((MapType) other).getValueType(), true);
} else {
- // only support char-type schema change behavior for nested
complex type
+ // Support safe type promotions for nested primitive types
// if nested is false, we do not check return value.
- if (nested && !checkSupportSchemaChangeForCharType(checkType,
other)) {
+ if (nested &&
!checkSupportSchemaChangeForNestedPrimitive(checkType, other)) {
throw new DdlException(
"Cannot change " + checkType.toSql() + " to " +
other.toSql() + " in nested types");
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
index bcdb7e2f39d..751c98c37d8 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
@@ -18,8 +18,11 @@
package org.apache.doris.datasource.iceberg;
import org.apache.doris.analysis.ColumnPosition;
+import org.apache.doris.catalog.ArrayType;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.ColumnType;
import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.MapType;
import org.apache.doris.catalog.StructField;
import org.apache.doris.catalog.StructType;
import org.apache.doris.common.DdlException;
@@ -68,6 +71,7 @@ import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.expressions.Literal;
import org.apache.iceberg.expressions.Term;
import org.apache.iceberg.types.Type;
+import org.apache.iceberg.types.Types;
import org.apache.iceberg.types.Types.NestedField;
import org.apache.iceberg.view.View;
import org.apache.logging.log4j.LogManager;
@@ -79,6 +83,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.stream.Collectors;
@@ -727,15 +732,36 @@ public class IcebergMetadataOps implements
ExternalMetadataOps {
public void modifyColumn(ExternalTable dorisTable, Column column,
ColumnPosition position, long updateTime)
throws UserException {
Table icebergTable = IcebergUtils.getIcebergTable(dorisTable);
- validateForModifyColumn(column, icebergTable);
- Type icebergType =
IcebergUtils.dorisTypeToIcebergType(column.getType());
+ NestedField currentCol =
icebergTable.schema().findField(column.getName());
+ if (currentCol == null) {
+ throw new UserException("Column " + column.getName() + " does not
exist");
+ }
+
+ validateCommonColumnInfo(column);
UpdateSchema updateSchema = icebergTable.updateSchema();
- updateSchema.updateColumn(column.getName(),
icebergType.asPrimitiveType(), column.getComment());
- if (column.isAllowNull()) {
- // we can change a required column to optional, but not the other
way around
- // because we don't know whether there is existing data with null
values.
- updateSchema.makeColumnOptional(column.getName());
+
+ if (column.getType().isComplexType()) {
+ // Complex type processing branch
+ validateForModifyComplexColumn(column, currentCol);
+ applyComplexTypeChange(updateSchema, column.getName(),
currentCol.type(), column.getType());
+ if (column.isAllowNull()) {
+ updateSchema.makeColumnOptional(column.getName());
+ }
+ if (!Objects.equals(currentCol.doc(), column.getComment())) {
+ updateSchema.updateColumnDoc(column.getName(),
column.getComment());
+ }
+ } else {
+ // Primitive type processing (existing logic)
+ validateForModifyColumn(column, currentCol);
+ Type icebergType =
IcebergUtils.dorisTypeToIcebergType(column.getType());
+ updateSchema.updateColumn(column.getName(),
icebergType.asPrimitiveType(), column.getComment());
+ if (column.isAllowNull()) {
+ // we can change a required column to optional, but not the
other way around
+ // because we don't know whether there is existing data with
null values.
+ updateSchema.makeColumnOptional(column.getName());
+ }
}
+
if (position != null) {
applyPosition(updateSchema, position, column.getName());
}
@@ -748,23 +774,184 @@ public class IcebergMetadataOps implements
ExternalMetadataOps {
refreshTable(dorisTable, updateTime);
}
- private void validateForModifyColumn(Column column, Table icebergTable)
throws UserException {
- validateCommonColumnInfo(column);
+ private void validateForModifyColumn(Column column, NestedField
currentCol) throws UserException {
// check complex type
if (column.getType().isComplexType()) {
throw new UserException("Modify column type to non-primitive type
is not supported: " + column.getType());
}
- // check exist
- NestedField currentCol =
icebergTable.schema().findField(column.getName());
- if (currentCol == null) {
- throw new UserException("Column " + column.getName() + " does not
exist");
- }
// check nullable
if (currentCol.isOptional() && !column.isAllowNull()) {
throw new UserException("Can not change nullable column " +
column.getName() + " to not null");
}
}
+ private void validateForModifyComplexColumn(Column column, NestedField
currentCol) throws UserException {
+ if (!column.getType().isComplexType()) {
+ throw new UserException("Modify column type to non-complex type is
not supported: " + column.getType());
+ }
+ Type oldIcebergType = currentCol.type();
+ if (oldIcebergType.isPrimitiveType()) {
+ throw new UserException("Modify column type from non-complex to
complex is not supported: "
+ + column.getName());
+ }
+
+ org.apache.doris.catalog.Type oldDorisType =
IcebergUtils.icebergTypeToDorisType(oldIcebergType, false, false);
+ org.apache.doris.catalog.Type newDorisType = column.getType();
+ if (!isSameComplexCategory(oldIcebergType, newDorisType)) {
+ throw new UserException("Cannot change complex column type
category from "
+ + oldDorisType.toSql() + " to " + newDorisType.toSql());
+ }
+ try {
+ ColumnType.checkSupportSchemaChangeForComplexType(oldDorisType,
newDorisType, false);
+ } catch (DdlException e) {
+ throw new UserException(e.getMessage(), e);
+ }
+ if (currentCol.isOptional() && !column.isAllowNull()) {
+ throw new UserException("Cannot change nullable column " +
column.getName() + " to not null");
+ }
+ if (column.getDefaultValue() != null ||
column.getDefaultValueExprDef() != null) {
+ throw new UserException("Complex type default value only supports
NULL");
+ }
+ }
+
+ private boolean isSameComplexCategory(Type oldIcebergType,
org.apache.doris.catalog.Type newDorisType) {
+ switch (oldIcebergType.typeId()) {
+ case STRUCT:
+ return newDorisType.isStructType();
+ case LIST:
+ return newDorisType.isArrayType();
+ case MAP:
+ return newDorisType.isMapType();
+ default:
+ return false;
+ }
+ }
+
+ private void applyComplexTypeChange(UpdateSchema updateSchema, String path,
+ org.apache.iceberg.types.Type oldIcebergType,
+ org.apache.doris.catalog.Type newDorisType) throws UserException {
+ switch (oldIcebergType.typeId()) {
+ case STRUCT:
+ applyStructChange(updateSchema, path,
oldIcebergType.asStructType(), (StructType) newDorisType);
+ break;
+ case LIST:
+ applyListChange(updateSchema, path, (Types.ListType)
oldIcebergType, (ArrayType) newDorisType);
+ break;
+ case MAP:
+ applyMapChange(updateSchema, path, (Types.MapType)
oldIcebergType, (MapType) newDorisType);
+ break;
+ default:
+ throw new UserException("Unsupported complex type for modify:
" + oldIcebergType);
+ }
+ }
+
+ private void applyStructChange(UpdateSchema updateSchema, String path,
+ Types.StructType oldStructType, StructType newStructType) throws
UserException {
+ List<NestedField> oldFields = oldStructType.fields();
+ List<StructField> newFields = newStructType.getFields();
+
+ for (int i = 0; i < oldFields.size(); i++) {
+ NestedField oldField = oldFields.get(i);
+ StructField newField = newFields.get(i);
+ String fieldPath = path + "." + oldField.name();
+
+ if (oldField.isOptional() && !newField.getContainsNull()) {
+ throw new UserException("Cannot change nullable column " +
fieldPath + " to not null");
+ }
+
+ org.apache.iceberg.types.Type oldFieldType = oldField.type();
+ org.apache.doris.catalog.Type newFieldType = newField.getType();
+ if (oldFieldType.isPrimitiveType()) {
+ org.apache.doris.catalog.Type oldDorisFieldType =
+ IcebergUtils.icebergTypeToDorisType(oldFieldType,
false, false);
+ boolean typeChanged = !oldDorisFieldType.equals(newFieldType);
+ boolean commentChanged = !Objects.equals(oldField.doc(),
newField.getComment());
+ if (typeChanged || commentChanged) {
+ org.apache.iceberg.types.Type newIcebergFieldType =
+ IcebergUtils.dorisTypeToIcebergType(newFieldType);
+ updateSchema.updateColumn(fieldPath,
newIcebergFieldType.asPrimitiveType(),
+ newField.getComment());
+ }
+ } else {
+ applyComplexTypeChange(updateSchema, fieldPath, oldFieldType,
newFieldType);
+ if (!Objects.equals(oldField.doc(), newField.getComment())) {
+ updateSchema.updateColumnDoc(fieldPath,
newField.getComment());
+ }
+ }
+
+ if (!oldField.isOptional() && newField.getContainsNull()) {
+ updateSchema.makeColumnOptional(fieldPath);
+ }
+ }
+
+ for (int i = oldFields.size(); i < newFields.size(); i++) {
+ StructField newField = newFields.get(i);
+ if (!newField.getContainsNull()) {
+ throw new UserException("New struct field '" +
newField.getName() + "' must be nullable");
+ }
+ org.apache.iceberg.types.Type newFieldIcebergType =
+ IcebergUtils.dorisTypeToIcebergType(newField.getType());
+ updateSchema.addColumn(path, newField.getName(),
newFieldIcebergType, newField.getComment());
+ }
+ }
+
+ private void applyListChange(UpdateSchema updateSchema, String path,
+ Types.ListType oldListType, ArrayType newArrayType) throws
UserException {
+ String elementPath = path + "." +
oldListType.field(oldListType.elementId()).name();
+ if (oldListType.isElementOptional() &&
!newArrayType.getContainsNull()) {
+ throw new UserException("Cannot change nullable column " +
elementPath + " to not null");
+ }
+ org.apache.iceberg.types.Type oldElementType =
oldListType.elementType();
+ org.apache.doris.catalog.Type newElementType =
newArrayType.getItemType();
+ if (oldElementType.isPrimitiveType()) {
+ org.apache.doris.catalog.Type oldDorisElementType =
+ IcebergUtils.icebergTypeToDorisType(oldElementType, false,
false);
+ if (!oldDorisElementType.equals(newElementType)) {
+ org.apache.iceberg.types.Type newIcebergElementType =
+ IcebergUtils.dorisTypeToIcebergType(newElementType);
+ updateSchema.updateColumn(elementPath,
newIcebergElementType.asPrimitiveType(), null);
+ }
+ } else {
+ applyComplexTypeChange(updateSchema, elementPath, oldElementType,
newElementType);
+ }
+ if (!oldListType.isElementOptional() &&
newArrayType.getContainsNull()) {
+ updateSchema.makeColumnOptional(elementPath);
+ }
+ }
+
+ private void applyMapChange(UpdateSchema updateSchema, String path,
+ Types.MapType oldMapType, MapType newMapType) throws UserException
{
+ org.apache.iceberg.types.Type oldKeyType = oldMapType.keyType();
+ org.apache.doris.catalog.Type newKeyType = newMapType.getKeyType();
+ org.apache.doris.catalog.Type oldDorisKeyType =
+ IcebergUtils.icebergTypeToDorisType(oldKeyType, false, false);
+ if (!oldDorisKeyType.equals(newKeyType)) {
+ throw new UserException("Cannot change MAP key type from "
+ + oldDorisKeyType.toSql() + " to " + newKeyType.toSql());
+ }
+
+ String valuePath = path + "." +
oldMapType.field(oldMapType.valueId()).name();
+ if (oldMapType.isValueOptional() &&
!newMapType.getIsValueContainsNull()) {
+ throw new UserException("Cannot change nullable column " +
valuePath + " to not null");
+ }
+ org.apache.iceberg.types.Type oldValueType = oldMapType.valueType();
+ org.apache.doris.catalog.Type newValueType = newMapType.getValueType();
+ if (oldValueType.isPrimitiveType()) {
+ org.apache.doris.catalog.Type oldDorisValueType =
+ IcebergUtils.icebergTypeToDorisType(oldValueType, false,
false);
+ if (!oldDorisValueType.equals(newValueType)) {
+ org.apache.iceberg.types.Type newIcebergValueType =
+ IcebergUtils.dorisTypeToIcebergType(newValueType);
+ updateSchema.updateColumn(valuePath,
newIcebergValueType.asPrimitiveType(), null);
+ }
+ } else {
+ applyComplexTypeChange(updateSchema, valuePath, oldValueType,
newValueType);
+ }
+ if (!oldMapType.isValueOptional() &&
newMapType.getIsValueContainsNull()) {
+ updateSchema.makeColumnOptional(valuePath);
+ }
+ }
+
private void validateCommonColumnInfo(Column column) throws UserException {
// check aggregation method
if (column.isAggregated()) {
@@ -1046,4 +1233,3 @@ public class IcebergMetadataOps implements
ExternalMetadataOps {
return builder.build();
}
}
-
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/ColumnTest.java
b/fe/fe-core/src/test/java/org/apache/doris/catalog/ColumnTest.java
index cde493d4adb..c3c82c5fc3e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/ColumnTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/ColumnTest.java
@@ -151,11 +151,18 @@ public class ColumnTest {
Assert.fail("No exception throws.");
}
- @Test(expected = DdlException.class)
+ @Test
public void testSchemaChangeArrayToArray() throws DdlException {
Column oldColumn = new Column("a", ArrayType.create(Type.TINYINT,
true), false, null, true, "0", "");
Column newColumn = new Column("a", ArrayType.create(Type.INT, true),
false, null, true, "0", "");
oldColumn.checkSchemaChangeAllowed(newColumn);
+ }
+
+ @Test(expected = DdlException.class)
+ public void testSchemaChangeArrayToArrayDowngrade() throws DdlException {
+ Column oldColumn = new Column("a", ArrayType.create(Type.INT, true),
false, null, true, "0", "");
+ Column newColumn = new Column("a", ArrayType.create(Type.TINYINT,
true), false, null, true, "0", "");
+ oldColumn.checkSchemaChangeAllowed(newColumn);
Assert.fail("No exception throws.");
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
new file mode 100644
index 00000000000..74baff368d8
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergMetadataOpsValidationTest.java
@@ -0,0 +1,180 @@
+// 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.doris.datasource.iceberg;
+
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.MapType;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.ExternalCatalog;
+
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.SupportsNamespaces;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.types.Types.NestedField;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Collections;
+
+public class IcebergMetadataOpsValidationTest {
+
+ private IcebergMetadataOps ops;
+ private Method validateForModifyColumnMethod;
+ private Method validateForModifyComplexColumnMethod;
+
+ @Before
+ public void setUp() throws Exception {
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ Catalog icebergCatalog = Mockito.mock(Catalog.class,
+
Mockito.withSettings().extraInterfaces(SupportsNamespaces.class));
+ Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new
ExecutionAuthenticator() {
+ });
+
Mockito.when(dorisCatalog.getProperties()).thenReturn(Collections.emptyMap());
+ ops = new IcebergMetadataOps(dorisCatalog, icebergCatalog);
+
+ validateForModifyColumnMethod =
IcebergMetadataOps.class.getDeclaredMethod(
+ "validateForModifyColumn", Column.class, NestedField.class);
+ validateForModifyColumnMethod.setAccessible(true);
+ validateForModifyComplexColumnMethod =
IcebergMetadataOps.class.getDeclaredMethod(
+ "validateForModifyComplexColumn", Column.class,
NestedField.class);
+ validateForModifyComplexColumnMethod.setAccessible(true);
+ }
+
+ @Test
+ public void testValidateForModifyColumnRejectsComplexType() {
+ Column column = new Column("arr_i", ArrayType.create(Type.INT, true),
true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
Types.IntegerType.get());
+ assertUserException(() -> invokeValidateForModifyColumn(column,
currentCol),
+ "Modify column type to non-primitive type is not supported");
+ }
+
+ @Test
+ public void testValidateForModifyColumnRejectsNullableToNotNull() {
+ Column column = new Column("int_col", Type.INT, false);
+ NestedField currentCol = Types.NestedField.optional(1, "int_col",
Types.IntegerType.get());
+ assertUserException(() -> invokeValidateForModifyColumn(column,
currentCol),
+ "Can not change nullable column int_col to not null");
+ }
+
+ @Test
+ public void testValidateForModifyColumnSuccess() throws Throwable {
+ Column column = new Column("int_col", Type.INT, true);
+ NestedField currentCol = Types.NestedField.required(1, "int_col",
Types.IntegerType.get());
+ invokeValidateForModifyColumn(column, currentCol);
+ }
+
+ @Test
+ public void testValidateForModifyComplexColumnRejectsPrimitiveType() {
+ Column column = new Column("arr_i", Type.INT, true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Modify column type to non-complex type is not supported");
+ }
+
+ @Test
+ public void
testValidateForModifyComplexColumnRejectsIncompatibleNestedType() {
+ Column column = new Column("arr_i", ArrayType.create(Type.SMALLINT,
true), true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Cannot change int to smallint in nested types");
+ }
+
+ @Test
+ public void testValidateForModifyComplexColumnRejectsPrimitiveToComplex() {
+ Column column = new Column("arr_i", ArrayType.create(Type.INT, true),
true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
Types.IntegerType.get());
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Modify column type from non-complex to complex is not
supported");
+ }
+
+ @Test
+ public void
testValidateForModifyComplexColumnRejectsDifferentComplexCategory() {
+ Column column = new Column("arr_i", new MapType(Type.INT, Type.INT),
true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Cannot change complex column type category");
+ }
+
+ @Test
+ public void testValidateForModifyComplexColumnRejectsNullableToNotNull() {
+ Column column = new Column("arr_i", ArrayType.create(Type.INT, true),
false);
+ NestedField currentCol = Types.NestedField.optional(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Cannot change nullable column arr_i to not null");
+ }
+
+ @Test
+ public void testValidateForModifyComplexColumnRejectsDefaultValue() {
+ Column column = new Column("arr_i", ArrayType.create(Type.INT, true),
+ false, null, true, "1", "");
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ assertUserException(() -> invokeValidateForModifyComplexColumn(column,
currentCol),
+ "Complex type default value only supports NULL");
+ }
+
+ @Test
+ public void testValidateForModifyComplexColumnSuccess() throws Throwable {
+ Column column = new Column("arr_i", ArrayType.create(Type.BIGINT,
true), true);
+ NestedField currentCol = Types.NestedField.required(1, "arr_i",
+ Types.ListType.ofOptional(2, Types.IntegerType.get()));
+ invokeValidateForModifyComplexColumn(column, currentCol);
+ }
+
+ private void invokeValidateForModifyColumn(Column column, NestedField
currentCol) throws Throwable {
+ invokeValidationMethod(validateForModifyColumnMethod, column,
currentCol);
+ }
+
+ private void invokeValidateForModifyComplexColumn(Column column,
NestedField currentCol) throws Throwable {
+ invokeValidationMethod(validateForModifyComplexColumnMethod, column,
currentCol);
+ }
+
+ private void invokeValidationMethod(Method method, Column column,
NestedField currentCol) throws Throwable {
+ try {
+ method.invoke(ops, column, currentCol);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+
+ private void assertUserException(ThrowingRunnable runnable, String
expectedMessage) {
+ try {
+ runnable.run();
+ Assert.fail("expected UserException");
+ } catch (Throwable t) {
+ Assert.assertTrue(t instanceof UserException);
+ Assert.assertTrue(t.getMessage().contains(expectedMessage));
+ }
+ }
+
+ @FunctionalInterface
+ private interface ThrowingRunnable {
+ void run() throws Throwable;
+ }
+}
diff --git
a/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
b/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
index 2f68c298f37..dab31ada31b 100644
---
a/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
+++
b/regression-test/data/external_table_p0/iceberg/iceberg_schema_change_ddl.out
@@ -22,7 +22,7 @@ email text Yes true \N
1 Alice 25 \N 95.5 \N
2 Bob 30 \N 87.2 \N
3 Charlie 22 \N 92.8 \N
-4 David 28 123-456-7890 89.1 [email protected]
+4 David 28 123-456-7890 89.09999999999999
[email protected]
-- !add_3 --
4 [email protected]
@@ -45,7 +45,7 @@ address struct<city:text,country:text> Yes true
\N
1 Alice 25 \N 95.5 \N \N
2 Bob 30 \N 87.2 \N \N
3 Charlie 22 \N 92.8 \N \N
-4 David 28 123-456-7890 89.1 [email protected] \N
+4 David 28 123-456-7890 89.09999999999999
[email protected] \N
5 Eve 26 223-345-132 91.3 [email protected]
{"city":"New York", "country":"USA"}
-- !add_multi_3 --
@@ -64,7 +64,7 @@ address struct<city:text,country:text> Yes true
\N
1 95.5
2 87.2
3 92.8
-4 89.1
+4 89.09999999999999
5 91.3
-- !rename_3 --
@@ -84,7 +84,7 @@ address struct<city:text,country:text> Yes true
\N
1 25 \N 95.5 \N \N
2 30 \N 87.2 \N \N
3 22 \N 92.8 \N \N
-4 28 123-456-7890 89.1 [email protected] \N
+4 28 123-456-7890 89.09999999999999 [email protected]
\N
5 26 223-345-132 91.3 [email protected] {"city":"New
York", "country":"USA"}
-- !drop_3 --
@@ -129,7 +129,7 @@ col2 text Yes true \N User
defined column2
-- !modify_3 --
30 2 \N 87.2 \N \N \N \N
-28 4 123-456-7890 89.1 [email protected] \N \N
\N
+28 4 123-456-7890 89.09999999999999 [email protected]
\N \N \N
26 5 223-345-132 91.3 [email protected] {"city":"New
York", "country":"USA"} \N \N
-- !before_no_comment --
@@ -193,10 +193,10 @@ test_decimal decimal(10,2) Yes true \N
1 25 \N \N 95.5 \N \N \N \N \N
2 30 \N \N 87.2 \N \N \N \N \N
3 22 \N \N 92.8 \N \N \N \N \N
-4 28 \N \N 89.1 123-456-7890 [email protected]
\N \N \N
+4 28 \N \N 89.09999999999999 123-456-7890
[email protected] \N \N \N
5 26 \N \N 91.3 223-345-132 [email protected]
{"city":"New York", "country":"USA"} \N \N
-6 \N \N \N 100.0 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
-7 \N \N \N 0.0 \N \N \N
3.140000104904175 123.45
+6 \N \N \N 100 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
+7 \N \N \N 0 \N \N \N
3.140000104904175 123.45
-- !before_rename_show_tables_old --
iceberg_ddl_test
@@ -224,19 +224,19 @@ test_decimal decimal(10,2) Yes true \N
1 25 \N \N 95.5 \N \N \N \N \N
2 30 \N \N 87.2 \N \N \N \N \N
3 22 \N \N 92.8 \N \N \N \N \N
-4 28 \N \N 89.1 123-456-7890 [email protected]
\N \N \N
+4 28 \N \N 89.09999999999999 123-456-7890
[email protected] \N \N \N
5 26 \N \N 91.3 223-345-132 [email protected]
{"city":"New York", "country":"USA"} \N \N
-6 \N \N \N 100.0 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
-7 \N \N \N 0.0 \N \N \N
3.140000104904175 123.45
+6 \N \N \N 100 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
+7 \N \N \N 0 \N \N \N
3.140000104904175 123.45
-- !rename_table_back_1 --
1 25 \N \N 95.5 \N \N \N \N \N
2 30 \N \N 87.2 \N \N \N \N \N
3 22 \N \N 92.8 \N \N \N \N \N
-4 28 \N \N 89.1 123-456-7890 [email protected]
\N \N \N
+4 28 \N \N 89.09999999999999 123-456-7890
[email protected] \N \N \N
5 26 \N \N 91.3 223-345-132 [email protected]
{"city":"New York", "country":"USA"} \N \N
-6 \N \N \N 100.0 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
-7 \N \N \N 0.0 \N \N \N
3.140000104904175 123.45
+6 \N \N \N 100 123-456-7890 [email protected]
{"city":"Los Angeles", "country":"USA"} \N \N
+7 \N \N \N 0 \N \N \N
3.140000104904175 123.45
-- !partition_init_1 --
id int Yes true \N
diff --git
a/regression-test/suites/external_table_p0/iceberg/iceberg_schema_change_ddl.groovy
b/regression-test/suites/external_table_p0/iceberg/iceberg_schema_change_ddl.groovy
index 33ce0648110..1762a088953 100644
---
a/regression-test/suites/external_table_p0/iceberg/iceberg_schema_change_ddl.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/iceberg_schema_change_ddl.groovy
@@ -310,7 +310,7 @@ suite("iceberg_schema_change_ddl",
"p0,external,doris,external_docker,external_d
test {
sql """ ALTER TABLE ${table_name} MODIFY COLUMN email STRUCT<name:
STRING> """
- exception "Modify column type to non-primitive type is not supported"
+ exception "Modify column type from non-complex to complex is not
supported"
}
// Test 8: reorder columns
@@ -445,4 +445,3 @@ suite("iceberg_schema_change_ddl",
"p0,external,doris,external_docker,external_d
// Clean up
sql """ drop table if exists ${int_partition_table_name} """
}
-
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_schema_change_complex_types.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_schema_change_complex_types.groovy
new file mode 100644
index 00000000000..da36702e770
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_schema_change_complex_types.groovy
@@ -0,0 +1,232 @@
+// 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.
+
+suite("test_iceberg_schema_change_complex_types",
"p0,external,doris,external_docker,external_docker_doris") {
+
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test.")
+ return
+ }
+
+ String rest_port = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String catalog_name = "test_iceberg_schema_change_complex_types"
+ String db_name = "iceberg_schema_change_complex_types_db"
+ String table_name = "iceberg_complex_modify"
+
+ sql """drop catalog if exists ${catalog_name}"""
+ sql """
+ CREATE CATALOG ${catalog_name} PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri' = 'http://${externalEnvIp}:${rest_port}',
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "http://${externalEnvIp}:${minio_port}",
+ "s3.region" = "us-east-1"
+ );"""
+
+ sql """switch ${catalog_name};"""
+ sql """drop database if exists ${db_name} force"""
+ sql """create database ${db_name}"""
+ sql """use ${db_name};"""
+
+ sql """set enable_fallback_to_original_planner=false;"""
+ sql """set show_column_comment_in_describe=true;"""
+
+ sql """drop table if exists ${table_name}"""
+ sql """
+ CREATE TABLE ${table_name} (
+ id INT NOT NULL,
+ arr_i ARRAY<INT>,
+ arr_f ARRAY<FLOAT>,
+ mp_i MAP<INT, INT>,
+ mp_f MAP<INT, FLOAT>,
+ st STRUCT<
+ si: INT,
+ sf: FLOAT,
+ sv: STRING COMMENT 'v1',
+ sm: MAP<INT, INT> COMMENT 'm1',
+ sa: ARRAY<INT> COMMENT 'a1'
+ > NOT NULL COMMENT 'old struct'
+ );"""
+
+ sql """
+ INSERT INTO ${table_name} VALUES (
+ 1,
+ ARRAY(1, 2),
+ ARRAY(CAST(1.1 AS FLOAT)),
+ MAP(1, 10),
+ MAP(1, CAST(1.1 AS FLOAT)),
+ STRUCT(1, CAST(1.1 AS FLOAT), 'abc', MAP(1, 10), ARRAY(1, 2))
+ )"""
+
+ // Complex type default value only supports NULL
+ test {
+ sql """
+ ALTER TABLE ${table_name} MODIFY COLUMN st STRUCT<
+ si: INT,
+ sf: FLOAT,
+ sv: STRING COMMENT 'v1',
+ sm: MAP<INT, INT> COMMENT 'm1',
+ sa: ARRAY<INT> COMMENT 'a1'
+ > DEFAULT 'x'"""
+ exception "just support null"
+ }
+
+ // Cannot change nullable complex column to not null
+ test {
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN arr_i ARRAY<INT> NOT
NULL"""
+ exception "Cannot change nullable column arr_i to not null"
+ }
+
+ // Map key type change is not allowed
+ test {
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN mp_i MAP<BIGINT, INT>"""
+ exception "Cannot change MAP key type"
+ }
+
+ // Complex type category change is not allowed
+ test {
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN arr_i MAP<INT, INT>"""
+ exception "Cannot change complex column type category"
+ }
+
+ // Struct field rename is not allowed
+ test {
+ sql """
+ ALTER TABLE ${table_name} MODIFY COLUMN st STRUCT<
+ si_rename: INT,
+ sf: FLOAT,
+ sv: STRING COMMENT 'v1',
+ sm: MAP<INT, INT> COMMENT 'm1',
+ sa: ARRAY<INT> COMMENT 'a1'
+ > NOT NULL COMMENT 'old struct'"""
+ exception "Cannot rename struct field"
+ }
+
+ // Struct field drop is not allowed
+ test {
+ sql """
+ ALTER TABLE ${table_name} MODIFY COLUMN st STRUCT<
+ si: INT,
+ sf: FLOAT,
+ sv: STRING COMMENT 'v1',
+ sm: MAP<INT, INT> COMMENT 'm1'
+ > NOT NULL COMMENT 'old struct'"""
+ exception "Cannot reduce struct fields"
+ }
+
+ // Nested type downgrade is not allowed
+ test {
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN arr_i ARRAY<SMALLINT>"""
+ exception "Cannot change int to smallint in nested types"
+ }
+
+ // Array element type promotions
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN arr_i ARRAY<BIGINT>"""
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN arr_f ARRAY<DOUBLE>"""
+
+ // Map value type promotions
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN mp_i MAP<INT, BIGINT>"""
+ sql """ALTER TABLE ${table_name} MODIFY COLUMN mp_f MAP<INT, DOUBLE>"""
+
+ // Struct field promotions, nested complex updates, add field, and comment
updates
+ sql """
+ ALTER TABLE ${table_name} MODIFY COLUMN st STRUCT<
+ si: BIGINT,
+ sf: DOUBLE,
+ sv: STRING COMMENT 'v2',
+ sm: MAP<INT, BIGINT> COMMENT 'm2',
+ sa: ARRAY<BIGINT> COMMENT 'a2',
+ sn: STRING COMMENT 'new field',
+ sn_map: MAP<INT, BIGINT> COMMENT 'new map',
+ sn_arr: ARRAY<BIGINT> COMMENT 'new array'
+ > NULL COMMENT 'new struct'"""
+
+ // Insert new data after schema change and keep old data readable
+ sql """
+ INSERT INTO ${table_name} VALUES (
+ 2,
+ ARRAY(10, 20, 30),
+ ARRAY(CAST(2.2 AS DOUBLE)),
+ MAP(2, 200),
+ MAP(2, CAST(2.2 AS DOUBLE)),
+ STRUCT(
+ 2,
+ CAST(2.2 AS DOUBLE),
+ 'xyz',
+ MAP(2, 200),
+ ARRAY(10, 20),
+ 'new field value',
+ MAP(3, 300),
+ ARRAY(30, 40)
+ )
+ )"""
+
+ def descRows = sql """DESC ${table_name}"""
+ def normalizeType = { String s -> s.toLowerCase().replaceAll("\\s+", "") }
+ def typeOf = { String name ->
+ def row = descRows.find { it[0].toString().equalsIgnoreCase(name) }
+ assertTrue(row != null, "column not found: ${name}")
+ return normalizeType(row[1].toString())
+ }
+
+ assertTrue(typeOf("arr_i").contains("array<bigint>"), descRows.toString())
+ assertTrue(typeOf("arr_f").contains("array<double>"), descRows.toString())
+ assertTrue(typeOf("mp_i").contains("map<int,bigint>"), descRows.toString())
+ assertTrue(typeOf("mp_f").contains("map<int,double>"), descRows.toString())
+
+ def stType = typeOf("st")
+ assertTrue(stType.contains("si:bigint"), stType)
+ assertTrue(stType.contains("sf:double"), stType)
+ assertTrue(stType.contains("sv:text"), stType)
+ assertTrue(stType.contains("sm:map<int,bigint>"), stType)
+ assertTrue(stType.contains("sa:array<bigint>"), stType)
+ assertTrue(stType.contains("sn:text"), stType)
+ assertTrue(stType.contains("sn_map:map<int,bigint>"), stType)
+ assertTrue(stType.contains("sn_arr:array<bigint>"), stType)
+
+ def stRow = descRows.find { it[0].toString().equalsIgnoreCase("st") }
+ assertTrue(stRow.toString().toLowerCase().contains("new struct"),
stRow.toString())
+ assertTrue(stRow[2].toString().equalsIgnoreCase("YES"), stRow.toString())
+
+ // Reorder columns should still work after complex type changes
+ sql """ALTER TABLE ${table_name} ORDER BY (id, arr_i, mp_i, st, arr_f,
mp_f)"""
+
+ def queryRes = sql """SELECT id,
+ STRUCT_ELEMENT(st, 'si') AS si,
+ STRUCT_ELEMENT(st, 'sn') AS sn,
+ ARRAY_SIZE(arr_i) AS arr_sz
+ FROM ${table_name} ORDER BY id"""
+ assertTrue(queryRes.size() == 2, queryRes.toString())
+ assertTrue(queryRes[0][0].toString() == "1", queryRes.toString())
+ assertTrue(queryRes[0][1].toString() == "1", queryRes.toString())
+ assertTrue(queryRes[0][2] == null, queryRes.toString())
+ assertTrue(queryRes[0][3].toString() == "2", queryRes.toString())
+
+ assertTrue(queryRes[1][0].toString() == "2", queryRes.toString())
+ assertTrue(queryRes[1][1].toString() == "2", queryRes.toString())
+ assertTrue(queryRes[1][2].toString() == "new field value",
queryRes.toString())
+ assertTrue(queryRes[1][3].toString() == "3", queryRes.toString())
+
+ sql """drop table if exists ${table_name}"""
+ sql """drop database if exists ${db_name} force"""
+ sql """drop catalog if exists ${catalog_name}"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]