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 3ce91977a2 [#13945] fix(jdbc): Honor pool wait settings and validate 
MySQL table properties (#13946)
3ce91977a2 is described below

commit 3ce91977a2d137af39321716a9fb5813922afceb
Author: Qi Yu <[email protected]>
AuthorDate: Tue Oct 20 22:12:13 2026 +0800

    [#13945] fix(jdbc): Honor pool wait settings and validate MySQL table 
properties (#13946)
    
    ### What changes were proposed in this pull request?
    
    - Register and forward `jdbc.pool.max-wait-ms` so it reaches the JDBC
    connection pool. As with `jdbc.pool.max-idle`, an explicit canonical
    value takes precedence over `gravitino.bypass.maxWaitMillis`; the
    canonical default no longer overrides a bypassed `maxWaitMillis`.
    - Reject undeclared MySQL table properties instead of silently dropping
    them, while retaining internal identifier and credential metadata
    handling.
    - Decode supported MySQL options using their declared enum/integer types
    and render canonical SQL values. Apply the same validation to
    create/alter SQL generation.
    - Remove Spark's implicit `owner` metadata in the JDBC connector before
    forwarding table options, preserving normal Spark table creation under
    strict MySQL validation.
    - Strip the JDBC scan options the Flink connector injects (`url`,
    `table-name`, `username`, `password`) before forwarding table options,
    so `CREATE TABLE ... LIKE` keeps working and the catalog password is no
    longer sent as a table property. Same-named options written explicitly
    in `WITH` are dropped too; they never took effect, because scan options
    are always regenerated from the catalog connection.
    - Make the shared Flink catalog ITs send arbitrary `WITH` options only
    to catalogs that accept custom table properties; the MySQL Flink IT opts
    out.
    - Clarify the MySQL property names and immutable-property policy in the
    catalog documentation, and list the supported keys in the rejection
    message.
    - Document `jdbc.pool.max-wait-ms` and its bypass precedence in every
    JDBC catalog page (adds the missing Hologres row).
    
    ### Why are the changes needed?
    
    The canonical pool-wait setting currently falls back to `30000` ms.
    Unknown MySQL table properties are accepted and then discarded by
    mapping on both create and alter. This patch corrects these two
    property-handling paths; it does not claim that unknown properties
    bypass immutability through the public API.
    
    Fix: #13945
    
    ### Does this PR introduce _any_ user-facing change?
    
    Configured pool wait times take effect, whether set through
    `jdbc.pool.max-wait-ms` or `gravitino.bypass.maxWaitMillis`. Values that
    were previously ignored now apply after upgrade: a stored non-positive
    `jdbc.pool.max-wait-ms` fails catalog initialization, and a bypassed
    `maxWaitMillis=-1` means an unbounded wait. MySQL create/alter requests
    with unknown table properties now fail with an error naming the key.
    Supported values are validated before DDL. Shared metadata keys that
    every entity accepts (the internal identifier and credential-vending
    keys) are still accepted and not turned into MySQL options, since they
    are not table options. `engine` and `auto-increment-offset` remain
    immutable, and strict unknown-key validation is scoped to MySQL. Spark
    JDBC no longer forwards its reserved `owner` metadata as a table option;
    other user-supplied keys continue to reach the server for
    catalog-specific handling.
    
    ### How was this patch tested?
    
    - Full JDBC common and MySQL non-Docker unit suites: 106 and 29 tests
    passed.
    - Regressions exercise catalog initialization through the actual pool,
    catalog create/alter property mapping, internal metadata handling, and
    create/alter SQL generation before any connection is borrowed.
    - Separate mutation checks revert the pool registration, MySQL metadata
    validation, and SQL validation; the corresponding regression cases fail
    as expected.
    - MySQL catalog module with Docker enabled: 51 tests passed, including
    real create/alter property round trips.
    - Full Spark 3.5 and 4.0 non-Docker suites: 233 and 229 tests passed,
    including implicit-owner removal and preservation of unknown user keys
    for server-side validation.
    - Reverting Spark owner removal makes both owner regression cases fail;
    restoring the fix passes all four converter tests.
    - Pool wait precedence covers canonical, prefixed canonical and DBCP
    `maxWaitMillis` bypass combinations (6 cases); reverting the precedence
    guard fails the bypass-only case.
    - Flink converter suites (MySQL, PostgreSQL) cover stripping injected
    scan options; removing the stripping fails both. A new `CREATE TABLE ...
    LIKE` case in `FlinkJdbcMysqlCatalogIT119` fails without the fix
    (`Unsupported MySQL table property: password`) and passes with it.
    - `FlinkJdbcMysqlCatalogIT119` with Docker MySQL: 30 tests, 0 failures,
    including the LIKE case and the four create-table cases that previously
    sent unsupported options.
    - JDBC common, MySQL, and Spark common, and Flink common Spotless
    checks.
---
 .../jdbc/JdbcCatalogPropertiesMetadata.java        |   7 +
 .../catalog/jdbc/utils/DataSourceUtils.java        |   7 +-
 .../jdbc/TestJdbcCatalogPropertiesMetadata.java    |  86 ++++++++
 .../mysql/MysqlTablePropertiesMetadata.java        |  34 +++-
 .../mysql/operation/MysqlTableOperations.java      |  22 +-
 .../mysql/TestMysqlTablePropertiesMetadata.java    | 222 +++++++++++++++++++++
 .../mysql/operation/TestMysqlTableOperations.java  |  11 +-
 .../TestMysqlTableOperationsSqlGeneration.java     |  88 ++++++++
 docs/jdbc-clickhouse-catalog.md                    |   2 +-
 docs/jdbc-doris-catalog.md                         |   2 +-
 docs/jdbc-hologres-catalog.md                      |  21 +-
 docs/jdbc-mysql-catalog.md                         |   6 +-
 docs/jdbc-oceanbase-catalog.md                     |   2 +-
 docs/jdbc-postgresql-catalog.md                    |   2 +-
 docs/jdbc-starrocks-catalog.md                     |   2 +-
 .../connector/jdbc/JdbcPropertiesConverter.java    |  21 ++
 .../connector/integration/test/FlinkCommonIT.java  |  35 ++--
 .../test/jdbc/FlinkJdbcMysqlCatalogIT.java         |  32 +++
 .../AbstractJdbcPropertiesConverterTestSuite.java  |  21 ++
 .../connector/jdbc/JdbcPropertiesConverter.java    |   6 +-
 .../jdbc/TestJdbcPropertiesConverter.java          |  19 ++
 21 files changed, 598 insertions(+), 50 deletions(-)

diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
index 3f933b0975..be57c26f3e 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogPropertiesMetadata.java
@@ -47,6 +47,7 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
           JdbcConfig.POOL_MIN_SIZE.getKey(),
           JdbcConfig.POOL_MAX_SIZE.getKey(),
           JdbcConfig.POOL_MAX_IDLE.getKey(),
+          JdbcConfig.POOL_MAX_WAIT_MS.getKey(),
           JdbcConfig.TEST_ON_BORROW.getKey(),
           JdbcConfig.CONNECT_TIMEOUT_MS.getKey(),
           JdbcConfig.VERIFY_ON_CREATE.getKey());
@@ -116,6 +117,12 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 JdbcConfig.POOL_MAX_IDLE.getDefaultValue(),
                 false /* hidden */,
                 false /* reserved */),
+            longOptionalPropertyEntry(
+                JdbcConfig.POOL_MAX_WAIT_MS.getKey(),
+                JdbcConfig.POOL_MAX_WAIT_MS.getDoc(),
+                false /* immutable */,
+                JdbcConfig.POOL_MAX_WAIT_MS.getDefaultValue(),
+                false /* hidden */),
             booleanPropertyEntry(
                 JdbcConfig.TEST_ON_BORROW.getKey(),
                 JdbcConfig.TEST_ON_BORROW.getDoc(),
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
index c2d224b7a1..b2f61e19ff 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
@@ -166,7 +166,12 @@ public class DataSourceUtils {
             : jdbcConfig.getPoolMinSize());
     // Validate connections on borrow when enabled.
     basicDataSource.setTestOnBorrow(jdbcConfig.getTestOnBorrow());
-    basicDataSource.setMaxWait(Duration.ofMillis(jdbcConfig.getMaxWaitMs()));
+    // Same precedence as maxIdle: keep a maxWaitMillis supplied through the 
DBCP bypass unless the
+    // canonical setting is explicitly configured.
+    if 
(jdbcConfig.getAllConfig().containsKey(JdbcConfig.POOL_MAX_WAIT_MS.getKey())
+        || !jdbcConfig.getAllConfig().containsKey("maxWaitMillis")) {
+      basicDataSource.setMaxWait(Duration.ofMillis(jdbcConfig.getMaxWaitMs()));
+    }
     // Apply the driver-aware connect/socket timeout as DBCP connection 
properties (not pool keys,
     // so no collision with UNSAFE_POOL_PROPERTIES and the pool stays lazy). 
These bound a single
     // connection and take effect on the first real connection — i.e. 
create-time verification — so
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
index b6fd8a56bf..60a4e95a34 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogPropertiesMetadata.java
@@ -20,7 +20,10 @@ package org.apache.gravitino.catalog.jdbc;
 
 import com.google.common.collect.ImmutableMap;
 import java.time.Instant;
+import java.util.HashMap;
 import java.util.Map;
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
@@ -36,12 +39,14 @@ import 
org.apache.gravitino.catalog.jdbc.operation.SqliteTableOperations;
 import 
org.apache.gravitino.catalog.jdbc.utils.TestDataSourceValidation.UnsupportedValidationDriver;
 import org.apache.gravitino.connector.CatalogInfo;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestJdbcCatalogPropertiesMetadata {
 
@@ -68,6 +73,87 @@ public class TestJdbcCatalogPropertiesMetadata {
             .shouldValidateOnCreate());
   }
 
+  @ParameterizedTest
+  @CsvSource({
+    "none,none,none,30000",
+    "1250,none,none,1250",
+    "none,2300,none,2300",
+    "1250,2300,none,1250",
+    "none,none,3400,3400",
+    "1250,none,3400,1250"
+  })
+  void testPoolMaxWaitReachesCatalogDataSource(
+      String canonical, String bypass, String dbcpBypass, long expected) 
throws Exception {
+    Map<String, String> properties = poolWaitProperties();
+    if (!"none".equals(canonical)) {
+      properties.put(JdbcConfig.POOL_MAX_WAIT_MS.getKey(), canonical);
+    }
+    if (!"none".equals(bypass)) {
+      properties.put("gravitino.bypass." + 
JdbcConfig.POOL_MAX_WAIT_MS.getKey(), bypass);
+    }
+    if (!"none".equals(dbcpBypass)) {
+      // The DBCP-native key documented as the bypass example in the JDBC 
catalog docs.
+      properties.put("gravitino.bypass.maxWaitMillis", dbcpBypass);
+    }
+    JdbcCatalogPropertiesMetadata metadata = new 
JdbcCatalogPropertiesMetadata();
+    
Assertions.assertTrue(metadata.containsProperty(JdbcConfig.POOL_MAX_WAIT_MS.getKey()));
+    
Assertions.assertFalse(metadata.isHiddenProperty(JdbcConfig.POOL_MAX_WAIT_MS.getKey()));
+    
Assertions.assertFalse(metadata.isImmutableProperty(JdbcConfig.POOL_MAX_WAIT_MS.getKey()));
+    try (JdbcCatalogOperations operations = poolWaitOperations()) {
+      operations.initialize(properties, poolWaitCatalogInfo(properties), new 
TestJdbcCatalog());
+      BasicDataSource pool = (BasicDataSource) 
FieldUtils.readField(operations, "dataSource", true);
+      Assertions.assertEquals(expected, pool.getMaxWaitDuration().toMillis());
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"0", "-1", "not-a-number"})
+  void testInvalidPoolMaxWaitIsRejected(String value) {
+    Map<String, String> properties = poolWaitProperties();
+    properties.put(JdbcConfig.POOL_MAX_WAIT_MS.getKey(), value);
+    try (JdbcCatalogOperations operations = poolWaitOperations()) {
+      Assertions.assertThrows(
+          GravitinoRuntimeException.class,
+          () ->
+              operations.initialize(
+                  properties, poolWaitCatalogInfo(properties), new 
TestJdbcCatalog()));
+    }
+  }
+
+  private static Map<String, String> poolWaitProperties() {
+    return new HashMap<>(
+        Map.of(
+            JdbcConfig.JDBC_URL.getKey(),
+            "jdbc:sqlite::memory:",
+            JdbcConfig.JDBC_DRIVER.getKey(),
+            "org.sqlite.JDBC",
+            JdbcConfig.USERNAME.getKey(),
+            "user",
+            JdbcConfig.PASSWORD.getKey(),
+            "password"));
+  }
+
+  private static CatalogInfo poolWaitCatalogInfo(Map<String, String> 
properties) {
+    return new CatalogInfo(
+        1L,
+        "jdbc_catalog",
+        Catalog.Type.RELATIONAL,
+        "jdbc-test",
+        "test",
+        properties,
+        
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build(),
+        Namespace.of("metalake"));
+  }
+
+  private static JdbcCatalogOperations poolWaitOperations() {
+    return new JdbcCatalogOperations(
+        new SqliteExceptionConverter(),
+        new SqliteTypeConverter(),
+        new SqliteDatabaseOperations(":memory:"),
+        new SqliteTableOperations(),
+        new SqliteColumnDefaultValueConverter());
+  }
+
   private static JdbcCatalog buildCatalog(Map<String, String> extraProperties) 
{
     Map<String, String> properties =
         ImmutableMap.<String, String>builder()
diff --git 
a/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/MysqlTablePropertiesMetadata.java
 
b/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/MysqlTablePropertiesMetadata.java
index e4de0a0cbd..1b73a3f704 100644
--- 
a/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/MysqlTablePropertiesMetadata.java
+++ 
b/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/MysqlTablePropertiesMetadata.java
@@ -18,10 +18,12 @@
  */
 package org.apache.gravitino.catalog.mysql;
 
+import static 
org.apache.gravitino.catalog.PropertiesMetadataHelpers.checkValueFormat;
 import static 
org.apache.gravitino.connector.PropertyEntry.enumImmutablePropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.integerOptionalPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.stringReservedPropertyEntry;
 
+import com.google.common.base.Preconditions;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -107,17 +109,23 @@ public class MysqlTablePropertiesMetadata extends 
JdbcTablePropertiesMetadata {
 
   @Override
   public Map<String, String> transformToJdbcProperties(Map<String, String> 
properties) {
-    return Collections.unmodifiableMap(
-        new HashMap<String, String>() {
-          {
-            properties.forEach(
-                (key, value) -> {
-                  if (GRAVITINO_CONFIG_TO_MYSQL.containsKey(key)) {
-                    put(GRAVITINO_CONFIG_TO_MYSQL.get(key), value);
-                  }
-                });
+    Map<String, String> result = new HashMap<>();
+    properties.forEach(
+        (key, value) -> {
+          Preconditions.checkArgument(
+              containsProperty(key),
+              "Unsupported MySQL table property: %s. Supported properties: %s",
+              key,
+              String.join(", ", GRAVITINO_CONFIG_TO_MYSQL.keySet()));
+          if (GRAVITINO_CONFIG_TO_MYSQL.containsKey(key)) {
+            // A null value represents RemoveProperty in 
JdbcCatalogOperations. Non-SQL metadata,
+            // such as the internal identifier and credential settings, must 
not become options.
+            result.put(
+                GRAVITINO_CONFIG_TO_MYSQL.get(key),
+                value == null ? null : 
validateAndEncode(getPropertyEntry(key), value));
           }
         });
+    return Collections.unmodifiableMap(result);
   }
 
   @Override
@@ -135,4 +143,12 @@ public class MysqlTablePropertiesMetadata extends 
JdbcTablePropertiesMetadata {
           }
         });
   }
+
+  private static <T> String validateAndEncode(PropertyEntry<T> entry, String 
value) {
+    T decoded = checkValueFormat(entry.getName(), value, entry::decode);
+    if (decoded instanceof ENGINE) {
+      return ((ENGINE) decoded).getValue();
+    }
+    return entry.getEncoder().apply(decoded);
+  }
 }
diff --git 
a/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/operation/MysqlTableOperations.java
 
b/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/operation/MysqlTableOperations.java
index 7f80ceba9c..af5b4becc4 100644
--- 
a/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/operation/MysqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-mysql/src/main/java/org/apache/gravitino/catalog/mysql/operation/MysqlTableOperations.java
@@ -49,6 +49,7 @@ import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.catalog.jdbc.JdbcColumn;
 import org.apache.gravitino.catalog.jdbc.JdbcTable;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
+import org.apache.gravitino.catalog.mysql.MysqlTablePropertiesMetadata;
 import org.apache.gravitino.exceptions.NoSuchColumnException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
 import org.apache.gravitino.rel.Column;
@@ -65,6 +66,8 @@ import org.apache.gravitino.rel.types.Types;
 /** Table operations for MySQL. */
 public class MysqlTableOperations extends JdbcTableOperations {
 
+  private static final MysqlTablePropertiesMetadata TABLE_PROPERTIES_METADATA =
+      new MysqlTablePropertiesMetadata();
   private static final String BACK_QUOTE = "`";
   private static final String MYSQL_AUTO_INCREMENT = "AUTO_INCREMENT";
   private static final String MYSQL_NOT_SUPPORT_NESTED_COLUMN_MSG =
@@ -134,7 +137,7 @@ public class MysqlTableOperations extends 
JdbcTableOperations {
     if (MapUtils.isNotEmpty(properties)) {
       sqlBuilder.append(
           properties.entrySet().stream()
-              .map(entry -> String.format("%s = %s", entry.getKey(), 
entry.getValue()))
+              .map(entry -> generateTableProperty(entry.getKey(), 
entry.getValue()))
               .collect(Collectors.joining(",\n", "\n", "")));
     }
 
@@ -578,11 +581,24 @@ public class MysqlTableOperations extends 
JdbcTableOperations {
   private String generateTableProperties(List<TableChange.SetProperty> 
setProperties) {
     return setProperties.stream()
         .map(
-            setProperty ->
-                String.format("%s = %s", setProperty.getProperty(), 
setProperty.getValue()))
+            setProperty -> generateTableProperty(setProperty.getProperty(), 
setProperty.getValue()))
         .collect(Collectors.joining(",\n"));
   }
 
+  private String generateTableProperty(String key, String value) {
+    Map<String, String> logicalProperties =
+        
TABLE_PROPERTIES_METADATA.convertFromJdbcProperties(Collections.singletonMap(key,
 value));
+    Preconditions.checkArgument(
+        !logicalProperties.isEmpty(),
+        "Unsupported MySQL table option: %s. Supported options: %s",
+        key,
+        String.join(", ", 
MysqlTablePropertiesMetadata.GRAVITINO_CONFIG_TO_MYSQL.values()));
+    String encoded =
+        
TABLE_PROPERTIES_METADATA.transformToJdbcProperties(logicalProperties).get(key);
+    Preconditions.checkArgument(encoded != null, "MySQL table option %s cannot 
be null", key);
+    return key + " = " + encoded;
+  }
+
   private String updateColumnCommentFieldDefinition(
       TableChange.UpdateColumnComment updateColumnComment, JdbcTable 
jdbcTable) {
     String newComment = updateColumnComment.getNewComment();
diff --git 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/TestMysqlTablePropertiesMetadata.java
 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/TestMysqlTablePropertiesMetadata.java
new file mode 100644
index 0000000000..16893e6b6b
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/TestMysqlTablePropertiesMetadata.java
@@ -0,0 +1,222 @@
+/*
+ * 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.gravitino.catalog.mysql;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.StringIdentifier;
+import org.apache.gravitino.catalog.PropertiesMetadataHelpers;
+import org.apache.gravitino.catalog.jdbc.JdbcCatalogOperations;
+import org.apache.gravitino.catalog.jdbc.JdbcColumn;
+import org.apache.gravitino.catalog.jdbc.JdbcTable;
+import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
+import 
org.apache.gravitino.catalog.mysql.converter.MysqlColumnDefaultValueConverter;
+import org.apache.gravitino.catalog.mysql.converter.MysqlTypeConverter;
+import org.apache.gravitino.catalog.mysql.operation.MysqlDatabaseOperations;
+import org.apache.gravitino.catalog.mysql.operation.MysqlTableOperations;
+import org.apache.gravitino.connector.CatalogInfo;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.rel.Column;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableChange;
+import org.apache.gravitino.rel.expressions.distributions.Distribution;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
+import org.apache.gravitino.rel.expressions.transforms.Transform;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.gravitino.rel.indexes.Indexes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class TestMysqlTablePropertiesMetadata {
+  private static final NameIdentifier IDENT =
+      NameIdentifier.of("metalake", "catalog", "database", "table");
+
+  @ParameterizedTest
+  @ValueSource(strings = {"unknown-key", "ENGINE", "ROW_FORMAT", "engine; DROP 
TABLE t"})
+  void testCatalogRejectsUnknownPropertiesBeforeCreateOrAlter(String key) {
+    RecordingTableOperations table = new RecordingTableOperations();
+    try (JdbcCatalogOperations operations = operations(table)) {
+      IllegalArgumentException create =
+          Assertions.assertThrows(
+              IllegalArgumentException.class, () -> create(operations, 
Map.of(key, "MEMORY")));
+      Assertions.assertTrue(create.getMessage().contains(key));
+      Assertions.assertTrue(
+          create.getMessage().contains("Supported properties: 
auto-increment-offset, engine"),
+          create.getMessage());
+      IllegalArgumentException alter =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> operations.alterTable(IDENT, TableChange.setProperty(key, 
"MEMORY")));
+      Assertions.assertTrue(alter.getMessage().contains(key));
+      IllegalArgumentException remove =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> operations.alterTable(IDENT, 
TableChange.removeProperty(key)));
+      Assertions.assertTrue(remove.getMessage().contains(key));
+      Assertions.assertEquals(0, table.writes);
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"engine,InnoDB; DROP TABLE t", "auto-increment-offset,42; DROP 
TABLE t"})
+  void testInvalidDeclaredValuesAreRejectedBeforeCreate(String key, String 
value) {
+    RecordingTableOperations table = new RecordingTableOperations();
+    try (JdbcCatalogOperations operations = operations(table)) {
+      IllegalArgumentException error =
+          Assertions.assertThrows(
+              IllegalArgumentException.class, () -> create(operations, 
Map.of(key, value)));
+      Assertions.assertTrue(error.getMessage().contains(key));
+      Assertions.assertEquals(0, table.writes);
+    }
+  }
+
+  @Test
+  void testValidPropertiesRoundTripAndMetadataDoesNotBecomeSql() {
+    RecordingTableOperations table = new RecordingTableOperations();
+    try (JdbcCatalogOperations operations = operations(table)) {
+      Table created =
+          create(
+              operations,
+              Map.of(
+                  "engine",
+                  "InNoDB",
+                  "auto-increment-offset",
+                  "+0042",
+                  "credential-providers",
+                  "test-provider"));
+      Assertions.assertEquals(Map.of("ENGINE", "InnoDB", "AUTO_INCREMENT", 
"42"), table.properties);
+      Assertions.assertEquals(
+          Map.of("engine", "InnoDB", "auto-increment-offset", "42"), 
created.properties());
+      Table loaded = operations.loadTable(IDENT);
+      Assertions.assertEquals("InnoDB", loaded.properties().get("engine"));
+      Assertions.assertEquals("42", 
loaded.properties().get("auto-increment-offset"));
+      Assertions.assertEquals(1, table.writes);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"engine", "auto-increment-offset"})
+  void testDeclaredPropertiesRemainImmutable(String key) {
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                PropertiesMetadataHelpers.validatePropertyForAlter(
+                    new MysqlTablePropertiesMetadata(), Map.of(key, "42"), 
Map.of()));
+    Assertions.assertTrue(error.getMessage().contains("immutable"));
+    Map<String, String> removal = new HashMap<>();
+    removal.put(key, null);
+    Assertions.assertTrue(
+        new 
MysqlTablePropertiesMetadata().transformToJdbcProperties(removal).containsValue(null));
+  }
+
+  private static Table create(JdbcCatalogOperations operations, Map<String, 
String> properties) {
+    return operations.createTable(
+        IDENT,
+        new Column[0],
+        "comment",
+        StringIdentifier.newPropertiesWithId(StringIdentifier.fromId(1L), 
properties),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        new SortOrder[0],
+        Indexes.EMPTY_INDEXES);
+  }
+
+  private static JdbcCatalogOperations operations(RecordingTableOperations 
table) {
+    JdbcCatalogOperations operations =
+        new JdbcCatalogOperations(
+            new JdbcExceptionConverter(),
+            new MysqlTypeConverter(),
+            new MysqlDatabaseOperations(),
+            table,
+            new MysqlColumnDefaultValueConverter());
+    Map<String, String> conf =
+        Map.of(
+            "jdbc-url",
+            "jdbc:mysql://127.0.0.1:1/unused",
+            "jdbc-driver",
+            "com.mysql.cj.jdbc.Driver",
+            "jdbc-user",
+            "test",
+            "jdbc-password",
+            "test");
+    CatalogInfo info =
+        new CatalogInfo(
+            1L,
+            "catalog",
+            Catalog.Type.RELATIONAL,
+            "jdbc-mysql",
+            "test",
+            conf,
+            
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build(),
+            Namespace.of("metalake"));
+    operations.initialize(conf, info, new MysqlCatalog());
+    return operations;
+  }
+
+  private static class RecordingTableOperations extends MysqlTableOperations {
+    private int writes;
+    private Map<String, String> properties = Collections.emptyMap();
+    private String comment;
+
+    /** Records a create after catalog property validation and translation. */
+    @Override
+    public void create(
+        String databaseName,
+        String tableName,
+        JdbcColumn[] columns,
+        String comment,
+        Map<String, String> properties,
+        Transform[] partitioning,
+        Distribution distribution,
+        Index[] indexes,
+        SortOrder[] sortOrders) {
+      writes++;
+      this.properties = new HashMap<>(properties);
+      this.comment = comment;
+    }
+
+    /** Records alterations reaching the table operation boundary. */
+    @Override
+    public void alterTable(String databaseName, String tableName, 
TableChange... changes) {
+      writes++;
+    }
+
+    /** Returns the state recorded by create. */
+    @Override
+    public JdbcTable load(String databaseName, String tableName) {
+      return JdbcTable.builder()
+          .withName(tableName)
+          .withColumns(new JdbcColumn[0])
+          .withProperties(properties)
+          .withComment(comment)
+          .build();
+    }
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
index 9a871df0e3..e15d53b0a3 100644
--- 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperations.java
@@ -39,7 +39,6 @@ import org.apache.gravitino.catalog.jdbc.JdbcTable;
 import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
 import 
org.apache.gravitino.catalog.mysql.converter.MysqlColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.mysql.converter.MysqlTypeConverter;
-import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
@@ -157,15 +156,13 @@ public class TestMysqlTableOperations extends TestMysql {
     assertionsTableInfo(
         newName, tableComment, alterColumns, properties, indexes, 
Transforms.EMPTY_TRANSFORM, load);
 
-    // Detect unsupported properties
+    // Reject an unsupported engine before generating or executing DDL.
     TableChange setProperty = TableChange.setProperty(MYSQL_ENGINE_KEY, "ABC");
-    GravitinoRuntimeException gravitinoRuntimeException =
+    IllegalArgumentException invalidEngine =
         Assertions.assertThrows(
-            GravitinoRuntimeException.class,
+            IllegalArgumentException.class,
             () -> TABLE_OPERATIONS.alterTable(TEST_DB_NAME.toString(), 
newName, setProperty));
-    Assertions.assertTrue(
-        StringUtils.contains(
-            gravitinoRuntimeException.getMessage(), "Unknown storage engine 
'ABC'"));
+    Assertions.assertTrue(invalidEngine.getMessage().contains("engine"));
 
     // delete column
     TABLE_OPERATIONS.alterTable(
diff --git 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperationsSqlGeneration.java
 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperationsSqlGeneration.java
index 7f240760a0..a8292a95cb 100644
--- 
a/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperationsSqlGeneration.java
+++ 
b/catalogs/catalog-jdbc-mysql/src/test/java/org/apache/gravitino/catalog/mysql/operation/TestMysqlTableOperationsSqlGeneration.java
@@ -19,10 +19,13 @@
 package org.apache.gravitino.catalog.mysql.operation;
 
 import java.util.Collections;
+import java.util.Map;
+import java.util.stream.Stream;
 import org.apache.gravitino.catalog.jdbc.JdbcColumn;
 import org.apache.gravitino.catalog.jdbc.JdbcTable;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
+import org.apache.gravitino.catalog.jdbc.utils.ConnectionCountingDataSource;
 import org.apache.gravitino.catalog.mysql.converter.MysqlTypeConverter;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.TableChange;
@@ -34,6 +37,9 @@ import org.apache.gravitino.rel.indexes.Indexes;
 import org.apache.gravitino.rel.types.Types;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 public class TestMysqlTableOperationsSqlGeneration {
 
@@ -44,6 +50,11 @@ public class TestMysqlTableOperationsSqlGeneration {
       super.columnDefaultValueConverter = new 
JdbcColumnDefaultValueConverter();
     }
 
+    private TestableMysqlTableOperations(ConnectionCountingDataSource 
connections) {
+      this();
+      dataSource = connections.dataSource();
+    }
+
     public String createTableSql(String tableName, JdbcColumn[] columns) {
       return createTableSql(tableName, columns, "comment");
     }
@@ -70,6 +81,73 @@ public class TestMysqlTableOperationsSqlGeneration {
     }
   }
 
+  @ParameterizedTest
+  @MethodSource("invalidTableOptions")
+  void testInvalidOptionsAreRejectedBeforeCreateAndAlterSql(String key, String 
value) {
+    ConnectionCountingDataSource connections = new 
ConnectionCountingDataSource();
+    TestableMysqlTableOperations ops = new 
TestableMysqlTableOperations(connections);
+    JdbcColumn column =
+        
JdbcColumn.builder().withName("col1").withType(Types.IntegerType.get()).build();
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ops.create(
+                "database",
+                "test_table",
+                new JdbcColumn[] {column},
+                "comment",
+                Collections.singletonMap(key, value),
+                Transforms.EMPTY_TRANSFORM,
+                Distributions.NONE,
+                Indexes.EMPTY_INDEXES));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> ops.alterTable("database", "test_table", 
TableChange.setProperty(key, value)));
+    Assertions.assertTrue(connections.executedSql().isEmpty());
+    Assertions.assertEquals(0, connections.borrowed());
+    Assertions.assertEquals(0, connections.peakBorrowed());
+  }
+
+  @Test
+  void testUnsupportedSqlOptionNamesSupportedOptions() {
+    TestableMysqlTableOperations ops = new TestableMysqlTableOperations();
+    JdbcColumn column =
+        
JdbcColumn.builder().withName("col1").withType(Types.IntegerType.get()).build();
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                ops.generateCreateTableSql(
+                    "test_table",
+                    new JdbcColumn[] {column},
+                    null,
+                    Map.of("ROW_FORMAT", "DYNAMIC"),
+                    Transforms.EMPTY_TRANSFORM,
+                    Distributions.NONE,
+                    Indexes.EMPTY_INDEXES));
+    Assertions.assertTrue(
+        error.getMessage().contains("Supported options: AUTO_INCREMENT, 
ENGINE"),
+        error.getMessage());
+  }
+
+  @Test
+  void testSupportedSqlOptionsAreNormalized() {
+    TestableMysqlTableOperations ops = new TestableMysqlTableOperations();
+    JdbcColumn column =
+        
JdbcColumn.builder().withName("col1").withType(Types.IntegerType.get()).build();
+    String sql =
+        ops.generateCreateTableSql(
+            "test_table",
+            new JdbcColumn[] {column},
+            null,
+            Map.of("ENGINE", "InNoDB", "AUTO_INCREMENT", "+0042"),
+            Transforms.EMPTY_TRANSFORM,
+            Distributions.NONE,
+            Indexes.EMPTY_INDEXES);
+    Assertions.assertTrue(sql.contains("ENGINE = InnoDB"), sql);
+    Assertions.assertTrue(sql.contains("AUTO_INCREMENT = 42"), sql);
+  }
+
   @Test
   public void testCreateTableWithEmptyStringDefaultValue() {
     TestableMysqlTableOperations ops = new TestableMysqlTableOperations();
@@ -210,4 +288,14 @@ public class TestMysqlTableOperationsSqlGeneration {
     Assertions.assertTrue(alterSql.contains("ADD COLUMN `col2`"), alterSql);
     Assertions.assertTrue(alterSql.contains("COMMENT 'owner\\\\''s 
\"comment\"; --'"), alterSql);
   }
+
+  private static Stream<Arguments> invalidTableOptions() {
+    return Stream.of(
+        Arguments.of("ENGINE", "ABC"),
+        Arguments.of("ENGINE", "InnoDB, ROW_FORMAT=DYNAMIC"),
+        Arguments.of("AUTO_INCREMENT", "1; DROP TABLE t"),
+        Arguments.of("ROW_FORMAT", "DYNAMIC"),
+        Arguments.of("ENGINE; DROP TABLE t", "MEMORY"),
+        Arguments.of("AUTO_INCREMENT", null));
+  }
 }
diff --git a/docs/jdbc-clickhouse-catalog.md b/docs/jdbc-clickhouse-catalog.md
index 370ba3758a..4b6e64db22 100644
--- a/docs/jdbc-clickhouse-catalog.md
+++ b/docs/jdbc-clickhouse-catalog.md
@@ -62,7 +62,7 @@ When using the JDBC catalog you must provide `jdbc-url`, 
`jdbc-driver`, `jdbc-us
 | `jdbc.pool.min-size`    | Minimum pool size                                  
                                                                                
                                                      | `2`           | No      
 |
 | `jdbc.pool.max-size`    | Maximum pool size                                  
                                                                                
                                                      | `10`          | No      
 |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
-| `jdbc.pool.max-wait-ms` | Max wait time for a connection                     
                                                                                
                                                      | `30000`       | No      
 |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 ### Create a ClickHouse Catalog
 
diff --git a/docs/jdbc-doris-catalog.md b/docs/jdbc-doris-catalog.md
index 0dea47a4f8..15090280c1 100644
--- a/docs/jdbc-doris-catalog.md
+++ b/docs/jdbc-doris-catalog.md
@@ -53,7 +53,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
 |
 | `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
     |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
-| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned. `30000` by default.                                  
                                                      | `30000`       | No      
 |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 Before using the Doris Catalog, you must download the corresponding JDBC 
driver to the `catalogs/jdbc-doris/libs` directory.
 Gravitino doesn't package the JDBC driver for Doris due to licensing issues.
diff --git a/docs/jdbc-hologres-catalog.md b/docs/jdbc-hologres-catalog.md
index 4bd30f1fa4..e2844b88cb 100644
--- a/docs/jdbc-hologres-catalog.md
+++ b/docs/jdbc-hologres-catalog.md
@@ -46,16 +46,17 @@ Check the relevant data source configuration in [data 
source properties](https:/
 If you use a JDBC catalog, you must provide `jdbc-url`, `jdbc-driver`, 
`jdbc-database`, `jdbc-user` and `jdbc-password` to catalog properties.
 Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-properties-configuration), the 
Hologres catalog has the following properties:
 
-| Configuration item   | Description                                           
                                                                                
                                                   | Default value | Required |
-|----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|
-| `jdbc-url`           | JDBC URL for connecting to the database. For example, 
`jdbc:postgresql://hgprecn-cn-xxx.hologres.aliyuncs.com:80/my_database`         
                                                   | (none)        | Yes      |
-| `jdbc-driver`        | The driver of the JDBC connection. Must be 
`org.postgresql.Driver`.                                                        
                                                              | (none)        | 
Yes      |
-| `jdbc-database`      | The database name. This is mandatory for Hologres.    
                                                                                
                                                   | (none)        | Yes      |
-| `jdbc-user`          | The JDBC user name (AccessKey ID or database 
username).                                                                      
                                                            | (none)        | 
Yes      |
-| `jdbc-password`      | The JDBC password (AccessKey Secret or database 
password).                                                                      
                                                         | (none)        | Yes  
    |
-| `jdbc.pool.min-size` | The minimum number of connections in the pool. `2` by 
default.                                                                        
                                                   | `2`           | No       |
-| `jdbc.pool.max-size` | The maximum number of connections in the pool. `10` 
by default.                                                                     
                                                     | `10`          | No       
|
-| `jdbc.pool.max-idle` | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
+| Configuration item      | Description                                        
                                                                                
                                                      | Default value | 
Required |
+|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|----------|
+| `jdbc-url`              | JDBC URL for connecting to the database. For 
example, 
`jdbc:postgresql://hgprecn-cn-xxx.hologres.aliyuncs.com:80/my_database`         
                                                   | (none)        | Yes      |
+| `jdbc-driver`           | The driver of the JDBC connection. Must be 
`org.postgresql.Driver`.                                                        
                                                              | (none)        | 
Yes      |
+| `jdbc-database`         | The database name. This is mandatory for Hologres. 
                                                                                
                                                      | (none)        | Yes     
 |
+| `jdbc-user`             | The JDBC user name (AccessKey ID or database 
username).                                                                      
                                                            | (none)        | 
Yes      |
+| `jdbc-password`         | The JDBC password (AccessKey Secret or database 
password).                                                                      
                                                         | (none)        | Yes  
    |
+| `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
 |
+| `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
     |
+| `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 :::caution
 Hologres uses the PostgreSQL JDBC Driver (version 42.3.2 or later 
recommended). You need to download the PostgreSQL JDBC Driver and place it in 
the `catalogs/jdbc-hologres/libs` directory under the Gravitino distribution 
(e.g., `distribution/package/catalogs/jdbc-hologres/libs` or 
`distribution/package-all/catalogs/jdbc-hologres/libs`).
diff --git a/docs/jdbc-mysql-catalog.md b/docs/jdbc-mysql-catalog.md
index ec15f1736e..14bacae6bc 100644
--- a/docs/jdbc-mysql-catalog.md
+++ b/docs/jdbc-mysql-catalog.md
@@ -53,7 +53,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
 |
 | `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
     |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
-| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned. `30000` by default.                                  
                                                      | `30000`       | No      
 |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 :::caution
 Gravitino does not package the MySQL JDBC driver, because MySQL Connector/J is
@@ -214,7 +214,9 @@ Although MySQL itself does not support table properties, 
Gravitino offers table
 :::
 
 :::caution
-- Doesn't support remove table properties. You can only add or modify 
properties, not delete properties.
+For MySQL table options, use the Gravitino property names listed below. 
Unknown properties, including SQL option names such as `ENGINE` or 
`ROW_FORMAT`, are rejected. Use the Gravitino property name `engine` instead. 
Metadata keys shared by every entity, such as the credential-vending settings, 
are accepted but not applied to MySQL tables.
+
+These properties can only be specified when creating a table; they cannot be 
changed or removed afterwards. Values are validated against their declared 
types before DDL is executed.
 :::
 
 | Property Name           | Description                                        
                                                                                
                      | Default Value | Required | Reserved | Immutable |
diff --git a/docs/jdbc-oceanbase-catalog.md b/docs/jdbc-oceanbase-catalog.md
index 192af85017..861597d83e 100644
--- a/docs/jdbc-oceanbase-catalog.md
+++ b/docs/jdbc-oceanbase-catalog.md
@@ -51,7 +51,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
 |
 | `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
     |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
-| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned. `30000` by default.                                  
                                                      | `30000`       | No      
 |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 :::caution
 Gravitino does not package the OceanBase JDBC driver due to licensing, so you
diff --git a/docs/jdbc-postgresql-catalog.md b/docs/jdbc-postgresql-catalog.md
index eac6d40ee0..3bc301809f 100644
--- a/docs/jdbc-postgresql-catalog.md
+++ b/docs/jdbc-postgresql-catalog.md
@@ -52,7 +52,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
                       |
 | `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
                           |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No                             |
-| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned. `30000` by default.                                  
                                                      | `30000`       | No      
                       |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No                             |
 
 :::caution
 Download the corresponding JDBC driver to the `catalogs/jdbc-postgresql/libs` 
directory.
diff --git a/docs/jdbc-starrocks-catalog.md b/docs/jdbc-starrocks-catalog.md
index cb87bad97e..318ea46114 100644
--- a/docs/jdbc-starrocks-catalog.md
+++ b/docs/jdbc-starrocks-catalog.md
@@ -52,7 +52,7 @@ Besides the [common catalog 
properties](./gravitino-server-config.md#catalog-pro
 | `jdbc.pool.min-size`    | The minimum number of connections in the pool. `2` 
by default.                                                                     
                                                      | `2`           | No      
 |
 | `jdbc.pool.max-size`    | The maximum number of connections in the pool. 
`10` by default.                                                                
                                                          | `10`          | No  
     |
 | `jdbc.pool.max-idle`    | Maximum idle connections retained per catalog per 
server; capped by `jdbc.pool.max-size`; takes precedence over 
`gravitino.bypass.maxIdle`. Idle connections are not evicted by default. | `8`  
         | No       |
-| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned. `30000` by default.                                  
                                                      | `30000`       | No      
 |
+| `jdbc.pool.max-wait-ms` | The maximum Duration that the pool will wait for a 
connection to be returned; takes precedence over 
`gravitino.bypass.maxWaitMillis`.                                               
     | `30000`       | No       |
 
 Before using the StarRocks Catalog, you must download the corresponding JDBC 
driver to the `catalogs/jdbc-starrocks/libs` directory.
 Gravitino doesn't package the JDBC driver for StarRocks due to licensing 
issues.
diff --git 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/jdbc/JdbcPropertiesConverter.java
 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/jdbc/JdbcPropertiesConverter.java
index 611952e02d..d0994d9259 100644
--- 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/jdbc/JdbcPropertiesConverter.java
+++ 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/jdbc/JdbcPropertiesConverter.java
@@ -19,8 +19,10 @@
 
 package org.apache.gravitino.flink.connector.jdbc;
 
+import com.google.common.collect.ImmutableSet;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import org.apache.commons.lang3.StringUtils;
@@ -35,6 +37,13 @@ public abstract class JdbcPropertiesConverter
 
   private static final Pattern jdbcUrlPattern = 
Pattern.compile("(jdbc:\\w+://[^:/]+(?::\\d+)?)");
 
+  private static final Set<String> INJECTED_TABLE_OPTIONS =
+      ImmutableSet.of(
+          JdbcPropertiesConstants.FLINK_JDBC_TABLE_DATABASE_URL,
+          JdbcPropertiesConstants.FLINK_JDBC_TABLE_NAME,
+          JdbcPropertiesConstants.FLINK_JDBC_USER,
+          JdbcPropertiesConstants.FLINK_JDBC_PASSWORD);
+
   @Override
   public Map<String, String> toGravitinoCatalogProperties(Configuration 
flinkConf) {
     Map<String, String> gravitinoCatalogProperties =
@@ -86,6 +95,18 @@ public abstract class JdbcPropertiesConverter
     return JdbcPropertiesConstants.gravitinoToFlinkMap.get(configKey);
   }
 
+  /**
+   * Drops the connection options that {@link #toFlinkTableProperties} injects 
for scans. Flink
+   * copies them into the options of {@code CREATE TABLE ... LIKE}, but they 
describe the catalog
+   * connection rather than the table, and must not reach the server as table 
properties.
+   */
+  @Override
+  public Map<String, String> toGravitinoTableProperties(Map<String, String> 
flinkProperties) {
+    Map<String, String> gravitinoProperties = new HashMap<>(flinkProperties);
+    gravitinoProperties.keySet().removeAll(INJECTED_TABLE_OPTIONS);
+    return gravitinoProperties;
+  }
+
   @Override
   public Map<String, String> toFlinkTableProperties(
       Map<String, String> flinkCatalogProperties,
diff --git 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/FlinkCommonIT.java
 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/FlinkCommonIT.java
index 3f802bda82..16dbe33cc0 100644
--- 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/FlinkCommonIT.java
+++ 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/FlinkCommonIT.java
@@ -112,6 +112,15 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
     return true;
   }
 
+  /**
+   * Whether the catalog accepts arbitrary table properties, such as {@code 
'test key' = 'test
+   * value'}, at creation time. Catalogs that validate table properties 
against a fixed set reject
+   * them.
+   */
+  protected boolean supportsCustomTableProperties() {
+    return true;
+  }
+
   protected boolean defaultValueWithNullLiterals() {
     return false;
   }
@@ -315,16 +324,15 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
                   "CREATE TABLE %s "
                       + "(string_type STRING COMMENT 'string_type', "
                       + " double_type DOUBLE COMMENT 'double_type')"
-                      + " COMMENT '%s' WITH ("
-                      + "'%s' = '%s')",
-                  tableName, comment, key, value);
+                      + " COMMENT '%s'%s",
+                  tableName, comment, customTablePropertiesClause(key, value));
           TestUtils.assertTableResult(result, ResultKind.SUCCESS);
 
           Table table =
               
catalog.asTableCatalog().loadTable(NameIdentifier.of(databaseName, tableName));
           Assertions.assertNotNull(table);
           Assertions.assertEquals(comment, table.comment());
-          if (supportTablePropertiesOperation()) {
+          if (supportTablePropertiesOperation() && 
supportsCustomTableProperties()) {
             Assertions.assertEquals(value, table.properties().get(key));
           }
           Column[] columns =
@@ -369,9 +377,8 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
                   + " cc int,"
                   + "  PRIMARY KEY (aa,bb) NOT ENFORCED"
                   + ")"
-                  + " COMMENT '%s' WITH ("
-                  + "'%s' = '%s')",
-              tableName, comment, key, value);
+                  + " COMMENT '%s'%s",
+              tableName, comment, customTablePropertiesClause(key, value));
           Table table =
               
catalog.asTableCatalog().loadTable(NameIdentifier.of(databaseName, tableName));
           Assertions.assertEquals(1, table.index().length);
@@ -511,7 +518,9 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
                   NameIdentifier.of(databaseName, "test_desc_table"),
                   columns,
                   comment,
-                  ImmutableMap.of("k1", "v1"));
+                  supportsCustomTableProperties()
+                      ? ImmutableMap.of("k1", "v1")
+                      : ImmutableMap.of());
 
           Optional<org.apache.flink.table.catalog.Catalog> flinkCatalog =
               tableEnv.getCatalog(catalog.name());
@@ -730,10 +739,8 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
                   "CREATE TABLE %s "
                       + "(user_id BIGINT COMMENT 'USER_ID', "
                       + " order_amount INT COMMENT 'ORDER_AMOUNT')"
-                      + " COMMENT 'test comment'"
-                      + " WITH ("
-                      + "'%s' = '%s')",
-                  tableName, "test key", "test value");
+                      + " COMMENT 'test comment'%s",
+                  tableName, customTablePropertiesClause("test key", "test 
value"));
           TestUtils.assertTableResult(result, ResultKind.SUCCESS);
           result = sql("ALTER TABLE %s MODIFY order_amount INT COMMENT 'new 
comment2'", tableName);
           TestUtils.assertTableResult(result, ResultKind.SUCCESS);
@@ -1133,4 +1140,8 @@ public abstract class FlinkCommonIT extends FlinkEnvIT {
         true,
         supportDropCascade());
   }
+
+  private String customTablePropertiesClause(String key, String value) {
+    return supportsCustomTableProperties() ? String.format(" WITH ('%s' = 
'%s')", key, value) : "";
+  }
 }
diff --git 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/jdbc/FlinkJdbcMysqlCatalogIT.java
 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/jdbc/FlinkJdbcMysqlCatalogIT.java
index 4c08854cf5..77f0a4ea4b 100644
--- 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/jdbc/FlinkJdbcMysqlCatalogIT.java
+++ 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/integration/test/jdbc/FlinkJdbcMysqlCatalogIT.java
@@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableMap;
 import java.util.Map;
 import org.apache.flink.table.api.ValidationException;
 import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.credential.CredentialConstants;
 import org.apache.gravitino.credential.JdbcCredential;
 import org.apache.gravitino.flink.connector.integration.test.FlinkCommonIT;
@@ -57,6 +58,11 @@ public abstract class FlinkJdbcMysqlCatalogIT extends 
FlinkCommonIT {
     return false;
   }
 
+  @Override
+  protected boolean supportsCustomTableProperties() {
+    return false;
+  }
+
   @Override
   protected String defaultDatabaseName() {
     return MYSQL_CATALOG_MYSQL_IT.name();
@@ -208,4 +214,30 @@ public abstract class FlinkJdbcMysqlCatalogIT extends 
FlinkCommonIT {
                     catalogName, mysqlUrl, mysqlUsername, 
mysqlDefaultDatabase)),
         "Missing required options are:\n" + "\n" + "password");
   }
+
+  @Test
+  public void testCreateTableLikeDoesNotForwardConnectorOptions() {
+    String databaseName = "test_create_table_like_db";
+    doWithSchema(
+        currentCatalog(),
+        databaseName,
+        catalog -> {
+          sql("CREATE TABLE source_table (id INT COMMENT 'id', name STRING 
COMMENT 'name')");
+          // LIKE copies the options that getTable() exposes for the source 
table, which include the
+          // JDBC connection options the connector injects for scans.
+          sql("CREATE TABLE like_table LIKE source_table");
+
+          Map<String, String> properties =
+              catalog
+                  .asTableCatalog()
+                  .loadTable(NameIdentifier.of(databaseName, "like_table"))
+                  .properties();
+          Assertions.assertFalse(
+              
properties.containsKey(JdbcPropertiesConstants.FLINK_JDBC_PASSWORD));
+          Assertions.assertFalse(
+              
properties.containsKey(JdbcPropertiesConstants.FLINK_JDBC_TABLE_DATABASE_URL));
+        },
+        true,
+        supportDropCascade());
+  }
 }
diff --git 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/jdbc/AbstractJdbcPropertiesConverterTestSuite.java
 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/jdbc/AbstractJdbcPropertiesConverterTestSuite.java
index d89fbf5401..c40eb6277e 100644
--- 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/jdbc/AbstractJdbcPropertiesConverterTestSuite.java
+++ 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/jdbc/AbstractJdbcPropertiesConverterTestSuite.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.catalog.ObjectPath;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -168,4 +169,24 @@ public abstract class 
AbstractJdbcPropertiesConverterTestSuite {
 
     
Assertions.assertTrue(ex.getMessage().contains(JdbcPropertiesConstants.GRAVITINO_JDBC_URL));
   }
+
+  @Test
+  public void testInjectedTableOptionsDoNotBecomeGravitinoTableProperties() {
+    JdbcPropertiesConverter converter = getConverter(catalogProperties);
+    Map<String, String> flinkTableOptions =
+        new HashMap<>(
+            converter.toFlinkTableProperties(
+                converter.toFlinkCatalogProperties(catalogProperties),
+                ImmutableMap.of(),
+                new ObjectPath("db", "source_table")));
+    // CREATE TABLE ... LIKE copies the scan options alongside the user's own 
options.
+    flinkTableOptions.put("engine", "InnoDB");
+
+    Map<String, String> gravitinoProperties =
+        converter.toGravitinoTableProperties(flinkTableOptions);
+
+    Assertions.assertEquals(ImmutableMap.of("engine", "InnoDB"), 
gravitinoProperties);
+    Assertions.assertEquals(
+        password, 
flinkTableOptions.get(JdbcPropertiesConstants.FLINK_JDBC_PASSWORD));
+  }
 }
diff --git 
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/jdbc/JdbcPropertiesConverter.java
 
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/jdbc/JdbcPropertiesConverter.java
index cbcac2fce6..f4361d4e52 100644
--- 
a/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/jdbc/JdbcPropertiesConverter.java
+++ 
b/spark-connector/spark-common/src/main/java/org/apache/gravitino/spark/connector/jdbc/JdbcPropertiesConverter.java
@@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.spark.connector.PropertiesConverter;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
 
 public class JdbcPropertiesConverter implements PropertiesConverter {
 
@@ -86,7 +87,10 @@ public class JdbcPropertiesConverter implements 
PropertiesConverter {
       }
       return new HashMap<>();
     } else {
-      return new HashMap<>(properties);
+      Map<String, String> tableProperties = new HashMap<>(properties);
+      // Spark adds ownership to CREATE TABLE properties; JDBC ownership is 
not a table option.
+      
tableProperties.keySet().removeIf(TableCatalog.PROP_OWNER::equalsIgnoreCase);
+      return tableProperties;
     }
   }
 
diff --git 
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/jdbc/TestJdbcPropertiesConverter.java
 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/jdbc/TestJdbcPropertiesConverter.java
index 33b704f11f..7de04a6fda 100644
--- 
a/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/jdbc/TestJdbcPropertiesConverter.java
+++ 
b/spark-connector/spark-common/src/test/java/org/apache/gravitino/spark/connector/jdbc/TestJdbcPropertiesConverter.java
@@ -23,6 +23,8 @@ import com.google.common.collect.ImmutableMap;
 import java.util.Map;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestJdbcPropertiesConverter {
   private final JdbcPropertiesConverter jdbcPropertiesConverter =
@@ -51,6 +53,23 @@ public class TestJdbcPropertiesConverter {
         () -> instance.toGravitinoTableProperties(ImmutableMap.of("someKey", 
"value")));
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"owner", "OWNER"})
+  void testSparkOwnershipIsNotForwardedAsATableOption(String ownerKey) {
+    Map<String, String> properties =
+        ImmutableMap.of(ownerKey, "alice", "engine", "InnoDB", "unknown-key", 
"value");
+
+    Map<String, String> converted = 
jdbcPropertiesConverter.toGravitinoTableProperties(properties);
+
+    Assertions.assertEquals(ImmutableMap.of("engine", "InnoDB", "unknown-key", 
"value"), converted);
+    // Keep caller-owned input intact and forward unknown user keys for 
server-side validation.
+    Assertions.assertEquals("alice", properties.get(ownerKey));
+    Assertions.assertTrue(
+        jdbcPropertiesConverter
+            .toGravitinoTableProperties(ImmutableMap.of(ownerKey, "alice"))
+            .isEmpty());
+  }
+
   @Test
   void testCatalogProperties() {
     String url = "jdbc-url";

Reply via email to