This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 265a6ad2d4f branch-4.1: [feature](catalog) Support altering Iceberg
and Paimon table properties (#66428)
265a6ad2d4f is described below
commit 265a6ad2d4f83a0b38f289de4fe1ab4ceae4c5e3
Author: Socrates <[email protected]>
AuthorDate: Tue Aug 25 10:25:10 2026 +0800
branch-4.1: [feature](catalog) Support altering Iceberg and Paimon table
properties (#66428)
## What changed
- Support `ALTER TABLE ... SET (...)` for both Iceberg and Paimon
external tables.
- Keep Doris-owned `auto_analyze_policy` separate from connector-native
properties and reject mixed updates.
- Allow multiple connector properties in one synchronous external-table
ALTER operation.
- Commit Iceberg properties through one `UpdateProperties` transaction.
- Convert Paimon properties to `SchemaChange.setOption` entries and
apply them through one `Catalog.alterTable` call.
- Refresh FE external-table metadata and persist the refresh log only
after the remote commit succeeds.
## Atomicity and failure behavior
- Iceberg validates and commits the complete property map as one update.
- Paimon validates and applies the complete `List<SchemaChange>` as one
schema change.
- A failed remote update does not trigger FE refresh or leave a valid
subset of a mixed valid/invalid update behind.
## Review cleanup
- Reuse one `auto_analyze_policy` validator for internal and external
table paths.
- Remove redundant execution-layer validation already guaranteed by
command analysis.
- Remove overlapping mock coverage while retaining real Paimon catalog
atomicity coverage.
- Reduce redundant Spark refresh calls in the Iceberg regression case.
## Example
```sql
ALTER TABLE external_table SET (
'snapshot.num-retained.min' = '3',
'snapshot.num-retained.max' = '8'
);
```
The same syntax supports native Iceberg properties such as
`write.target-file-size-bytes` and `commit.manifest.min-count-to-merge`.
## Coverage
- SQL validation: external multi-property SET, internal-table
compatibility, `auto_analyze_policy` validation, and property-domain
separation.
- Iceberg metadata ops: multi-property commit, single commit, success
refresh, and failure without refresh.
- Paimon metadata ops: persisted options, one atomic alter call, real
filesystem-catalog rejection atomicity, and failure without refresh.
- Iceberg regression: multi-property SET, overwrite/preservation,
Doris-owned property isolation, invalid-update atomicity, and Spark
metadata verification.
- Paimon regression: multi-property SET, overwrite/preservation,
schema-version behavior, Doris-owned property isolation, invalid-update
atomicity, and `$options`/`$schemas` verification.
## Validation
- Targeted FE tests: 78 passed, 0 failures, 0 errors, 0 skipped.
- Maven reactor build: success.
- Checkstyle: 0 violations.
- Both new Groovy regression scripts compile successfully.
- External Iceberg/Paimon regression suites require the
Docker/Spark/MinIO test environment and were not executed locally.
---
.../main/java/org/apache/doris/alter/Alter.java | 7 +-
.../apache/doris/datasource/ExternalCatalog.java | 18 +++
.../datasource/iceberg/IcebergMetadataOps.java | 16 +++
.../datasource/operations/ExternalMetadataOps.java | 14 +++
.../doris/datasource/paimon/PaimonMetadataOps.java | 8 ++
.../commands/info/ModifyTablePropertiesOp.java | 41 +++++--
.../iceberg/IcebergMetadataOpsValidationTest.java | 48 ++++++++
.../datasource/paimon/PaimonMetadataOpsTest.java | 91 ++++++++++++++
.../commands/info/ModifyTablePropertiesOpTest.java | 100 +++++++++++++++
.../iceberg/test_iceberg_alter_properties.groovy | 114 ++++++++++++++++++
.../paimon/test_paimon_alter_properties.groovy | 134 +++++++++++++++++++++
11 files changed, 580 insertions(+), 11 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
index 62e5ab6ba53..3e07bb20e98 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
@@ -399,7 +399,12 @@ public class Alter {
long updateTime = System.currentTimeMillis();
for (AlterClause alterClause : alterClauses) {
if (alterClause instanceof ModifyTablePropertiesClause) {
- setExternalTableAutoAnalyzePolicy(table, alterClauses);
+ Map<String, String> properties = alterClause.getProperties();
+ if
(properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
+ setExternalTableAutoAnalyzePolicy(table, alterClauses);
+ } else {
+ table.getCatalog().updateTableProperties(table,
properties);
+ }
} else if (alterClause instanceof CreateOrReplaceBranchClause) {
table.getCatalog().createOrReplaceBranch(
table,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
index d7314efa46f..2c31414a99f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
@@ -1546,6 +1546,24 @@ public abstract class ExternalCatalog
dorisTable.getDbName(), dorisTable.getName(),
updateTime));
}
+ public void updateTableProperties(TableIf dorisTable, Map<String, String>
properties) throws UserException {
+ makeSureInitialized();
+ Preconditions.checkState(dorisTable instanceof ExternalTable,
dorisTable.getName());
+ ExternalTable externalTable = (ExternalTable) dorisTable;
+ if (metadataOps == null) {
+ throw new DdlException("Update table properties operation is not
supported for catalog: " + getName());
+ }
+ try {
+ long updateTime = System.currentTimeMillis();
+ metadataOps.updateTableProperties(externalTable, properties,
updateTime);
+ logRefreshExternalTable(externalTable, updateTime);
+ } catch (Exception e) {
+ LOG.warn("Failed to update properties for table {}.{} in catalog
{}",
+ externalTable.getDbName(), externalTable.getName(),
getName(), e);
+ throw e;
+ }
+ }
+
@Override
public void addColumn(TableIf dorisTable, Column column, ColumnPosition
position) throws UserException {
addColumn(dorisTable, ColumnPath.of(column.getName()), column,
position);
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 0635e0a9a0c..f0546bcb9c2 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
@@ -64,6 +64,7 @@ import org.apache.iceberg.SnapshotRef;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableProperties;
import org.apache.iceberg.UpdatePartitionSpec;
+import org.apache.iceberg.UpdateProperties;
import org.apache.iceberg.UpdateSchema;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
@@ -865,6 +866,21 @@ public class IcebergMetadataOps implements
ExternalMetadataOps {
refreshTable(dorisTable, updateTime);
}
+ @Override
+ public void updateTableProperties(ExternalTable dorisTable, Map<String,
String> properties, long updateTime)
+ throws UserException {
+ Table icebergTable = IcebergUtils.getIcebergTable(dorisTable);
+ UpdateProperties updateProperties = icebergTable.updateProperties();
+ properties.forEach(updateProperties::set);
+ try {
+ executionAuthenticator.execute(updateProperties::commit);
+ } catch (Exception e) {
+ throw new UserException("Failed to update properties for table: "
+ icebergTable.name()
+ + ", error message is: " + e.getMessage(), e);
+ }
+ refreshTable(dorisTable, updateTime);
+ }
+
@Override
public void renameColumn(ExternalTable dorisTable, String oldName, String
newName, long updateTime)
throws UserException {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
index 13b3d9fea41..9ba7e4e3f53 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/operations/ExternalMetadataOps.java
@@ -293,6 +293,20 @@ public interface ExternalMetadataOps {
throw new UnsupportedOperationException("Nested rename column
operation is not supported for this table type.");
}
+ /**
+ * update properties for external table
+ *
+ * @param dorisTable external table
+ * @param properties properties to update
+ * @param updateTime update time used to refresh FE metadata
+ * @throws UserException if the update fails
+ */
+ default void updateTableProperties(ExternalTable dorisTable, Map<String,
String> properties, long updateTime)
+ throws UserException {
+ throw new UnsupportedOperationException(
+ "Update table properties operation is not supported for this
table type.");
+ }
+
/**
* update column for external table
*
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
index 016831e26aa..ace93f14655 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonMetadataOps.java
@@ -530,6 +530,14 @@ public class PaimonMetadataOps implements
ExternalMetadataOps {
}
}
+ @Override
+ public void updateTableProperties(ExternalTable dorisTable, Map<String,
String> properties, long updateTime)
+ throws UserException {
+ List<SchemaChange> changes = new ArrayList<>(properties.size());
+ properties.forEach((key, value) ->
changes.add(SchemaChange.setOption(key, value)));
+ alterTable(dorisTable, changes, "set properties", updateTime);
+ }
+
@Override
public void addColumn(ExternalTable dorisTable, Column column,
ColumnPosition position, long updateTime)
throws UserException {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
index cbb4f68e78d..7a364042cc5 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOp.java
@@ -76,6 +76,11 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
throw new AnalysisException("Properties is not set");
}
+ if (tableName != null &&
!InternalCatalog.INTERNAL_CATALOG_NAME.equals(tableName.getCtl())) {
+ validateExternalTableProperties();
+ return;
+ }
+
if (properties.size() != 1
&& !TableProperty.isSamePrefixProperties(
properties,
DynamicPartitionProperty.DYNAMIC_PARTITION_PROPERTY_PREFIX)
@@ -359,16 +364,7 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
} else if
(properties.containsKey(PropertyAnalyzer.PROPERTIES_ROW_STORE_COLUMNS)) {
// do nothing, will be analyzed when creating alter job
} else if
(properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
- String analyzePolicy =
properties.getOrDefault(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY, "");
- if (analyzePolicy != null
- &&
!analyzePolicy.equals(PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY)
- &&
!analyzePolicy.equals(PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY)
- &&
!analyzePolicy.equals(PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY)) {
- throw new AnalysisException(
- "Table auto analyze policy only support for " +
PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY
- + " or " +
PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY
- + " or " +
PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY);
- }
+ validateAutoAnalyzePolicy();
this.needTableStable = false;
this.opType = AlterOpType.MODIFY_TABLE_PROPERTY_SYNC;
} else if
(properties.containsKey(PropertyAnalyzer.ENABLE_UNIQUE_KEY_SKIP_BITMAP_COLUMN))
{
@@ -385,6 +381,31 @@ public class ModifyTablePropertiesOp extends AlterTableOp {
analyzeForMTMV();
}
+ private void validateExternalTableProperties() throws AnalysisException {
+ this.needTableStable = false;
+ this.opType = AlterOpType.MODIFY_TABLE_PROPERTY_SYNC;
+ if
(!properties.containsKey(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY)) {
+ return;
+ }
+ if (properties.size() != 1) {
+ throw new AnalysisException("auto_analyze_policy cannot be set
with external table properties");
+ }
+ validateAutoAnalyzePolicy();
+ }
+
+ private void validateAutoAnalyzePolicy() throws AnalysisException {
+ String analyzePolicy =
properties.getOrDefault(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY, "");
+ if (analyzePolicy != null
+ &&
!analyzePolicy.equals(PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY)
+ &&
!analyzePolicy.equals(PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY)
+ &&
!analyzePolicy.equals(PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY)) {
+ throw new AnalysisException(
+ "Table auto analyze policy only support for " +
PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY
+ + " or " +
PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY
+ + " or " +
PropertyAnalyzer.USE_CATALOG_AUTO_ANALYZE_POLICY);
+ }
+ }
+
@Override
public AlterTableClause translateToLegacyAlterClause() {
return new ModifyTablePropertiesClause(properties, storagePolicy,
isBeingSynced, needTableStable, opType);
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
index de3a20eca59..b0b6878d248 100644
---
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
@@ -36,6 +36,7 @@ import org.apache.iceberg.CatalogProperties;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.UpdateProperties;
import org.apache.iceberg.UpdateSchema;
import org.apache.iceberg.catalog.Catalog;
import org.apache.iceberg.catalog.Namespace;
@@ -180,6 +181,53 @@ public class IcebergMetadataOpsValidationTest {
Mockito.verify(icebergTable, Mockito.never()).updateSchema();
}
+ @Test
+ public void testUpdateTablePropertiesCommitsAllProperties() throws
Exception {
+ ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+ Table icebergTable = Mockito.mock(Table.class);
+ UpdateProperties updateProperties =
Mockito.mock(UpdateProperties.class);
+
Mockito.when(icebergTable.updateProperties()).thenReturn(updateProperties);
+ Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db");
+
+ Map<String, String> properties = new HashMap<>();
+ properties.put("write.target-file-size-bytes", "134217728");
+ properties.put("commit.manifest.min-count-to-merge", "50");
+
+ try (MockedStatic<IcebergUtils> mockedIcebergUtils =
+ Mockito.mockStatic(IcebergUtils.class,
Mockito.CALLS_REAL_METHODS)) {
+ mockedIcebergUtils.when(() ->
IcebergUtils.getIcebergTable(dorisTable)).thenReturn(icebergTable);
+
+ ops.updateTableProperties(dorisTable, properties, 123L);
+ }
+
+ Mockito.verify(updateProperties).set("write.target-file-size-bytes",
"134217728");
+
Mockito.verify(updateProperties).set("commit.manifest.min-count-to-merge",
"50");
+ Mockito.verify(updateProperties).commit();
+ Mockito.verify(dorisCatalog).getDbForReplay("db");
+ }
+
+ @Test
+ public void testUpdateTablePropertiesDoesNotRefreshAfterCommitFailure() {
+ ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+ Table icebergTable = Mockito.mock(Table.class);
+ UpdateProperties updateProperties =
Mockito.mock(UpdateProperties.class);
+
Mockito.when(icebergTable.updateProperties()).thenReturn(updateProperties);
+ Mockito.when(icebergTable.name()).thenReturn("db.tbl");
+ Mockito.when(dorisTable.getRemoteDbName()).thenReturn("db");
+ Mockito.doThrow(new RuntimeException("commit
failed")).when(updateProperties).commit();
+
+ try (MockedStatic<IcebergUtils> mockedIcebergUtils =
+ Mockito.mockStatic(IcebergUtils.class,
Mockito.CALLS_REAL_METHODS)) {
+ mockedIcebergUtils.when(() ->
IcebergUtils.getIcebergTable(dorisTable)).thenReturn(icebergTable);
+
+ assertUserException(() -> ops.updateTableProperties(
+ dorisTable,
Collections.singletonMap("write.target-file-size-bytes", "134217728"), 123L),
+ "commit failed");
+ }
+
+ Mockito.verify(dorisCatalog,
Mockito.never()).getDbForReplay(Mockito.anyString());
+ }
+
@Test
public void testValidateForModifyColumnRejectsComplexToPrimitive() {
Column column = new Column("struct_col", Type.INT, true);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
index 3135df2541f..79b7d48e5c2 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/PaimonMetadataOpsTest.java
@@ -21,9 +21,11 @@ import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.UserException;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
import org.apache.doris.datasource.CatalogFactory;
import org.apache.doris.datasource.ExternalCatalog;
import org.apache.doris.datasource.ExternalDatabase;
+import org.apache.doris.datasource.ExternalTable;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand;
import org.apache.doris.nereids.trees.plans.commands.CreateTableCommand;
@@ -37,6 +39,7 @@ import org.apache.paimon.catalog.FileSystemCatalog;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.hive.HiveCatalog;
import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.table.Table;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.DataField;
@@ -51,13 +54,16 @@ import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -136,6 +142,91 @@ public class PaimonMetadataOpsTest {
Assert.assertEquals(1, table.primaryKeys().size());
}
+ @Test
+ public void testUpdateTablePropertiesPersistsAllOptions() throws Exception
{
+ String tableName = getTableName();
+ Identifier identifier = new Identifier(dbName, tableName);
+ createTable("create table " + dbName + "." + tableName + " (id int)
engine = paimon");
+
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog,
ops.getCatalog());
+ ExternalTable dorisTable = mockExternalTable(tableName);
+ Map<String, String> properties = new LinkedHashMap<>();
+ properties.put("snapshot.num-retained.min", "2");
+ properties.put("snapshot.num-retained.max", "5");
+
+ propertyOps.updateTableProperties(dorisTable, properties, 123L);
+
+ Map<String, String> actualOptions =
ops.getCatalog().getTable(identifier).options();
+ Assert.assertEquals("2",
actualOptions.get("snapshot.num-retained.min"));
+ Assert.assertEquals("5",
actualOptions.get("snapshot.num-retained.max"));
+ Mockito.verify(dorisCatalog).getDbForReplay(dbName);
+ }
+
+ @Test
+ public void testUpdateTablePropertiesUsesOneAtomicAlter() throws Exception
{
+ String tableName = getTableName();
+ Identifier identifier = new Identifier(dbName, tableName);
+ Catalog remoteCatalog = Mockito.mock(Catalog.class);
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog,
remoteCatalog);
+ Map<String, String> properties = new LinkedHashMap<>();
+ properties.put("snapshot.num-retained.min", "2");
+ properties.put("snapshot.num-retained.max", "5");
+
+ propertyOps.updateTableProperties(mockExternalTable(tableName),
properties, 123L);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<List<SchemaChange>> changesCaptor =
ArgumentCaptor.forClass(List.class);
+ Mockito.verify(remoteCatalog).alterTable(Mockito.eq(identifier),
changesCaptor.capture(), Mockito.eq(false));
+ Assert.assertEquals(
+ java.util.Arrays.asList(
+ SchemaChange.setOption("snapshot.num-retained.min",
"2"),
+ SchemaChange.setOption("snapshot.num-retained.max",
"5")),
+ changesCaptor.getValue());
+ }
+
+ @Test
+ public void testUpdateTablePropertiesRejectsInvalidBatchAtomically()
throws Exception {
+ String tableName = getTableName();
+ Identifier identifier = new Identifier(dbName, tableName);
+ createTable("create table " + dbName + "." + tableName
+ + " (id int not null, seq bigint) engine = paimon "
+ + "properties ('primary-key' = 'id',
'snapshot.num-retained.min' = '2', "
+ + "'snapshot.num-retained.max' = '5')");
+
+ ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+ PaimonMetadataOps propertyOps = newMetadataOps(dorisCatalog,
ops.getCatalog());
+ Map<String, String> properties = new LinkedHashMap<>();
+ properties.put("fields.missing.sequence-group", "seq");
+ properties.put("snapshot.num-retained.max", "10");
+
+ UserException exception = Assert.assertThrows(UserException.class,
+ () ->
propertyOps.updateTableProperties(mockExternalTable(tableName), properties,
123L));
+
+
Assert.assertTrue(exception.getMessage().toLowerCase().contains("missing"));
+ ops.getCatalog().invalidateTable(identifier);
+ Map<String, String> actualOptions =
ops.getCatalog().getTable(identifier).options();
+ Assert.assertEquals("5",
actualOptions.get("snapshot.num-retained.max"));
+
Assert.assertFalse(actualOptions.containsKey("fields.missing.sequence-group"));
+ Mockito.verify(dorisCatalog,
Mockito.never()).getDbForReplay(Mockito.anyString());
+ }
+
+ private PaimonMetadataOps newMetadataOps(ExternalCatalog dorisCatalog,
Catalog remoteCatalog) {
+ Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new
ExecutionAuthenticator() {
+ });
+ return new PaimonMetadataOps(dorisCatalog, remoteCatalog);
+ }
+
+ private ExternalTable mockExternalTable(String tableName) {
+ ExternalTable dorisTable = Mockito.mock(ExternalTable.class);
+ Mockito.when(dorisTable.getDbName()).thenReturn(dbName);
+ Mockito.when(dorisTable.getRemoteDbName()).thenReturn(dbName);
+ Mockito.when(dorisTable.getName()).thenReturn(tableName);
+ Mockito.when(dorisTable.getRemoteName()).thenReturn(tableName);
+ return dorisTable;
+ }
+
@Test
public void testType() throws Exception {
String tableName = getTableName();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
new file mode 100644
index 00000000000..a13e530e6d6
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyTablePropertiesOpTest.java
@@ -0,0 +1,100 @@
+// 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.nereids.trees.plans.commands.info;
+
+import org.apache.doris.alter.AlterOpType;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.info.TableNameInfo;
+import org.apache.doris.qe.ConnectContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class ModifyTablePropertiesOpTest {
+
+ @Test
+ public void testExternalTablePropertiesAllowMultipleConnectorOptions()
throws Exception {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("snapshot.num-retained.min", "2");
+ properties.put("snapshot.num-retained.max", "5");
+ ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+ op.setTableName(new TableNameInfo("paimon", "db", "tbl"));
+
+ op.validate(new ConnectContext());
+
+ Assertions.assertEquals(AlterOpType.MODIFY_TABLE_PROPERTY_SYNC,
+ op.translateToLegacyAlterClause().getOpType());
+ }
+
+ @Test
+ public void testInternalTableStillRejectsMultipleUnrelatedProperties() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put("snapshot.num-retained.min", "2");
+ properties.put("snapshot.num-retained.max", "5");
+ ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+ op.setTableName(new
TableNameInfo(InternalCatalog.INTERNAL_CATALOG_NAME, "db", "tbl"));
+
+ AnalysisException exception = Assertions.assertThrows(
+ AnalysisException.class, () -> op.validate(new
ConnectContext()));
+ Assertions.assertTrue(exception.getMessage().contains("Can only set
one table property"));
+ }
+
+ @Test
+ public void
testExternalAutoAnalyzePolicyCannotBeMixedWithConnectorOptions() {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
+ PropertyAnalyzer.ENABLE_AUTO_ANALYZE_POLICY);
+ properties.put("write.target-file-size-bytes", "134217728");
+ ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(properties);
+ op.setTableName(new TableNameInfo("iceberg", "db", "tbl"));
+
+ AnalysisException exception = Assertions.assertThrows(
+ AnalysisException.class, () -> op.validate(new
ConnectContext()));
+ Assertions.assertTrue(exception.getMessage().contains("cannot be set
with external table properties"));
+ }
+
+ @Test
+ public void testExternalAutoAnalyzePolicyIsValidated() {
+ ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(
+
java.util.Collections.singletonMap(PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
"invalid"));
+ op.setTableName(new TableNameInfo("iceberg", "db", "tbl"));
+
+ AnalysisException exception = Assertions.assertThrows(
+ AnalysisException.class, () -> op.validate(new
ConnectContext()));
+ Assertions.assertTrue(exception.getMessage().contains("Table auto
analyze policy only support"));
+ }
+
+ @Test
+ public void testExternalAutoAnalyzePolicyKeepsDorisPropertyPath() throws
Exception {
+ ModifyTablePropertiesOp op = new ModifyTablePropertiesOp(
+ java.util.Collections.singletonMap(
+ PropertyAnalyzer.PROPERTIES_AUTO_ANALYZE_POLICY,
+ PropertyAnalyzer.DISABLE_AUTO_ANALYZE_POLICY));
+ op.setTableName(new TableNameInfo("paimon", "db", "tbl"));
+
+ op.validate(new ConnectContext());
+
+ Assertions.assertEquals(AlterOpType.MODIFY_TABLE_PROPERTY_SYNC,
+ op.translateToLegacyAlterClause().getOpType());
+ }
+}
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
new file mode 100644
index 00000000000..51778aababd
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_alter_properties.groovy
@@ -0,0 +1,114 @@
+// 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_alter_properties", "p0,external") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("Iceberg test is not enabled, skip this test")
+ return
+ }
+
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String catalogName = "test_iceberg_alter_properties"
+ String dbName = "test_iceberg_alter_properties_db"
+ String tableName = "iceberg_alter_properties"
+
+ def refreshSparkTable = {
+ spark_iceberg """REFRESH TABLE demo.${dbName}.${tableName}"""
+ }
+ def sparkProperty = { String key ->
+ List<List<Object>> rows = spark_iceberg """
+ SHOW TBLPROPERTIES demo.${dbName}.${tableName} ('${key}')
+ """
+ assertEquals(1, rows.size())
+ return rows[0][1].toString()
+ }
+
+ sql """DROP CATALOG IF EXISTS ${catalogName}"""
+ sql """
+ CREATE CATALOG ${catalogName} PROPERTIES (
+ 'type' = 'iceberg',
+ 'iceberg.catalog.type' = 'rest',
+ 'uri' = 'http://${externalEnvIp}:${restPort}',
+ 's3.access_key' = 'admin',
+ 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+ 's3.region' = 'us-east-1'
+ )
+ """
+ sql """SWITCH ${catalogName}"""
+ sql """DROP DATABASE IF EXISTS ${dbName} FORCE"""
+ sql """CREATE DATABASE ${dbName}"""
+ sql """USE ${dbName}"""
+
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ sql """CREATE TABLE ${tableName} (id INT) PROPERTIES ('format-version' =
'2')"""
+
+ // Multiple connector properties are committed together.
+ sql """
+ ALTER TABLE ${tableName} SET (
+ 'write.target-file-size-bytes' = '134217728',
+ 'commit.manifest.min-count-to-merge' = '50'
+ )
+ """
+ refreshSparkTable()
+ assertEquals("134217728", sparkProperty("write.target-file-size-bytes"))
+ assertEquals("50", sparkProperty("commit.manifest.min-count-to-merge"))
+
+ // A later SET replaces the selected property and preserves unrelated ones.
+ sql """
+ ALTER TABLE ${tableName} SET (
+ 'write.target-file-size-bytes' = '268435456'
+ )
+ """
+ refreshSparkTable()
+ assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+ assertEquals("50", sparkProperty("commit.manifest.min-count-to-merge"))
+
+ // Doris-owned properties stay on the Doris path and never reach Iceberg.
+ sql """ALTER TABLE ${tableName} SET ('auto_analyze_policy' = 'disable')"""
+ refreshSparkTable()
+ assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+ test {
+ sql """
+ ALTER TABLE ${tableName} SET (
+ 'auto_analyze_policy' = 'enable',
+ 'write.target-file-size-bytes' = '536870912'
+ )
+ """
+ exception "auto_analyze_policy cannot be set with external table
properties"
+ }
+ refreshSparkTable()
+ assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+
+ // Iceberg validates the whole UpdateProperties commit. A rejected format
+ // downgrade must not commit the otherwise valid size change.
+ test {
+ sql """
+ ALTER TABLE ${tableName} SET (
+ 'format-version' = '1',
+ 'write.target-file-size-bytes' = '536870912'
+ )
+ """
+ exception "Cannot downgrade"
+ }
+ refreshSparkTable()
+ assertEquals("2", sparkProperty("format-version"))
+ assertEquals("268435456", sparkProperty("write.target-file-size-bytes"))
+}
diff --git
a/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
b/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
new file mode 100644
index 00000000000..69694b6f16d
--- /dev/null
+++
b/regression-test/suites/external_table_p0/paimon/test_paimon_alter_properties.groovy
@@ -0,0 +1,134 @@
+// 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_paimon_alter_properties", "p0,external,paimon") {
+ String enabled = context.config.otherConfigs.get("enablePaimonTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable paimon test.")
+ return
+ }
+
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String catalogName = "test_paimon_alter_properties"
+ String dbName = "test_paimon_alter_properties_db"
+ String tableName = "paimon_alter_properties"
+
+ def schemaId = {
+ return (sql """
+ SELECT MAX(schema_id) FROM `${tableName}\$schemas`
+ """)[0][0] as long
+ }
+ def optionRows = { String key ->
+ return sql("""
+ SELECT value
+ FROM `${tableName}\$options`
+ WHERE `key` = '${key}'
+ """)
+ }
+ def optionValue = { String key ->
+ List<List<Object>> rows = optionRows(key)
+ assertEquals(1, rows.size())
+ return rows[0][0].toString()
+ }
+
+ sql """DROP CATALOG IF EXISTS `${catalogName}`"""
+ sql """
+ CREATE CATALOG `${catalogName}` PROPERTIES (
+ 'type' = 'paimon',
+ 'paimon.catalog.type' = 'filesystem',
+ 'warehouse' = 's3://warehouse/wh',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+ 's3.access_key' = 'admin',
+ 's3.secret_key' = 'password',
+ 's3.path.style.access' = 'true'
+ )
+ """
+ sql """SWITCH `${catalogName}`"""
+ sql """DROP DATABASE IF EXISTS `${dbName}` FORCE"""
+ sql """CREATE DATABASE `${dbName}`"""
+ sql """USE `${dbName}`"""
+ sql """DROP TABLE IF EXISTS `${tableName}`"""
+ sql """
+ CREATE TABLE `${tableName}` (
+ id INT NOT NULL,
+ seq BIGINT NULL,
+ payload STRING NULL
+ ) ENGINE=paimon
+ PROPERTIES (
+ 'primary-key' = 'id',
+ 'snapshot.num-retained.min' = '2',
+ 'snapshot.num-retained.max' = '5'
+ )
+ """
+
+ // One Doris statement becomes one atomic Paimon schema change containing
+ // every SetOption. The refreshed system tables are visible immediately.
+ long beforeSchemaId = schemaId()
+ sql """
+ ALTER TABLE `${tableName}` SET (
+ 'snapshot.num-retained.min' = '3',
+ 'snapshot.num-retained.max' = '6'
+ )
+ """
+ assertEquals(beforeSchemaId + 1, schemaId())
+ assertEquals("3", optionValue("snapshot.num-retained.min"))
+ assertEquals("6", optionValue("snapshot.num-retained.max"))
+
+ // Updating one option replaces it without removing the other option.
+ beforeSchemaId = schemaId()
+ sql """
+ ALTER TABLE `${tableName}` SET (
+ 'snapshot.num-retained.max' = '8'
+ )
+ """
+ assertEquals(beforeSchemaId + 1, schemaId())
+ assertEquals("3", optionValue("snapshot.num-retained.min"))
+ assertEquals("8", optionValue("snapshot.num-retained.max"))
+
+ // auto_analyze_policy is Doris metadata, so it does not create a Paimon
+ // schema version. Mixing the two property domains is rejected up front.
+ beforeSchemaId = schemaId()
+ sql """ALTER TABLE `${tableName}` SET ('auto_analyze_policy' =
'disable')"""
+ assertEquals(beforeSchemaId, schemaId())
+ test {
+ sql """
+ ALTER TABLE `${tableName}` SET (
+ 'auto_analyze_policy' = 'enable',
+ 'snapshot.num-retained.max' = '9'
+ )
+ """
+ exception "auto_analyze_policy cannot be set with external table
properties"
+ }
+ assertEquals(beforeSchemaId, schemaId())
+ assertEquals("8", optionValue("snapshot.num-retained.max"))
+
+ // Paimon validates all SetOption changes before committing the next
schema.
+ // A bad field-scoped option must not leak the valid max-retention update.
+ test {
+ sql """
+ ALTER TABLE `${tableName}` SET (
+ 'fields.missing.sequence-group' = 'seq',
+ 'snapshot.num-retained.max' = '10'
+ )
+ """
+ exception "missing"
+ }
+ assertEquals(beforeSchemaId, schemaId())
+ assertEquals("8", optionValue("snapshot.num-retained.max"))
+ assertTrue(optionRows("fields.missing.sequence-group").isEmpty())
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]