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

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 848c067af0 [#12768] feat(clickhouse): support altering table settings 
(#12772)
848c067af0 is described below

commit 848c067af0d51d772850e0f1ff630d7641f6d68f
Author: StormSpirit <[email protected]>
AuthorDate: Fri Sep 18 23:10:04 2026 +0800

    [#12768] feat(clickhouse): support altering table settings (#12772)
    
    ### What changes were proposed in this pull request?
    
    This change adds ALTER support for ClickHouse table-level settings
    exposed through the existing `settings.*` table-property namespace.
    
    - Map settings-only `TableChange.SetProperty` requests to `MODIFY
    SETTING` and settings-only `TableChange.RemoveProperty` requests to
    `RESET SETTING`.
    - Validate setting requests before opening a JDBC connection, reject
    duplicate or unsupported command-family mixtures, and serialize settings
    in deterministic key order.
    - Validate setting identifiers and scalar literals while leaving
    concrete setting mutability, range, and permission checks to ClickHouse.
    - Preserve trusted `ON CLUSTER` behavior and avoid logging setting
    values.
    - Document the supported property contract and its scope boundaries.
    
    ### Why are the changes needed?
    
    The ClickHouse catalog already loads table settings into `settings.*`
    properties and writes them during CREATE TABLE, but ALTER rejects
    attempts to set or remove those properties. Supporting MODIFY and RESET
    completes the table-settings lifecycle without requiring users to bypass
    Gravitino or recreate tables.
    
    Fix: #12768
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Users can modify or reset table-level ClickHouse `settings.*`
    properties through `alterTable`. Each request must contain only set
    operations or only remove operations; settings cannot be mixed with
    schema, comment, or index changes. Numeric and boolean values are
    unquoted ClickHouse scalar literals, while string values must be valid
    single-quoted literals. Non-`settings.*` table properties remain
    immutable.
    
    Column-level SETTINGS and quoted-comma SETTINGS load/recreate parsing
    are out of scope; this PR also makes no new ReplicatedMergeTree
    compatibility claim.
    
    ### How was this patch tested?
    
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test -PskipITs` —
    passed; 92 tests, 0 skipped, 0 failures, 0 errors.
    - `CatalogClickHouseIT.testAlterTableSettings` — passed against
    ClickHouse 24.8.14.39, covering numeric and string modify/load/reset
    plus `READONLY_SETTING` and `UNKNOWN_SETTING` propagation.
    - `CatalogClickHouseIT.testAlterTableSettingReadOnlyConnectionError` —
    passed against ClickHouse 24.8.14.39 with ClickHouse JDBC 0.7.1
    `custom_settings=readonly%3D1`.
    - `CatalogClickHouseClusterIT.testAlterTableSettingsOnCluster` — passed
    against the project cluster fixture; `system.query_log` verified that
    both MODIFY and RESET SQL contain `ON CLUSTER` (`tests=1`, `skipped=0`,
    `failures=0`, `errors=0`).
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:spotlessCheck` —
    passed.
    - `./gradlew rat` — passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:build -x test` —
    passed.
    - A local-only supplemental Gravitino precheck completed without errors.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
---
 .../operations/ClickHouseTableOperations.java      | 172 ++++++++++++++--
 .../test/CatalogClickHouseClusterIT.java           |  62 ++++++
 .../integration/test/CatalogClickHouseIT.java      | 124 ++++++++++++
 .../operations/TestClickHouseTableOperations.java  |   2 +-
 .../TestClickHouseTableOperationsUnit.java         | 221 +++++++++++++++++++++
 docs/jdbc-clickhouse-catalog.md                    |  38 ++--
 6 files changed, 589 insertions(+), 30 deletions(-)

diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
index b6f566a4e0..148172cefe 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
@@ -118,6 +118,11 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   /** Matches ClickHouse wide integer type names (Int128/256, UInt128/256, and 
future variants). */
   private static final Pattern WIDE_INTEGER_PATTERN = 
Pattern.compile("^U?INT\\d+$");
 
+  private static final Pattern TABLE_SETTING_NAME_PATTERN =
+      Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
+  private static final Pattern NUMERIC_SETTING_LITERAL_PATTERN =
+      Pattern.compile("^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$");
+
   private static final String QUERY_INDEXES_SQL =
       """
       SELECT NULL AS TABLE_CAT,
@@ -695,6 +700,7 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   @Override
   public void alterTable(String databaseName, String tableName, TableChange... 
changes)
       throws NoSuchTableException {
+    validateTableSettingChanges(changes);
     LOG.info("Attempting to alter table {} from database {}", tableName, 
databaseName);
     try (Connection connection = getConnection(databaseName)) {
       String sql = generateAlterTableSql(databaseName, tableName, changes);
@@ -1035,20 +1041,21 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     JdbcTable lazyLoadTable = null;
     TableChange.UpdateComment updateComment = null;
     List<TableChange.SetProperty> setProperties = new ArrayList<>();
+    List<TableChange.RemoveProperty> removeProperties = new ArrayList<>();
     List<String> alterSql = new ArrayList<>();
 
+    collectAndValidateTableSettingChanges(changes, setProperties, 
removeProperties);
+
     for (TableChange change : changes) {
       if (change instanceof TableChange.UpdateComment) {
         updateComment = (TableChange.UpdateComment) change;
 
-      } else if (change instanceof TableChange.SetProperty setProperty) {
-        // The set attribute needs to be added at the end.
-        setProperties.add(setProperty);
-
-      } else if (change instanceof TableChange.RemoveProperty) {
-        // Clickhouse does not support deleting table attributes, it can be 
replaced by Set Property
-        throw new UnsupportedOperationException(
-            "Remove property for ClickHouse is not supported yet");
+      } else if (change instanceof TableChange.SetProperty
+          || change instanceof TableChange.RemoveProperty) {
+        // Table setting changes were fully validated before any table 
metadata was loaded. They are
+        // rendered together after the other change branches, which are 
mutually exclusive with
+        // settings changes.
+        continue;
 
       } else if (change instanceof TableChange.AddColumn addColumn) {
         lazyLoadTable = getOrCreateTable(databaseName, tableName, 
lazyLoadTable);
@@ -1130,8 +1137,8 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       alterSql.add(" MODIFY COMMENT 
'%s'".formatted(escapeSingleQuotes(newComment)));
     }
 
-    if (!setProperties.isEmpty()) {
-      alterSql.add(generateAlterTableProperties(setProperties));
+    if (!setProperties.isEmpty() || !removeProperties.isEmpty()) {
+      alterSql.add(generateAlterTableProperties(setProperties, 
removeProperties));
     }
 
     // Remove all empty SQL statements
@@ -1163,7 +1170,15 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
           "ALTER TABLE %s \n%s;"
               .formatted(quoteIdentifier(tableName), String.join(",\n", 
nonEmptySQLs));
     }
-    LOG.info("Generated alter table:{} sql: {}", databaseName + "." + 
tableName, result);
+    if (!setProperties.isEmpty() || !removeProperties.isEmpty()) {
+      LOG.info(
+          "Generated alter table settings for {}.{} with keys {}",
+          databaseName,
+          tableName,
+          tableSettingNames(setProperties, removeProperties));
+    } else {
+      LOG.info("Generated alter table:{} sql: {}", databaseName + "." + 
tableName, result);
+    }
     return result;
   }
 
@@ -1282,15 +1297,144 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
             appendColumnDefinition(updateColumn, new StringBuilder()));
   }
 
-  private String generateAlterTableProperties(List<TableChange.SetProperty> 
setProperties) {
-    if (CollectionUtils.isNotEmpty(setProperties)) {
+  private static void collectAndValidateTableSettingChanges(
+      TableChange[] changes,
+      List<TableChange.SetProperty> setProperties,
+      List<TableChange.RemoveProperty> removeProperties) {
+    for (TableChange change : changes) {
+      if (change instanceof TableChange.SetProperty setProperty) {
+        tableSettingName(setProperty.getProperty());
+        validateTableSettingLiteral(setProperty.getValue());
+        setProperties.add(setProperty);
+      } else if (change instanceof TableChange.RemoveProperty removeProperty) {
+        tableSettingName(removeProperty.getProperty());
+        removeProperties.add(removeProperty);
+      }
+    }
+
+    int settingChangeCount = setProperties.size() + removeProperties.size();
+    if (settingChangeCount == 0) {
+      return;
+    }
+
+    if (settingChangeCount != changes.length) {
+      throw new UnsupportedOperationException(
+          "ClickHouse table setting changes cannot be mixed with other table 
changes");
+    }
+    if (!setProperties.isEmpty() && !removeProperties.isEmpty()) {
+      throw new UnsupportedOperationException(
+          "ClickHouse MODIFY SETTING and RESET SETTING cannot be combined in 
one request");
+    }
+
+    validateNoDuplicateTableSettings(
+        setProperties.stream()
+            .map(change -> tableSettingName(change.getProperty()))
+            .collect(Collectors.toList()));
+    validateNoDuplicateTableSettings(
+        removeProperties.stream()
+            .map(change -> tableSettingName(change.getProperty()))
+            .collect(Collectors.toList()));
+
+    setProperties.sort(Comparator.comparing(change -> 
tableSettingName(change.getProperty())));
+    removeProperties.sort(Comparator.comparing(change -> 
tableSettingName(change.getProperty())));
+  }
+
+  private static void validateTableSettingChanges(TableChange[] changes) {
+    collectAndValidateTableSettingChanges(changes, new ArrayList<>(), new 
ArrayList<>());
+  }
+
+  private static void validateNoDuplicateTableSettings(List<String> 
settingNames) {
+    Set<String> uniqueNames = new HashSet<>();
+    for (String settingName : settingNames) {
+      Preconditions.checkArgument(
+          uniqueNames.add(settingName), "Duplicate ClickHouse table setting: 
%s", settingName);
+    }
+  }
+
+  private static String tableSettingName(String property) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(property), "ClickHouse table setting property 
is required");
+    if (!property.startsWith(TableConstants.SETTINGS_PREFIX)) {
       throw new UnsupportedOperationException(
-          "Alter table properties in ClickHouse is not supported");
+          "Only ClickHouse table properties with the 'settings.' prefix can be 
altered");
+    }
+
+    String settingName = 
property.substring(TableConstants.SETTINGS_PREFIX.length());
+    Preconditions.checkArgument(
+        TABLE_SETTING_NAME_PATTERN.matcher(settingName).matches(),
+        "Invalid ClickHouse table setting name: %s",
+        settingName);
+    return settingName;
+  }
+
+  private static void validateTableSettingLiteral(String value) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(value), "ClickHouse table setting value is 
required");
+    String literal = value.trim();
+    boolean valid =
+        NUMERIC_SETTING_LITERAL_PATTERN.matcher(literal).matches()
+            || "true".equalsIgnoreCase(literal)
+            || "false".equalsIgnoreCase(literal)
+            || isValidQuotedSettingLiteral(literal);
+    Preconditions.checkArgument(valid, "Invalid ClickHouse table setting 
literal");
+  }
+
+  private static boolean isValidQuotedSettingLiteral(String literal) {
+    if (literal.length() < 2
+        || literal.charAt(0) != '\''
+        || literal.charAt(literal.length() - 1) != '\'') {
+      return false;
     }
 
+    for (int i = 1; i < literal.length() - 1; i++) {
+      char current = literal.charAt(i);
+      if (current == '\\') {
+        if (i + 1 >= literal.length() - 1) {
+          return false;
+        }
+        i++;
+      } else if (current == '\'') {
+        if (i + 1 >= literal.length() - 1 || literal.charAt(i + 1) != '\'') {
+          return false;
+        }
+        i++;
+      }
+    }
+    return true;
+  }
+
+  private static String generateAlterTableProperties(
+      List<TableChange.SetProperty> setProperties,
+      List<TableChange.RemoveProperty> removeProperties) {
+    if (!setProperties.isEmpty()) {
+      return setProperties.stream()
+          .map(
+              change ->
+                  "%s = %s"
+                      .formatted(tableSettingName(change.getProperty()), 
change.getValue().trim()))
+          .collect(Collectors.joining(", ", "MODIFY SETTING ", ""));
+    }
+    if (!removeProperties.isEmpty()) {
+      return removeProperties.stream()
+          .map(change -> tableSettingName(change.getProperty()))
+          .collect(Collectors.joining(", ", "RESET SETTING ", ""));
+    }
     return "";
   }
 
+  private static List<String> tableSettingNames(
+      List<TableChange.SetProperty> setProperties,
+      List<TableChange.RemoveProperty> removeProperties) {
+    if (!setProperties.isEmpty()) {
+      return setProperties.stream()
+          .map(change -> tableSettingName(change.getProperty()))
+          .collect(Collectors.toList());
+    }
+    return removeProperties.stream()
+        .map(change -> tableSettingName(change.getProperty()))
+        .collect(Collectors.toList());
+  }
+
   private String updateColumnCommentFieldDefinition(
       TableChange.UpdateColumnComment updateColumnComment, JdbcTable 
jdbcTable) {
     String newComment = updateColumnComment.getNewComment();
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseClusterIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseClusterIT.java
index bd6b34c0ab..0cee91a224 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseClusterIT.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseClusterIT.java
@@ -48,6 +48,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.Schema;
 import org.apache.gravitino.StringIdentifier;
+import 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.TableConstants;
 import 
org.apache.gravitino.catalog.clickhouse.integration.test.service.ClickHouseService;
 import 
org.apache.gravitino.catalog.clickhouse.operations.ClickHouseClusterUtils;
 import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
@@ -1005,6 +1006,48 @@ public class CatalogClickHouseClusterIT extends BaseIT {
     }
   }
 
+  @Test
+  public void testAlterTableSettingsOnCluster() throws Exception {
+    String tableName = 
GravitinoITUtils.genRandomName("ck_alter_settings_cluster");
+    NameIdentifier tableIdentifier = NameIdentifier.of(schemaName, tableName);
+    TableCatalog tableCatalog = catalog.asTableCatalog();
+    tableCatalog.createTable(
+        tableIdentifier,
+        createColumns(),
+        tableComment,
+        clusterMergeTreeProperties(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        getSortOrders("col_3"),
+        Indexes.EMPTY_INDEXES);
+
+    tableCatalog.alterTable(
+        tableIdentifier,
+        TableChange.setProperty(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout", "3600"));
+    Table modified = tableCatalog.loadTable(tableIdentifier);
+    Assertions.assertEquals(
+        "3600",
+        modified.properties().get(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+
+    tableCatalog.alterTable(
+        tableIdentifier,
+        TableChange.removeProperty(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+    Table reset = tableCatalog.loadTable(tableIdentifier);
+    Assertions.assertFalse(
+        reset.properties().containsKey(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+
+    try (Connection connection =
+            DriverManager.getConnection(
+                clickHouseClusterContainer.getJdbcUrl(TEST_DB_NAME),
+                clickHouseClusterContainer.getUsername(),
+                clickHouseClusterContainer.getPassword());
+        Statement statement = connection.createStatement()) {
+      statement.execute("SYSTEM FLUSH LOGS");
+      assertSettingAlterUsesOnCluster(statement, tableName, "MODIFY SETTING");
+      assertSettingAlterUsesOnCluster(statement, tableName, "RESET SETTING");
+    }
+  }
+
   // 
---------------------------------------------------------------------------
   // Shard key validation IT tests
   // 
---------------------------------------------------------------------------
@@ -1257,6 +1300,25 @@ public class CatalogClickHouseClusterIT extends BaseIT {
     }
   }
 
+  private static void assertSettingAlterUsesOnCluster(
+      Statement statement, String tableName, String command) throws 
SQLException {
+    try (ResultSet resultSet =
+        statement.executeQuery(
+            String.format(
+                "SELECT query FROM system.query_log "
+                    + "WHERE type = 'QueryFinish' "
+                    + "AND query_kind = 'Alter' "
+                    + "AND query LIKE '%%`%s`%%' "
+                    + "AND query LIKE '%%%s%%' "
+                    + "ORDER BY event_time DESC LIMIT 1",
+                tableName, command))) {
+      Assertions.assertTrue(resultSet.next(), "Should find " + command + " 
query");
+      String sql = resultSet.getString("query");
+      Assertions.assertTrue(
+          sql.contains("ON CLUSTER"), command + " must include ON CLUSTER, 
actual: " + sql);
+    }
+  }
+
   private void awaitTableStateOnEveryNode(
       String oldTableName, boolean oldTableExists, String newTableName, 
boolean newTableExists) {
     Awaitility.await()
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
index a6619bd95d..855fcd51ef 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
@@ -2882,6 +2882,130 @@ public class CatalogClickHouseIT extends BaseIT {
     metalake.dropCatalog(testCatalogName, true);
   }
 
+  @Test
+  void testAlterTableSettings() {
+    String name = GravitinoITUtils.genRandomName("alter_settings");
+    NameIdentifier ident = NameIdentifier.of(schemaName, name);
+    Column[] columns =
+        new Column[] {
+          Column.of("id", Types.IntegerType.get(), "id", false, false, 
DEFAULT_VALUE_NOT_SET)
+        };
+    TableCatalog tableCatalog = catalog.asTableCatalog();
+    tableCatalog.createTable(
+        ident,
+        columns,
+        "alter settings",
+        createProperties(),
+        Distributions.NONE,
+        getSortOrders("id"));
+
+    tableCatalog.alterTable(
+        ident,
+        TableChange.setProperty(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout", "3600"));
+    Table modified = tableCatalog.loadTable(ident);
+    Assertions.assertEquals(
+        "3600",
+        modified.properties().get(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+
+    tableCatalog.alterTable(
+        ident,
+        TableChange.removeProperty(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+    Table reset = tableCatalog.loadTable(ident);
+    Assertions.assertFalse(
+        reset.properties().containsKey(TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout"));
+
+    tableCatalog.alterTable(
+        ident,
+        TableChange.setProperty(TableConstants.SETTINGS_PREFIX + 
"storage_policy", "'default'"));
+    Table stringModified = tableCatalog.loadTable(ident);
+    Assertions.assertEquals(
+        "'default'",
+        stringModified.properties().get(TableConstants.SETTINGS_PREFIX + 
"storage_policy"));
+    tableCatalog.alterTable(
+        ident, TableChange.removeProperty(TableConstants.SETTINGS_PREFIX + 
"storage_policy"));
+    Table stringReset = tableCatalog.loadTable(ident);
+    Assertions.assertFalse(
+        stringReset.properties().containsKey(TableConstants.SETTINGS_PREFIX + 
"storage_policy"));
+
+    RuntimeException readOnlyException =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () ->
+                tableCatalog.alterTable(
+                    ident,
+                    TableChange.setProperty(
+                        TableConstants.SETTINGS_PREFIX + "index_granularity", 
"4096")));
+    Assertions.assertTrue(
+        readOnlyException.getMessage().contains("READONLY_SETTING"),
+        readOnlyException.getMessage());
+
+    RuntimeException unknownSettingException =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () ->
+                tableCatalog.alterTable(
+                    ident,
+                    TableChange.setProperty(
+                        TableConstants.SETTINGS_PREFIX + 
"gravitino_unknown_setting", "1")));
+    Assertions.assertTrue(
+        unknownSettingException.getMessage().contains("UNKNOWN_SETTING"),
+        unknownSettingException.getMessage());
+  }
+
+  @Test
+  void testAlterTableSettingReadOnlyConnectionError() throws SQLException {
+    String tableName = 
GravitinoITUtils.genRandomName("alter_settings_readonly");
+    NameIdentifier tableIdentifier = NameIdentifier.of(schemaName, tableName);
+    TableCatalog tableCatalog = catalog.asTableCatalog();
+    tableCatalog.createTable(
+        tableIdentifier,
+        new Column[] {
+          Column.of("id", Types.IntegerType.get(), "id", false, false, 
DEFAULT_VALUE_NOT_SET)
+        },
+        "alter settings privilege",
+        createProperties(),
+        Distributions.NONE,
+        getSortOrders("id"));
+
+    String restrictedCatalogName =
+        GravitinoITUtils.genRandomName("alter_settings_restricted_catalog");
+    Map<String, String> catalogProperties = Maps.newHashMap();
+    String jdbcUrl =
+        StringUtils.substring(
+            CLICKHOUSE_CONTAINER.getJdbcUrl(TEST_DB_NAME),
+            0,
+            CLICKHOUSE_CONTAINER.getJdbcUrl(TEST_DB_NAME).lastIndexOf("/"));
+    catalogProperties.put(JdbcConfig.JDBC_URL.getKey(), jdbcUrl + 
"?custom_settings=readonly%3D1");
+    catalogProperties.put(
+        JdbcConfig.JDBC_DRIVER.getKey(), 
CLICKHOUSE_CONTAINER.getDriverClassName(TEST_DB_NAME));
+    catalogProperties.put(JdbcConfig.USERNAME.getKey(), 
CLICKHOUSE_CONTAINER.getUsername());
+    catalogProperties.put(JdbcConfig.PASSWORD.getKey(), 
CLICKHOUSE_CONTAINER.getPassword());
+
+    Catalog restrictedCatalog =
+        metalake.createCatalog(
+            restrictedCatalogName,
+            Catalog.Type.RELATIONAL,
+            provider,
+            "read-only alter settings catalog",
+            catalogProperties);
+    try {
+      RuntimeException readOnlyException =
+          Assertions.assertThrows(
+              RuntimeException.class,
+              () ->
+                  restrictedCatalog
+                      .asTableCatalog()
+                      .alterTable(
+                          tableIdentifier,
+                          TableChange.setProperty(
+                              TableConstants.SETTINGS_PREFIX + 
"merge_with_ttl_timeout", "3600")));
+      Assertions.assertTrue(
+          readOnlyException.getMessage().contains("READONLY"), 
readOnlyException.getMessage());
+    } finally {
+      metalake.dropCatalog(restrictedCatalogName, true);
+    }
+  }
+
   @Test
   void testLoadTableWithSettingsFromNativeSql() {
     String name = GravitinoITUtils.genRandomName("settings_native");
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperations.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperations.java
index c1b00b36b9..088709c1cd 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperations.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperations.java
@@ -174,7 +174,7 @@ public class TestClickHouseTableOperations extends 
TestClickHouse {
     Assertions.assertTrue(
         StringUtils.contains(
             gravitinoRuntimeException.getMessage(),
-            "Alter table properties in ClickHouse is not supported"));
+            "Only ClickHouse table properties with the 'settings.' prefix can 
be altered"));
 
     // delete column
     TABLE_OPERATIONS.alterTable(
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
index 86ee8538fd..736f327c68 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
@@ -29,14 +29,17 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import javax.sql.DataSource;
+import 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.ClusterConstants;
 import 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.TableConstants;
 import 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE;
 import 
org.apache.gravitino.catalog.clickhouse.converter.ClickHouseColumnDefaultValueConverter;
 import 
org.apache.gravitino.catalog.clickhouse.converter.ClickHouseExceptionConverter;
 import 
org.apache.gravitino.catalog.clickhouse.converter.ClickHouseTypeConverter;
 import org.apache.gravitino.catalog.jdbc.JdbcColumn;
+import org.apache.gravitino.catalog.jdbc.JdbcTable;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.FunctionExpression;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -53,6 +56,8 @@ import org.mockito.Mockito;
 public class TestClickHouseTableOperationsUnit {
 
   private static final class ExposedClickHouseTableOperations extends 
ClickHouseTableOperations {
+    private JdbcTable table;
+
     List<Index> callGetIndexes(Connection connection, String databaseName, 
String tableName)
         throws Exception {
       return getIndexes(connection, databaseName, tableName);
@@ -87,6 +92,20 @@ public class TestClickHouseTableOperationsUnit {
           Indexes.EMPTY_INDEXES,
           getSortOrders("id"));
     }
+
+    void setTable(JdbcTable table) {
+      this.table = table;
+    }
+
+    @Override
+    protected JdbcTable getOrCreateTable(
+        String databaseName, String tableName, JdbcTable lazyLoadCreateTable) {
+      return table;
+    }
+
+    String callGenerateAlterTableSql(TableChange... changes) {
+      return generateAlterTableSql("db", "test_table", changes);
+    }
   }
 
   private ExposedClickHouseTableOperations newOps() {
@@ -104,6 +123,29 @@ public class TestClickHouseTableOperationsUnit {
     return ops;
   }
 
+  private ExposedClickHouseTableOperations newAlterOps(Map<String, String> 
properties) {
+    ExposedClickHouseTableOperations ops = newOps();
+    JdbcColumn idColumn =
+        JdbcColumn.builder()
+            .withName("id")
+            .withType(Types.IntegerType.get())
+            .withNullable(false)
+            .build();
+    ops.setTable(
+        JdbcTable.builder()
+            .withName("test_table")
+            .withColumns(new JdbcColumn[] {idColumn})
+            .withIndexes(Indexes.EMPTY_INDEXES)
+            .withProperties(properties)
+            .withTableOperation(null)
+            .build());
+    return ops;
+  }
+
+  private static String settingProperty(String name) {
+    return TableConstants.SETTINGS_PREFIX + name;
+  }
+
   private Map<String, String> loadTableProperties(String engine, String 
engineFull)
       throws Exception {
     PreparedStatement statement = Mockito.mock(PreparedStatement.class);
@@ -848,6 +890,185 @@ public class TestClickHouseTableOperationsUnit {
     Mockito.verify(connection, 
Mockito.times(2)).prepareStatement(Mockito.anyString());
   }
 
+  @Test
+  void testGenerateModifyAndResetTableSettingsSql() {
+    ExposedClickHouseTableOperations ops = newAlterOps(Map.of());
+
+    String modifySql =
+        ops.callGenerateAlterTableSql(
+            TableChange.setProperty(settingProperty("z_setting"), "2"),
+            TableChange.setProperty(settingProperty("a_setting"), "1"));
+    Assertions.assertTrue(
+        modifySql.contains("MODIFY SETTING a_setting = 1, z_setting = 2"), 
modifySql);
+
+    String resetSql =
+        ops.callGenerateAlterTableSql(
+            TableChange.removeProperty(settingProperty("z_setting")),
+            TableChange.removeProperty(settingProperty("a_setting")));
+    Assertions.assertTrue(resetSql.contains("RESET SETTING a_setting, 
z_setting"), resetSql);
+  }
+
+  @Test
+  void testGenerateTableSettingsSqlOnCluster() {
+    ExposedClickHouseTableOperations ops =
+        newAlterOps(
+            Map.of(
+                ClusterConstants.ON_CLUSTER,
+                "true",
+                ClusterConstants.CLUSTER_NAME,
+                "test_cluster"));
+
+    String sql =
+        ops.callGenerateAlterTableSql(
+            TableChange.setProperty(settingProperty("merge_with_ttl_timeout"), 
"3600"));
+
+    Assertions.assertTrue(
+        sql.startsWith("ALTER TABLE `test_table` ON CLUSTER `test_cluster`"), 
sql);
+    Assertions.assertTrue(sql.contains("MODIFY SETTING merge_with_ttl_timeout 
= 3600"), sql);
+  }
+
+  @Test
+  void testAcceptValidTableSettingLiterals() {
+    ExposedClickHouseTableOperations ops = newAlterOps(Map.of());
+    String[] validLiterals = {
+      "0", "-1", "+1.5", ".25", "1e3", "true", "FALSE", "'default'", 
"'a,b\\\\c''d'"
+    };
+
+    for (String literal : validLiterals) {
+      String sql =
+          ops.callGenerateAlterTableSql(
+              TableChange.setProperty(settingProperty("test_setting"), 
literal));
+      Assertions.assertTrue(sql.contains("test_setting = " + literal), sql);
+    }
+  }
+
+  @Test
+  void testRejectInvalidTableSettingNamesAndLiterals() {
+    ExposedClickHouseTableOperations ops = newOps();
+    String[] invalidNames = {
+      null,
+      settingProperty(""),
+      settingProperty("1setting"),
+      settingProperty("bad-setting"),
+      settingProperty("bad setting"),
+      settingProperty("setting;DROP")
+    };
+    for (String property : invalidNames) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> 
ops.callGenerateAlterTableSql(TableChange.setProperty(property, "1")));
+    }
+
+    String[] invalidLiterals = {
+      "",
+      "value",
+      "'unterminated",
+      "'bad\\'",
+      "1, RESET SETTING other",
+      "1; DROP TABLE t",
+      "'ok' OR 1"
+    };
+    for (String literal : invalidLiterals) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  ops.callGenerateAlterTableSql(
+                      TableChange.setProperty(settingProperty("test_setting"), 
literal)));
+      if (!literal.isEmpty()) {
+        Assertions.assertFalse(exception.getMessage().contains(literal));
+      }
+    }
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.setProperty(settingProperty("test_setting"), 
null)));
+  }
+
+  @Test
+  void testRejectUnsupportedAndMixedTablePropertyChanges() {
+    ExposedClickHouseTableOperations ops = newOps();
+
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () -> ops.callGenerateAlterTableSql(TableChange.setProperty("engine", 
"MergeTree")));
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () -> 
ops.callGenerateAlterTableSql(TableChange.removeProperty("engine")));
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.setProperty(settingProperty("a"), "1"),
+                TableChange.removeProperty(settingProperty("b"))));
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.setProperty(settingProperty("a"), "1"),
+                TableChange.updateComment("new comment")));
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.removeProperty(settingProperty("a")),
+                TableChange.updateComment("new comment")));
+  }
+
+  @Test
+  void testRejectDuplicateTableSettingChanges() {
+    ExposedClickHouseTableOperations ops = newOps();
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.setProperty(settingProperty("a"), "1"),
+                TableChange.setProperty(settingProperty("a"), "2")));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.callGenerateAlterTableSql(
+                TableChange.removeProperty(settingProperty("a")),
+                TableChange.removeProperty(settingProperty("a"))));
+  }
+
+  @Test
+  void testInvalidTableSettingChangesFailBeforeJdbcConnection() {
+    DataSource dataSource = Mockito.mock(DataSource.class);
+    ClickHouseTableOperations ops = new ClickHouseTableOperations();
+    ops.initialize(
+        dataSource,
+        new ClickHouseExceptionConverter(),
+        new ClickHouseTypeConverter(),
+        new ClickHouseColumnDefaultValueConverter(),
+        new HashMap<>());
+
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () -> ops.alterTable("db", "test_table", 
TableChange.setProperty("engine", "MergeTree")));
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            ops.alterTable(
+                "db",
+                "test_table",
+                TableChange.setProperty(settingProperty("a"), "1"),
+                TableChange.removeProperty(settingProperty("b"))));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.alterTable(
+                "db",
+                "test_table",
+                TableChange.setProperty(settingProperty("a"), "1"),
+                TableChange.setProperty(settingProperty("a"), "2")));
+
+    Mockito.verifyNoInteractions(dataSource);
+  }
+
   private RenameMocks renameMocks(String storedComment, String engineFull) 
throws Exception {
     DataSource dataSource = Mockito.mock(DataSource.class);
     Connection connection = Mockito.mock(Connection.class);
diff --git a/docs/jdbc-clickhouse-catalog.md b/docs/jdbc-clickhouse-catalog.md
index 845964b021..9674448af0 100644
--- a/docs/jdbc-clickhouse-catalog.md
+++ b/docs/jdbc-clickhouse-catalog.md
@@ -175,7 +175,7 @@ See [Manage Catalogs and 
Schemas](./manage-catalogs-and-schemas.md#schema-operat
 | Indexes             | Primary key; data-skipping indexes 
`DATA_SKIPPING_MINMAX`, `DATA_SKIPPING_BLOOM_FILTER`, `DATA_SKIPPING_SET`, 
`DATA_SKIPPING_NGRAMBFV1`, and `DATA_SKIPPING_TOKENBFV1` (configurable 
granularity via `Index.properties()`).                                          
                                                                                
                                                                                
                                                [...]
 | Distribution        | Gravitino enforces `Distributions.NONE`; no custom 
distribution strategies.                                                        
                                                                                
                                                                                
                                                                                
                                                                                
       |
 | Column defaults     | Supported.                                             
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
   |
-| Unsupported         | Engine change after creation; removing table 
properties; auto-increment columns.                                             
                                                                                
                                                                                
                                                                                
                                                                                
             |
+| Unsupported         | Engine and connector-owned property changes after 
creation; mixing table setting changes with schema changes; auto-increment 
columns.                                                                        
                                                                                
                                                                                
                                                                                
            |
 
 ### Table Column Types
 
@@ -204,7 +204,12 @@ Other ClickHouse types are exposed as [External 
Type](./tables-and-views.md#exte
 ### Table Properties
 
 :::note
-- `settings.*` keys are passed to the ClickHouse `SETTINGS` clause verbatim.
+- `settings.*` keys are passed to the ClickHouse `SETTINGS` clause during 
CREATE TABLE. Their
+  values use ClickHouse scalar literal text: numbers and booleans are 
unquoted, while strings must
+  be valid single-quoted literals.
+- ALTER TABLE supports setting or resetting table-level `settings.*` 
properties. A request may
+  contain multiple setting operations of the same form, but cannot mix set and 
remove operations
+  or combine settings with schema, comment, or index changes.
 - The `engine` value is immutable after creation.
 :::
 
@@ -223,18 +228,18 @@ If you need Gravitino to manage an existing cluster 
database or table, recreate
 **Memory engine data volatility**: Tables created with `engine=Memory` store 
data in RAM only. After a ClickHouse server restart the table definition 
persists (Gravitino's `loadTable` succeeds), but all data is permanently lost. 
Gravitino metadata and ClickHouse remain consistent at the schema level, but 
users are responsible for repopulating data after restarts. Consider using 
`TinyLog`, `StripeLog`, or a MergeTree-family engine if data durability is 
required.
 :::
 
-| Property Name             | Description                                      
                                                        | Default Value | 
Required   | Reserved | Immutable |
-|---------------------------|----------------------------------------------------------------------------------------------------------|---------------|------------|----------|-----------|
-| `engine`                  | Table engine (for example `MergeTree`, 
`ReplacingMergeTree`, `Distributed`, `Memory`, etc.)              | `MergeTree` 
  | No         | No       | Yes       |
-| `graphite.config`         | Name of the `<graphite_rollup>` configuration 
element used by `GraphiteMergeTree`                        | (none)        | 
No\*\*\*   | No       | No        |
-| `engine_parameters`       | Parameters for supported parameterized MergeTree 
engines                                                 | (none)        | No    
     | No       | No        |
-| `cluster-name`            | Cluster name used with `ON CLUSTER` and 
Distributed engine                                               | (none)       
 | No\*       | No       | No        |
-| `on-cluster`              | Use `ON CLUSTER` when creating the table         
                                                        | (none)        | No    
     | No       | No        |
-| `cluster-remote-database` | Remote database for `Distributed` engine         
                                                        | (none)        | 
No\*\*     | No       | No        |
-| `cluster-remote-table`    | Remote table for `Distributed` engine            
                                                        | (none)        | 
No\*\*     | No       | No        |
-| `cluster-sharding-key`    | Sharding key for `Distributed` engine 
(expression allowed; referenced columns must be non-null integral) | (none)     
   | No\*\*     | No       | No        |
-| `settings.<name>`         | ClickHouse engine setting forwarded as `SETTINGS 
<name>=<value>`                                         | (none)        | No    
     | No       | No        |
-| `partition-key`           | ClickHouse's canonical native partition 
expression (from `system.tables.partition_key`). Read-only; always present on 
load, empty string means unpartitioned. | `""`          | No         | Yes      
| Yes       |
+| Property Name             | Description                                      
                                                                                
                             | Default Value | Required | Reserved | Immutable |
+|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|----------|-----------|
+| `engine`                  | Table engine (for example `MergeTree`, 
`ReplacingMergeTree`, `Distributed`, `Memory`, etc.)                            
                                       | `MergeTree`   | No       | No       | 
Yes       |
+| `graphite.config`         | Name of the `<graphite_rollup>` configuration 
element used by `GraphiteMergeTree`                                             
                                | (none)        | No\*\*\* | No       | No      
  |
+| `engine_parameters`       | Parameters for supported parameterized MergeTree 
engines                                                                         
                             | (none)        | No       | No       | No        |
+| `cluster-name`            | Cluster name used with `ON CLUSTER` and 
Distributed engine                                                              
                                      | (none)        | No\*     | No       | 
No        |
+| `on-cluster`              | Use `ON CLUSTER` when creating the table         
                                                                                
                             | (none)        | No       | No       | No        |
+| `cluster-remote-database` | Remote database for `Distributed` engine         
                                                                                
                             | (none)        | No\*\*   | No       | No        |
+| `cluster-remote-table`    | Remote table for `Distributed` engine            
                                                                                
                             | (none)        | No\*\*   | No       | No        |
+| `cluster-sharding-key`    | Sharding key for `Distributed` engine 
(expression allowed; referenced columns must be non-null integral)              
                                        | (none)        | No\*\*   | No       | 
No        |
+| `settings.<name>`         | ClickHouse engine setting forwarded as `SETTINGS 
<name>=<scalar-literal>`; supports settings-only set or remove requests after 
creation                       | (none)        | No       | No       | No       
 |
+| `partition-key`           | ClickHouse's canonical native partition 
expression (from `system.tables.partition_key`). Read-only; always present on 
load, empty string means unpartitioned. | `""`          | No       | Yes      | 
Yes       |
 
 \* Required when `on-cluster=true` or `engine=Distributed`.  
 \*\* Required when `engine=Distributed`.
@@ -366,10 +371,13 @@ Supported:
 - Delete columns (with `IF EXISTS` support).
 - Add and drop data-skipping indexes; configure custom `GRANULARITY`, 
`set(N)`, and `ngrambf_v1`/`tokenbf_v1` Bloom-filter parameters via 
`Index.properties()`. Adding/dropping primary key is not supported.
 - Update table comment.
+- Modify table-level `settings.*` properties with `MODIFY SETTING` and reset 
them with `RESET SETTING`. Each request must contain only set operations or 
only remove operations.
 
 Unsupported:
 - Changing engine after creation.
-- Removing table properties or arbitrary `ALTER TABLE ... SETTINGS`.
+- Altering non-`settings.*` table properties.
+- Mixing settings with schema/comment/index changes, or mixing setting 
modifications and resets in one request.
+- Column-level SETTINGS and quoted-comma SETTINGS load/recreate round-trip.
 - Auto-increment columns.
 
 See [Manage Relational Metadata Using 
Gravitino](./manage-relational-metadata-using-gravitino.md#table-operations) 
for common JDBC semantics.

Reply via email to