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

jerryshao 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 c7a4551b09 [#14484] fix(jdbc): validate configuration and classify 
PostgreSQL failures (#14485)
c7a4551b09 is described below

commit c7a4551b092e2f2dc2cbee99ca76735e2d8d770f
Author: Qi Yu <[email protected]>
AuthorDate: Tue Nov 3 11:15:10 2026 +0800

    [#14484] fix(jdbc): validate configuration and classify PostgreSQL failures 
(#14485)
    
    ### What changes were proposed in this pull request?
    
    Validate canonical and native pool settings before pool initialization
    or source verification, independently of whether verification is
    enabled. Failed catalog creation rolls back the stored entity and
    secrets; alteration validates before writing secrets or updating the
    entity. Classify PostgreSQL permission failures correctly and reject NUL
    comments/defaults before DDL. Failed-create privilege diagnosis releases
    the original connection before probing schema visibility. If diagnosis
    fails, its exception retains the original create failure as a suppressed
    exception; PostgreSQL exception conversion preserves the underlying SQL
    causes.
    
    ### Why are the changes needed?
    
    Invalid settings must not be persisted, and invalid input or missing
    privileges must not look like source connectivity failures.
    
    Fixes #14484
    Fixes #14502
    
    Consolidates #14513 into this PR.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Invalid driver, boolean and pool settings fail with actionable errors;
    permission failures remain forbidden and NUL input is rejected as
    invalid input. Adds a default configuration-only catalog validation
    hook. No source connection is opened by static validation.
    
    ### How was this patch tested?
    
    Previous full runs: coreUnitTest: 2,692 cases, 2 skipped; JDBC common:
    132; PostgreSQL: 92, Docker enabled. No failures. This follow-up: 21
    targeted cases passed, including exception-cause preservation,
    diagnostic connection/query failures, and the live PostgreSQL
    least-privilege create regression. Independent cumulative review and
    prior component mutation regressions verified. Deployed REST/Spark/Trino
    end-to-end is not claimed.
---
 .../apache/gravitino/catalog/jdbc/JdbcCatalog.java |  32 +++-
 .../catalog/jdbc/JdbcCatalogOperations.java        |  25 +---
 .../jdbc/JdbcCatalogPropertiesMetadata.java        |  19 ++-
 .../gravitino/catalog/jdbc/config/JdbcConfig.java  |  14 +-
 .../catalog/jdbc/utils/DataSourceUtils.java        | 144 +++++++++++-------
 .../jdbc/TestJdbcCatalogPropertiesMetadata.java    | 104 ++++++++++++-
 .../jdbc/utils/TestDataSourceUrlValidation.java    |   4 +-
 .../jdbc/utils/TestDataSourceValidation.java       |  24 +++
 .../postgresql/PostgreSQLCatalogOperations.java    |   3 +-
 .../catalog/postgresql/PostgreSqlCatalog.java      |   4 +-
 .../PostgreSqlColumnDefaultValueConverter.java     |  12 ++
 .../converter/PostgreSqlExceptionConverter.java    |  21 +--
 .../operation/PostgreSqlTableOperations.java       |  85 ++++++++++-
 .../TestPostgreSqlCatalogConfiguration.java        |  28 ++++
 .../TestPostgreSqlColumnDefaultValueConverter.java |  14 ++
 .../TestPostgreSqlExceptionConverter.java          |  79 ++++++++++
 .../TestPostgreSqlTableCreatePrivileges.java       | 165 +++++++++++++++++++++
 .../operation/TestPostgreSqlTableOperations.java   | 151 +++++++++++++++++++
 ...TestPostgreSqlTableOperationsSqlGeneration.java | 105 +++++++++++++
 .../apache/gravitino/catalog/CatalogManager.java   |  43 ++++--
 .../apache/gravitino/connector/BaseCatalog.java    |  23 +++
 .../java/org/apache/gravitino/TestCatalog.java     |  10 ++
 .../gravitino/catalog/TestCatalogManager.java      | 100 +++++++++++++
 docs/jdbc-catalog-connection-validation.md         |   7 +-
 24 files changed, 1105 insertions(+), 111 deletions(-)

diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
index 95bbd66a92..556f7a826a 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalog.java
@@ -31,6 +31,7 @@ import 
org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcDatabaseOperations;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
+import org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
 import org.apache.gravitino.connector.BaseCatalog;
 import org.apache.gravitino.connector.CatalogOperations;
 import org.apache.gravitino.connector.PropertiesMetadata;
@@ -86,6 +87,32 @@ public abstract class JdbcCatalog extends 
BaseCatalog<JdbcCatalog> {
     return false;
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public JdbcCatalog withCatalogConf(Map<String, String> configuration) {
+    return super.withCatalogConf(prepareConfiguration(configuration));
+  }
+
+  /**
+   * Normalizes and validates provider-specific settings for initialization 
and proposed changes.
+   *
+   * @param configuration the complete proposed configuration, which must not 
be mutated
+   * @return the normalized settings; the default returns the original map
+   * @throws IllegalArgumentException if provider-specific settings are 
inconsistent
+   */
+  protected Map<String, String> prepareConfiguration(Map<String, String> 
configuration) {
+    return configuration;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public void validateConfiguration(Map<String, String> configuration) {
+    JdbcCatalogPropertiesMetadata metadata =
+        (JdbcCatalogPropertiesMetadata) catalogPropertiesMetadata();
+    DataSourceUtils.validateConfiguration(
+        new 
JdbcConfig(metadata.jdbcProperties(prepareConfiguration(configuration))));
+  }
+
   /**
    * Returns the effective {@link JdbcConfig#VERIFY_ON_CREATE 
verify-on-create} setting for this
    * catalog. A relational JDBC catalog verifies its backend at create time by 
default; an
@@ -100,7 +127,10 @@ public abstract class JdbcCatalog extends 
BaseCatalog<JdbcCatalog> {
     if (properties == null) {
       return true;
     }
-    return new JdbcConfig(properties).getVerifyOnCreate();
+    JdbcCatalogPropertiesMetadata metadata =
+        (JdbcCatalogPropertiesMetadata) catalogPropertiesMetadata();
+    return new 
JdbcConfig(metadata.jdbcProperties(prepareConfiguration(properties)))
+        .getVerifyOnCreate();
   }
 
   /**
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
index 631b53db12..ebe9679478 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
@@ -18,8 +18,6 @@
  */
 package org.apache.gravitino.catalog.jdbc;
 
-import static org.apache.gravitino.connector.BaseCatalog.CATALOG_BYPASS_PREFIX;
-
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Maps;
@@ -81,7 +79,6 @@ import 
org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
-import org.apache.gravitino.utils.MapUtils;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -168,14 +165,7 @@ public class JdbcCatalogOperations
     this.jdbcTablePropertiesMetadata =
         (JdbcTablePropertiesMetadata) 
propertiesMetadata.tablePropertiesMetadata();
 
-    // Key format like gravitino.bypass.a.b
-    Map<String, String> prefixMap = MapUtils.getPrefixMap(conf, 
CATALOG_BYPASS_PREFIX);
-
-    // Hold keys that lie in JDBC_IMMUTABLE_PROPERTIES
-    Map<String, String> gravitinoConfig =
-        this.jdbcCatalogPropertiesMetadata.transformProperties(conf);
-    Map<String, String> resultConf = Maps.newHashMap(prefixMap);
-    resultConf.putAll(gravitinoConfig);
+    Map<String, String> resultConf = 
this.jdbcCatalogPropertiesMetadata.jdbcProperties(conf);
 
     this.jdbcConfig = new JdbcConfig(resultConf);
     this.jdbcUrl = jdbcConfig.getJdbcUrl();
@@ -221,18 +211,15 @@ public class JdbcCatalogOperations
       return null;
     }
 
-    return DriverManager.getDriver(jdbcUrl);
+    return DataSourceUtils.configuredDriver(jdbcConfig);
   }
 
   /**
-   * Initializes the configured driver class so that it registers itself with 
{@link DriverManager}
-   * before {@link #getDriver()} looks it up.
+   * Initializes the configured driver for provider overrides that still use 
{@link DriverManager}.
    *
-   * <p>{@code DriverManager} only discovers drivers on the server classpath 
by itself. A driver in
-   * the catalog's own libs directory registers only when its class is 
initialized, which the lazy
-   * connection pool does on its first connection. The create-time probe runs 
before that, so
-   * without this the lookup fails with "No suitable driver". The class is 
loaded by this catalog's
-   * class loader, which is also the loader {@code DriverManager} checks the 
caller against.
+   * <p>The default {@link #getDriver()} instantiates the configured driver 
directly. Legacy
+   * overrides, such as {@link 
MySQLProtocolCompatibleCatalogOperations#getDriver()}, instead use
+   * DriverManager lookup, which requires drivers in catalog-specific libs to 
register first.
    */
   private void ensureDriverRegistered() {
     String driverClassName = jdbcConfig.getJdbcDriver();
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 be57c26f3e..6b79ec3c5d 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
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.catalog.jdbc;
 
+import static org.apache.gravitino.connector.BaseCatalog.CATALOG_BYPASS_PREFIX;
 import static 
org.apache.gravitino.connector.PropertyEntry.booleanPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.integerPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.longOptionalPropertyEntry;
@@ -32,6 +33,7 @@ import java.util.Map;
 import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
 import org.apache.gravitino.connector.BaseCatalogPropertiesMetadata;
 import org.apache.gravitino.connector.PropertyEntry;
+import org.apache.gravitino.utils.MapUtils;
 
 public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata {
 
@@ -129,7 +131,7 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 false /* required */,
                 false /* immutable */,
                 JdbcConfig.TEST_ON_BORROW.getDefaultValue(),
-                true /* hidden */,
+                false /* hidden */,
                 false /* reserved */),
             longOptionalPropertyEntry(
                 JdbcConfig.CONNECT_TIMEOUT_MS.getKey(),
@@ -143,7 +145,7 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
                 false /* required */,
                 false /* immutable */,
                 JdbcConfig.VERIFY_ON_CREATE.getDefaultValue(),
-                true /* hidden */,
+                false /* hidden */,
                 false /* reserved */));
     PROPERTIES_METADATA =
         ImmutableMap.<String, PropertyEntry<?>>builder()
@@ -156,6 +158,19 @@ public class JdbcCatalogPropertiesMetadata extends 
BaseCatalogPropertiesMetadata
     return PROPERTIES_METADATA;
   }
 
+  /**
+   * Returns the effective JDBC settings used by validation and operation 
initialization.
+   *
+   * @param properties the catalog configuration
+   * @return a new map with bypass prefixes removed and canonical JDBC 
properties taking precedence
+   */
+  public Map<String, String> jdbcProperties(Map<String, String> properties) {
+    Map<String, String> result =
+        Maps.newHashMap(MapUtils.getPrefixMap(properties, 
CATALOG_BYPASS_PREFIX));
+    result.putAll(transformProperties(properties));
+    return result;
+  }
+
   public Map<String, String> transformProperties(Map<String, String> 
properties) {
     Map<String, String> result = Maps.newHashMap();
     properties.forEach(
diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
index d6426e6f54..c13d56aadc 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/config/JdbcConfig.java
@@ -198,7 +198,7 @@ public class JdbcConfig extends Config {
   }
 
   public boolean getTestOnBorrow() {
-    return get(TEST_ON_BORROW);
+    return strictBoolean(TEST_ON_BORROW);
   }
 
   public long getMaxWaitMs() {
@@ -220,11 +220,21 @@ public class JdbcConfig extends Config {
    * @return {@code true} to verify the backend at create (default); {@code 
false} to skip the check
    */
   public boolean getVerifyOnCreate() {
-    return get(VERIFY_ON_CREATE);
+    return strictBoolean(VERIFY_ON_CREATE);
   }
 
   public JdbcConfig(Map<String, String> properties) {
     super(false);
     loadFromMap(properties, k -> true);
   }
+
+  private boolean strictBoolean(ConfigEntry<Boolean> entry) {
+    String value = getAllConfig().get(entry.getKey());
+    if (value != null
+        && !"true".equalsIgnoreCase(value.trim())
+        && !"false".equalsIgnoreCase(value.trim())) {
+      throw new IllegalArgumentException(entry.getKey() + " must be true or 
false");
+    }
+    return get(entry);
+  }
 }
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 b2f61e19ff..de1aa71997 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
@@ -18,6 +18,7 @@
  */
 package org.apache.gravitino.catalog.jdbc.utils;
 
+import java.sql.Driver;
 import java.sql.SQLException;
 import java.time.Duration;
 import java.util.List;
@@ -70,6 +71,22 @@ public class DataSourceUtils {
 
   public static DataSource createDataSource(JdbcConfig jdbcConfig)
       throws GravitinoRuntimeException {
+    validateConfiguration(jdbcConfig);
+    try {
+      return createDBCPDataSource(jdbcConfig);
+    } catch (Exception exception) {
+      throw new GravitinoRuntimeException(exception, "Error creating 
datasource");
+    }
+  }
+
+  /**
+   * Validates JDBC settings without opening a connection or retaining a pool.
+   *
+   * @param jdbcConfig the effective JDBC configuration
+   * @throws IllegalArgumentException if a boolean, pool bound, driver or URL 
is invalid
+   * @throws GravitinoRuntimeException if a dangerous URL or driver setting is 
rejected
+   */
+  public static void validateConfiguration(JdbcConfig jdbcConfig) {
     // H2 is bundled as an embedded backend and must not be used through 
user-facing catalog
     // configuration. Its INIT parameter allows arbitrary SQL (and Java code 
via CREATE ALIAS)
     // to execute at connection time, and the H2 driver class must also be 
blocked to prevent
@@ -88,51 +105,94 @@ public class DataSourceUtils {
     // config to the factory. Kept outside the try below so the specific 
reason surfaces directly
     // instead of being wrapped as "Error creating datasource".
     rejectUnsafePoolProperties(jdbcConfig.getAllConfig());
+    // Validate user configuration before the data-source builder wraps 
infrastructure failures.
+    jdbcConfig.getVerifyOnCreate();
+    jdbcConfig.getTestOnBorrow();
+    jdbcConfig.getConnectTimeoutMs();
+    jdbcConfig.getMaxWaitMs();
+    jdbcConfig.getPoolMaxIdle();
+    int minSize = jdbcConfig.getPoolMinSize();
+    int maxSize = jdbcConfig.getPoolMaxSize();
+    if (minSize > maxSize) {
+      throw new IllegalArgumentException("jdbc.pool.min-size must not exceed 
jdbc.pool.max-size");
+    }
     try {
-      return createDBCPDataSource(jdbcConfig);
-    } catch (Exception exception) {
-      if (isDriverClassMissing(exception)) {
-        // Some JDBC drivers are not packaged with Gravitino and must be 
installed by the user.
-        // Surface a clear, actionable message naming the driver instead of a 
raw
-        // ClassNotFoundException.
-        throw new GravitinoRuntimeException(
-            exception,
-            "JDBC driver class '%s' was not found on the catalog classpath. 
Install the driver "
-                + "JAR in the catalog's libs directory and recreate the 
catalog.",
-            jdbcConfig.getJdbcDriver());
+      JdbcUrlUtils.validateJdbcConfig(
+          jdbcConfig.getJdbcDriver(), jdbcConfig.getJdbcUrl(), 
jdbcConfig.getAllConfig());
+    } catch (RuntimeException e) {
+      throw new GravitinoRuntimeException(e, "Error creating datasource");
+    }
+    configuredDriver(jdbcConfig);
+    Properties properties = getProperties(jdbcConfig);
+    try {
+      validateNativePoolProperties(properties);
+    } catch (Exception invalidProperties) {
+      // Identify a malformed native alias without printing unrelated settings 
or credentials.
+      for (String key : properties.stringPropertyNames()) {
+        Properties singleProperty = new Properties();
+        singleProperty.setProperty(key, properties.getProperty(key));
+        try {
+          validateNativePoolProperties(singleProperty);
+        } catch (Exception invalidProperty) {
+          throw new IllegalArgumentException(
+              "Invalid native JDBC connection-pool property '"
+                  + key
+                  + "': "
+                  + invalidProperty.getMessage(),
+              invalidProperty);
+        }
       }
-      throw new GravitinoRuntimeException(exception, "Error creating 
datasource");
+      throw new IllegalArgumentException(
+          "Invalid native JDBC connection-pool properties; check 
gravitino.bypass.* settings",
+          invalidProperties);
+    }
+  }
+
+  private static void validateNativePoolProperties(Properties properties) 
throws Exception {
+    // initialSize and class-loading properties were rejected before this 
method. The factory
+    // therefore only parses an uninitialized configuration bean; no 
connections are opened.
+    try (BasicDataSource ignored = 
BasicDataSourceFactory.createDataSource(properties)) {
+      // Always close the temporary bean, including on the diagnostic path for 
individual keys.
     }
   }
 
   /**
-   * Returns whether the given throwable chain indicates a missing JDBC driver 
class. DBCP2 reports
-   * an absent driver as a {@link ClassNotFoundException} (sometimes wrapped 
in a {@link
-   * SQLException} whose message is "Cannot load JDBC driver class ..."), so 
both the exception
-   * chain and that message are checked.
+   * Creates the configured driver and verifies that it accepts the configured 
JDBC URL.
    *
-   * @param throwable the throwable thrown while creating the data source
-   * @return {@code true} if the failure is due to a driver class that cannot 
be loaded
+   * @param config the JDBC catalog configuration
+   * @return the configured JDBC driver
+   * @throws IllegalArgumentException if the driver is absent, invalid, or 
incompatible with the URL
    */
-  private static boolean isDriverClassMissing(Throwable throwable) {
-    for (Throwable current = throwable; current != null; current = 
current.getCause()) {
-      if (current instanceof ClassNotFoundException || current instanceof 
NoClassDefFoundError) {
-        return true;
-      }
-      String message = current.getMessage();
-      if (message != null && message.contains("Cannot load JDBC driver 
class")) {
-        return true;
+  public static Driver configuredDriver(JdbcConfig config) {
+    String name = config.getJdbcDriver();
+    ClassLoader loader = Thread.currentThread().getContextClassLoader();
+    if (loader == null) {
+      loader = DataSourceUtils.class.getClassLoader();
+    }
+    try {
+      Class<?> type = Class.forName(name, false, loader);
+      if (!Driver.class.isAssignableFrom(type)) {
+        throw new IllegalArgumentException(
+            "jdbc-driver '" + name + "' must implement java.sql.Driver");
       }
-      if (current.getCause() == current) {
-        break;
+      Driver driver = 
type.asSubclass(Driver.class).getDeclaredConstructor().newInstance();
+      if (!driver.acceptsURL(config.getJdbcUrl())) {
+        throw new IllegalArgumentException("jdbc-driver '" + name + "' does 
not accept jdbc-url");
       }
+      return driver;
+    } catch (ClassNotFoundException e) {
+      throw new IllegalArgumentException(
+          "JDBC driver class '"
+              + name
+              + "' was not found on the catalog classpath. Install the driver 
JAR in the catalog's libs directory.",
+          e);
+    } catch (ReflectiveOperationException | SQLException | LinkageError e) {
+      throw new IllegalArgumentException(
+          "Invalid jdbc-driver '" + name + "': driver must be installed and 
usable", e);
     }
-    return false;
   }
 
   private static DataSource createDBCPDataSource(JdbcConfig jdbcConfig) throws 
Exception {
-    JdbcUrlUtils.validateJdbcConfig(
-        jdbcConfig.getJdbcDriver(), jdbcConfig.getJdbcUrl(), 
jdbcConfig.getAllConfig());
     // Keep DBCP's default Connection.isValid() validation unless a 
validationQuery is explicitly
     // configured. DBCP caches a validation query as a prepared statement, and 
MySQL Connector/J
     // switches back to the database captured at prepare time when executing 
it. That switch fails
@@ -142,11 +202,6 @@ public class DataSourceUtils {
     String jdbcUrl = jdbcConfig.getJdbcUrl();
     basicDataSource.setUrl(jdbcUrl);
     String driverClassName = jdbcConfig.getJdbcDriver();
-    // DBCP2 loads the driver lazily on the first connection, so a missing 
driver would otherwise
-    // only surface much later (and as an opaque error). Verify the driver 
class is on the catalog
-    // classpath now, so catalog creation fails fast with a clear, actionable 
message. Loaded
-    // without initialization; the H2 driver is already rejected above.
-    verifyDriverPresent(driverClassName);
     basicDataSource.setDriverClassName(driverClassName);
     String userName = jdbcConfig.getUsername();
     basicDataSource.setUsername(userName);
@@ -181,23 +236,6 @@ public class DataSourceUtils {
     return basicDataSource;
   }
 
-  /**
-   * Verifies that the configured JDBC driver class is loadable from the 
catalog classpath. The
-   * class is resolved without initialization using the catalog's context 
class loader (falling back
-   * to this class's loader), so an absent driver fails catalog creation 
immediately rather than on
-   * the first connection.
-   *
-   * @param driverClassName the fully qualified JDBC driver class name
-   * @throws ClassNotFoundException if the driver class is not on the catalog 
classpath
-   */
-  private static void verifyDriverPresent(String driverClassName) throws 
ClassNotFoundException {
-    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    if (classLoader == null) {
-      classLoader = DataSourceUtils.class.getClassLoader();
-    }
-    Class.forName(driverClassName, false, classLoader);
-  }
-
   private static Properties getProperties(JdbcConfig jdbcConfig) {
     Properties properties = new Properties();
     properties.putAll(jdbcConfig.getAllConfig());
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 60a4e95a34..dfadcc6e03 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
@@ -19,9 +19,13 @@
 package org.apache.gravitino.catalog.jdbc;
 
 import com.google.common.collect.ImmutableMap;
+import java.sql.Connection;
+import java.sql.SQLException;
 import java.time.Instant;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Properties;
 import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
@@ -47,9 +51,98 @@ 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;
+import org.sqlite.JDBC;
 
 public class TestJdbcCatalogPropertiesMetadata {
 
+  @Test
+  void testConfigurationValidationDoesNotDependOnBackendVerification() {
+    for (Map<String, String> invalid :
+        List.of(
+            Map.of("jdbc-driver", "java.lang.String"),
+            Map.of("jdbc-driver", "missing.Driver"),
+            Map.of("jdbc-url", "jdbc:postgresql://localhost/unused"),
+            Map.of("jdbc.pool.test-on-borrow", "maybe"),
+            Map.of("verify-on-create", "yes"),
+            Map.of("jdbc.pool.min-size", "0"),
+            Map.of("jdbc.pool.max-size", "-1"),
+            Map.of("jdbc.pool.min-size", "5", "jdbc.pool.max-size", "2"),
+            Map.of("jdbc.pool.connect-timeout-ms", "2147483648"))) {
+      Map<String, String> properties = poolWaitProperties();
+      properties.put("verify-on-create", "false");
+      properties.putAll(invalid);
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> new TestJdbcCatalog().validateConfiguration(properties),
+          invalid.toString());
+    }
+    Map<String, String> unavailable = poolWaitProperties();
+    unavailable.put("jdbc-driver", NoConnectionDriver.class.getName());
+    unavailable.put("jdbc-url", "jdbc:sqlite:unavailable");
+    unavailable.put("verify-on-create", "false");
+    // A static settings check must not attempt a connection to this 
unavailable backend.
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(unavailable));
+  }
+
+  @Test
+  void testValidationUsesTheSameBypassPrecedenceAsOperations() {
+    Map<String, String> properties = poolWaitProperties();
+    properties.put("verify-on-create", "false");
+    properties.put("gravitino.bypass.jdbc.pool.min-size", "5");
+    properties.put("jdbc.pool.max-size", "2");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> new TestJdbcCatalog().validateConfiguration(properties));
+    properties.put("jdbc.pool.min-size", "1");
+    // Canonical JDBC settings override prefixed settings.
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(properties));
+    properties.put("url", "ignored property");
+    Assertions.assertDoesNotThrow(() -> new 
TestJdbcCatalog().validateConfiguration(properties));
+    Assertions.assertFalse(
+        buildCatalog(Map.of("gravitino.bypass.verify-on-create", "false"))
+            .shouldValidateOnCreate());
+    Assertions.assertTrue(
+        buildCatalog(
+                Map.of("verify-on-create", "true", 
"gravitino.bypass.verify-on-create", "false"))
+            .shouldValidateOnCreate());
+    properties.put("gravitino.bypass.initialSize", "1");
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class,
+        () -> new TestJdbcCatalog().validateConfiguration(properties));
+  }
+
+  @Test
+  void testNativePoolPropertySyntaxIsValidatedWithoutBackendVerification() {
+    for (String key : List.of("maxWaitMillis", "maxIdle")) {
+      Map<String, String> properties = poolWaitProperties();
+      properties.put("verify-on-create", "false");
+      properties.put("gravitino.bypass." + key, "not-a-number");
+      IllegalArgumentException error =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> new TestJdbcCatalog().validateConfiguration(properties));
+      Assertions.assertTrue(error.getMessage().contains(key));
+    }
+  }
+
+  @Test
+  void testBooleanConnectionSettingsAreVisibleAndStrict() {
+    JdbcCatalogPropertiesMetadata metadata = new 
JdbcCatalogPropertiesMetadata();
+    
Assertions.assertFalse(metadata.isHiddenProperty(JdbcConfig.VERIFY_ON_CREATE.getKey()));
+    
Assertions.assertFalse(metadata.isHiddenProperty(JdbcConfig.TEST_ON_BORROW.getKey()));
+    for (String value : new String[] {"yes", "1", "perhaps", ""}) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              
buildCatalog(ImmutableMap.of(JdbcConfig.VERIFY_ON_CREATE.getKey(), value))
+                  .shouldValidateOnCreate());
+    }
+    Assertions.assertTrue(
+        buildCatalog(ImmutableMap.of("verify-on-create", 
"TRUE")).shouldValidateOnCreate());
+    Assertions.assertFalse(
+        buildCatalog(ImmutableMap.of("verify-on-create", 
"FALSE")).shouldValidateOnCreate());
+  }
+
   @Test
   public void testPoolSizePropertiesAreNotHidden() {
     JdbcCatalogPropertiesMetadata propertiesMetadata = new 
JdbcCatalogPropertiesMetadata();
@@ -113,7 +206,7 @@ public class TestJdbcCatalogPropertiesMetadata {
     properties.put(JdbcConfig.POOL_MAX_WAIT_MS.getKey(), value);
     try (JdbcCatalogOperations operations = poolWaitOperations()) {
       Assertions.assertThrows(
-          GravitinoRuntimeException.class,
+          IllegalArgumentException.class,
           () ->
               operations.initialize(
                   properties, poolWaitCatalogInfo(properties), new 
TestJdbcCatalog()));
@@ -297,6 +390,15 @@ public class TestJdbcCatalogPropertiesMetadata {
     }
   }
 
+  /** A driver that proves settings validation does not open a connection. */
+  public static class NoConnectionDriver extends JDBC {
+    /** {@inheritDoc} */
+    @Override
+    public Connection connect(String url, Properties properties) throws 
SQLException {
+      throw new AssertionError("Configuration validation must not open a 
connection");
+    }
+  }
+
   private static class TestJdbcCatalog extends JdbcCatalog {
     @Override
     public String shortName() {
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
index d11e87a179..c40f29cc35 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
@@ -453,9 +453,9 @@ public class TestDataSourceUrlValidation {
     properties.put(JdbcConfig.USERNAME.getKey(), "test");
     properties.put(JdbcConfig.PASSWORD.getKey(), "test");
 
-    GravitinoRuntimeException gre =
+    IllegalArgumentException gre =
         Assertions.assertThrows(
-            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+            IllegalArgumentException.class, () -> 
DataSourceUtils.createDataSource(properties));
     Assertions.assertTrue(
         gre.getMessage().contains(missingDriver),
         "message should name the missing driver: " + gre.getMessage());
diff --git 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
index 40db43de67..95e21e5088 100644
--- 
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
+++ 
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceValidation.java
@@ -27,6 +27,8 @@ import java.sql.SQLFeatureNotSupportedException;
 import java.sql.Statement;
 import java.time.Duration;
 import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Properties;
 import javax.annotation.Nullable;
@@ -42,6 +44,28 @@ import org.sqlite.SQLiteConnection;
 /** Tests default driver validation and explicitly configured SQL validation. 
*/
 public class TestDataSourceValidation {
 
+  @Test
+  void testInvalidConfigurationFailsBeforeConnection() {
+    for (Map<String, String> invalid :
+        List.of(
+            Map.of("jdbc-driver", "java.lang.String"),
+            Map.of("jdbc-driver", "missing.Driver"),
+            Map.of("jdbc-url", "jdbc:postgresql://localhost/db"),
+            Map.of("verify-on-create", "yes"),
+            Map.of("jdbc.pool.test-on-borrow", "maybe"),
+            Map.of("jdbc.pool.min-size", "0"),
+            Map.of("jdbc.pool.max-size", "-1"),
+            Map.of("jdbc.pool.min-size", "5", "jdbc.pool.max-size", "2"),
+            Map.of("jdbc.pool.max-wait-ms", "0"),
+            Map.of("jdbc.pool.connect-timeout-ms", "2147483648"))) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(IllegalArgumentException.class, () -> 
createDataSource(invalid));
+      Assertions.assertTrue(
+          exception.getMessage().toLowerCase(Locale.ROOT).contains("jdbc")
+              || exception.getMessage().contains("verify-on-create"));
+    }
+  }
+
   /** Verifies default validation preserves a live physical SQLite connection 
across borrows. */
   @Test
   public void testReuseWithDefaultValidation() throws SQLException {
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
index b04ac0dad0..3539039058 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSQLCatalogOperations.java
@@ -19,7 +19,6 @@
 package org.apache.gravitino.catalog.postgresql;
 
 import java.sql.Driver;
-import java.sql.DriverManager;
 import java.sql.SQLException;
 import org.apache.gravitino.catalog.jdbc.JdbcCatalogOperations;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
@@ -46,6 +45,6 @@ public class PostgreSQLCatalogOperations extends 
JdbcCatalogOperations {
 
   @Override
   protected Driver getDriver() throws SQLException {
-    return DriverManager.getDriver("jdbc:postgresql://dummy_address:12345/");
+    return super.getDriver();
   }
 }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
index ca8ec349ef..d7be0ab429 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/PostgreSqlCatalog.java
@@ -42,8 +42,8 @@ public class PostgreSqlCatalog extends JdbcCatalog {
 
   /** {@inheritDoc} */
   @Override
-  public JdbcCatalog withCatalogConf(Map<String, String> conf) {
-    return super.withCatalogConf(resolveJdbcDatabase(conf, 
PostgreSqlCatalog::databaseFromUrl));
+  protected Map<String, String> prepareConfiguration(Map<String, String> conf) 
{
+    return resolveJdbcDatabase(conf, PostgreSqlCatalog::databaseFromUrl);
   }
 
   @Override
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
index 65e16256a4..b115aa6fad 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlColumnDefaultValueConverter.java
@@ -27,6 +27,7 @@ import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.annotation.Nullable;
 import 
org.apache.gravitino.catalog.jdbc.converter.JdbcColumnDefaultValueConverter;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcTypeConverter;
 import org.apache.gravitino.rel.expressions.Expression;
@@ -73,6 +74,17 @@ public class PostgreSqlColumnDefaultValueConverter extends 
JdbcColumnDefaultValu
     }
   }
 
+  /** {@inheritDoc} */
+  @Override
+  @Nullable
+  public String fromGravitino(Expression defaultValue) {
+    String sql = super.fromGravitino(defaultValue);
+    if (sql != null && sql.indexOf('\0') >= 0) {
+      throw new IllegalArgumentException("PostgreSQL column default must not 
contain U+0000 (NUL)");
+    }
+    return sql;
+  }
+
   /**
    * Doubles single quotes only. With {@code standard_conforming_strings} on, 
the default since
    * PostgreSQL 9.1, a backslash in a string literal is an ordinary character.
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
index 7934308a27..e9c721fcab 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/converter/PostgreSqlExceptionConverter.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog.postgresql.converter;
 import java.sql.SQLException;
 import org.apache.gravitino.catalog.jdbc.converter.JdbcExceptionConverter;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -35,6 +36,7 @@ public class PostgreSqlExceptionConverter extends 
JdbcExceptionConverter {
   private static final String INVALID_SCHEMA_NAME = "3D000";
   private static final String INVALID_SCHEMA = "3F000";
   private static final String UNDEFINED_TABLE = "42P01";
+  private static final String INSUFFICIENT_PRIVILEGE = "42501";
 
   /**
    * SQLSTATE '08' is the class code of connection exceptions See <a
@@ -43,34 +45,35 @@ public class PostgreSqlExceptionConverter extends 
JdbcExceptionConverter {
    */
   private static final String CONNECTION_EXCEPTION = "08";
 
-  @SuppressWarnings("FormatStringAnnotation")
   @Override
   public GravitinoRuntimeException toGravitinoException(SQLException se) {
     if (null != se.getSQLState()) {
       switch (se.getSQLState()) {
+        case INSUFFICIENT_PRIVILEGE:
+          return new ForbiddenException(se, "%s", se.getMessage());
         case DUPLICATE_DATABASE:
         case DUPLICATE_SCHEMA:
-          return new SchemaAlreadyExistsException(se.getMessage(), se);
+          return new SchemaAlreadyExistsException(se, "%s", se.getMessage());
         case DUPLICATE_TABLE:
-          return new TableAlreadyExistsException(se.getMessage(), se);
+          return new TableAlreadyExistsException(se, "%s", se.getMessage());
         case INVALID_SCHEMA_NAME:
         case INVALID_SCHEMA:
-          return new NoSuchSchemaException(se.getMessage(), se);
+          return new NoSuchSchemaException(se, "%s", se.getMessage());
         case UNDEFINED_TABLE:
-          return new NoSuchTableException(se.getMessage(), se);
+          return new NoSuchTableException(se, "%s", se.getMessage());
         default:
           {
             if (se.getSQLState().startsWith(CONNECTION_EXCEPTION)) {
-              return new ConnectionFailedException(se.getMessage(), se);
+              return new ConnectionFailedException(se, "%s", se.getMessage());
             }
-            return new GravitinoRuntimeException(se.getMessage(), se);
+            return new GravitinoRuntimeException(se, "%s", se.getMessage());
           }
       }
     } else {
       if (se.getMessage() != null && se.getMessage().contains("password 
authentication failed")) {
-        return new ConnectionFailedException(se.getMessage(), se);
+        return new ConnectionFailedException(se, "%s", se.getMessage());
       }
-      return new GravitinoRuntimeException(se.getMessage(), se);
+      return new GravitinoRuntimeException(se, "%s", se.getMessage());
     }
   }
 }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
index 01c8438503..fa42e943eb 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/main/java/org/apache/gravitino/catalog/postgresql/operation/PostgreSqlTableOperations.java
@@ -26,6 +26,7 @@ import com.google.common.collect.Lists;
 import java.nio.charset.StandardCharsets;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.util.ArrayList;
@@ -51,6 +52,7 @@ import 
org.apache.gravitino.catalog.jdbc.operation.DatabaseOperation;
 import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
 import org.apache.gravitino.catalog.jdbc.operation.RequireDatabaseOperation;
 import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchColumnException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
@@ -60,6 +62,7 @@ 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.literals.Literals;
+import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.types.Types;
@@ -102,6 +105,47 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     this.schemaOperations = (PostgreSqlSchemaOperations) databaseOperation;
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public void create(
+      String schema,
+      String tableName,
+      JdbcColumn[] columns,
+      @Nullable String comment,
+      Map<String, String> properties,
+      @Nullable Transform[] partitioning,
+      Distribution distribution,
+      Index[] indexes,
+      @Nullable SortOrder[] sortOrders) {
+    try {
+      super.create(
+          schema,
+          tableName,
+          columns,
+          comment,
+          properties,
+          partitioning,
+          distribution,
+          indexes,
+          sortOrders);
+    } catch (NoSuchSchemaException e) {
+      // PostgreSQL removes unusable schemas from search_path. An unqualified 
CREATE can therefore
+      // report 3F000 even when the requested schema exists but the source 
role has no USAGE.
+      boolean lacksUsage;
+      try {
+        lacksUsage = schemaLacksUsage(schema);
+      } catch (RuntimeException diagnosisFailure) {
+        diagnosisFailure.addSuppressed(e);
+        throw diagnosisFailure;
+      }
+      if (lacksUsage) {
+        throw new ForbiddenException(
+            e, "Insufficient source privileges for schema %s: USAGE is 
required", schema);
+      }
+      throw e;
+    }
+  }
+
   @Override
   public List<String> listTables(String schemaName) throws 
NoSuchSchemaException {
     try (Connection connection = getConnection(schemaName)) {
@@ -207,7 +251,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
           .append(TABLE_COMMENT)
           .append(quotedIdentifier(tableName))
           .append(IS)
-          .append(escapeSqlLiteral(comment, '\''))
+          .append(escapeSqlLiteral(validateText(comment, "table comment"), 
'\''))
           .append("';");
     }
     Arrays.stream(columns)
@@ -221,7 +265,11 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
                     .append(".")
                     .append(quotedIdentifier(jdbcColumn.name()))
                     .append(IS)
-                    .append(escapeSqlLiteral(jdbcColumn.comment(), '\''))
+                    .append(
+                        escapeSqlLiteral(
+                            validateText(
+                                jdbcColumn.comment(), "comment for column " + 
jdbcColumn.name()),
+                            '\''))
                     .append("';"));
 
     // Return the generated SQL statement
@@ -477,7 +525,8 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
 
   private String updateCommentDefinition(
       TableChange.UpdateComment updateComment, JdbcTable jdbcTable) {
-    String newComment = 
StringUtils.defaultString(updateComment.getNewComment());
+    String newComment =
+        StringUtils.defaultString(validateText(updateComment.getNewComment(), 
"table comment"));
     if (null == StringIdentifier.fromComment(newComment)) {
       // Detect and add Gravitino id.
       if (StringUtils.isNotEmpty(jdbcTable.comment())) {
@@ -490,7 +539,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     return TABLE_COMMENT
         + quotedIdentifier(jdbcTable.name())
         + IS
-        + escapeSqlLiteral(newComment, '\'')
+        + escapeSqlLiteral(validateText(newComment, "table comment"), '\'')
         + "';";
   }
 
@@ -650,7 +699,8 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
               + "."
               + quotedIdentifier(col)
               + IS
-              + escapeSqlLiteral(addColumn.getComment(), '\'')
+              + escapeSqlLiteral(
+                  validateText(addColumn.getComment(), "comment for column " + 
col), '\'')
               + "';");
     }
     return result;
@@ -668,7 +718,7 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
         + "."
         + quotedIdentifier(col)
         + IS
-        + escapeSqlLiteral(newComment, '\'')
+        + escapeSqlLiteral(validateText(newComment, "comment for column " + 
col), '\'')
         + "';";
   }
 
@@ -763,6 +813,29 @@ public class PostgreSqlTableOperations extends 
JdbcTableOperations
     }
   }
 
+  private boolean schemaLacksUsage(String schema) {
+    try (Connection connection = dataSource.getConnection();
+        PreparedStatement statement =
+            connection.prepareStatement(
+                "SELECT pg_catalog.has_schema_privilege(n.oid, 'USAGE') "
+                    + "FROM pg_catalog.pg_namespace n WHERE n.nspname = ?")) {
+      statement.setString(1, schema);
+      try (ResultSet rows = statement.executeQuery()) {
+        return rows.next() && !rows.getBoolean(1);
+      }
+    } catch (SQLException e) {
+      throw exceptionMapper.toGravitinoException(e);
+    }
+  }
+
+  @Nullable
+  private static String validateText(@Nullable String text, String field) {
+    if (text != null && text.indexOf('\0') >= 0) {
+      throw new IllegalArgumentException("PostgreSQL " + field + " must not 
contain U+0000 (NUL)");
+    }
+    return text;
+  }
+
   private static String quotedIdentifier(String name) {
     return PG_QUOTE + name.replace(PG_QUOTE, PG_QUOTE + PG_QUOTE) + PG_QUOTE;
   }
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
index ef1e83047e..160f2d5ebe 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/TestPostgreSqlCatalogConfiguration.java
@@ -36,6 +36,34 @@ import org.junit.jupiter.params.provider.ValueSource;
 /** Tests database resolution before catalog operations are initialized. */
 class TestPostgreSqlCatalogConfiguration {
 
+  @Test
+  void testProposedConfigurationRejectsDatabaseMismatchBeforeInitialization() {
+    Map<String, String> configuration =
+        new HashMap<>(
+            Map.of(
+                "jdbc-url",
+                "jdbc:postgresql://localhost/demo",
+                "jdbc-database",
+                "other",
+                "jdbc-driver",
+                "org.postgresql.Driver",
+                "jdbc-user",
+                "unused",
+                "jdbc-password",
+                "unused",
+                "verify-on-create",
+                "false"));
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> new 
PostgreSqlCatalog().validateConfiguration(configuration));
+    Assertions.assertTrue(error.getMessage().contains("jdbc-database must 
match"));
+    Assertions.assertEquals("other", configuration.get("jdbc-database"));
+    configuration.put("jdbc-database", "demo");
+    Assertions.assertDoesNotThrow(
+        () -> new PostgreSqlCatalog().validateConfiguration(configuration));
+  }
+
   @ParameterizedTest
   @CsvSource(
       value = {
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
index c66b17fa35..ec1825323a 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlColumnDefaultValueConverter.java
@@ -28,6 +28,20 @@ public class TestPostgreSqlColumnDefaultValueConverter {
   private final PostgreSqlColumnDefaultValueConverter converter =
       new PostgreSqlColumnDefaultValueConverter();
 
+  @Test
+  void testNulStringDefaultIsRejectedBeforeDdl() {
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> 
converter.fromGravitino(Literals.stringLiteral("before\0after")));
+    Assertions.assertTrue(error.getMessage().contains("default"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+    Assertions.assertEquals(
+        "'before\\u0000after'",
+        converter.fromGravitino(Literals.stringLiteral("before\\u0000after")));
+    Assertions.assertEquals("NULL", converter.fromGravitino(Literals.NULL));
+  }
+
   /** Verifies long quoted text defaults can be loaded without recursive regex 
stack growth. */
   @Test
   public void testLongQuotedTextLiteralIsUnescaped() {
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
new file mode 100644
index 0000000000..e45514e637
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/converter/TestPostgreSqlExceptionConverter.java
@@ -0,0 +1,79 @@
+/*
+ * 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.postgresql.converter;
+
+import java.sql.SQLException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** PostgreSQL source permission errors remain distinct from missing objects 
and outages. */
+public class TestPostgreSqlExceptionConverter {
+  @SuppressWarnings("OrphanedFormatString") // Source messages are literal, 
not format templates.
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(
+      strings = {
+        "42501", "42P04", "42P06", "42P07", "3D000", "3F000", "42P01", 
"08006", "08P01", "XX000"
+      })
+  void testSqlFailuresPreserveCauseAndLiteralMessage(String state) {
+    SQLException failure = new SQLException("source failure containing literal 
%s", state);
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @SuppressWarnings("OrphanedFormatString") // Source messages are literal, 
not format templates.
+  @Test
+  void testAuthenticationFailureWithoutSqlStatePreservesCause() {
+    SQLException failure = new SQLException("password authentication failed 
for role %s");
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertInstanceOf(ConnectionFailedException.class, error);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @Test
+  void testInsufficientPrivilegePreservesCauseAndReason() {
+    SQLException failure = new SQLException("permission denied for schema 
restricted", "42501");
+    Exception error = new 
PostgreSqlExceptionConverter().toGravitinoException(failure);
+    Assertions.assertInstanceOf(ForbiddenException.class, error);
+    Assertions.assertSame(failure, error.getCause());
+    Assertions.assertEquals(failure.getMessage(), error.getMessage());
+  }
+
+  @Test
+  void testMissingSchemaAndConnectionFailuresRemainDistinct() {
+    PostgreSqlExceptionConverter converter = new 
PostgreSqlExceptionConverter();
+    Assertions.assertInstanceOf(
+        NoSuchSchemaException.class,
+        converter.toGravitinoException(new SQLException("schema missing", 
"3F000")));
+    for (String state : new String[] {"08006", "08P01"}) {
+      Assertions.assertInstanceOf(
+          ConnectionFailedException.class,
+          converter.toGravitinoException(
+              new SQLException("connection or protocol failure", state)));
+    }
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
new file mode 100644
index 0000000000..304afcd1f9
--- /dev/null
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableCreatePrivileges.java
@@ -0,0 +1,165 @@
+/*
+ * 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.postgresql.operation;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Map;
+import javax.sql.DataSource;
+import org.apache.gravitino.catalog.jdbc.JdbcColumn;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlExceptionConverter;
+import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.rel.expressions.distributions.Distributions;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/** A hidden search-path entry must not make an existing inaccessible schema 
look absent. */
+public class TestPostgreSqlTableCreatePrivileges {
+  private DataSource source;
+  private Connection createConnection;
+  private Connection probeConnection;
+  private Statement createStatement;
+  private PreparedStatement probeStatement;
+  private ResultSet rows;
+  private PostgreSqlTableOperations operations;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    source = mock(DataSource.class);
+    createConnection = mock(Connection.class);
+    probeConnection = mock(Connection.class);
+    createStatement = mock(Statement.class);
+    probeStatement = mock(PreparedStatement.class);
+    rows = mock(ResultSet.class);
+    when(source.getConnection()).thenReturn(createConnection, probeConnection);
+    when(createConnection.createStatement()).thenReturn(createStatement);
+    when(createStatement.executeUpdate(anyString()))
+        .thenThrow(new SQLException("no schema has been selected to create 
in", "3F000"));
+    
when(probeConnection.prepareStatement(anyString())).thenReturn(probeStatement);
+    when(probeStatement.executeQuery()).thenReturn(rows);
+    operations = new PostgreSqlTableOperations();
+    operations.initialize(
+        source,
+        new PostgreSqlExceptionConverter(),
+        new PostgreSqlTypeConverter(),
+        new PostgreSqlColumnDefaultValueConverter(),
+        Map.of("jdbc-database", "database"));
+  }
+
+  @Test
+  void testExistingSchemaWithoutUsageIsForbidden() throws Exception {
+    when(rows.next()).thenReturn(true);
+    when(rows.getBoolean(1)).thenReturn(false);
+    ForbiddenException error = 
Assertions.assertThrows(ForbiddenException.class, this::create);
+    Assertions.assertTrue(error.getMessage().contains("restricted"));
+    Assertions.assertTrue(error.getMessage().contains("USAGE"));
+    verify(probeStatement).setString(1, "restricted");
+    verify(createConnection).close();
+    verify(probeConnection).close();
+    verify(probeStatement).close();
+    verify(rows).close();
+  }
+
+  @Test
+  void testMissingSchemaRemainsMissing() throws Exception {
+    when(rows.next()).thenReturn(false);
+    Assertions.assertThrows(NoSuchSchemaException.class, this::create);
+    verify(probeConnection).close();
+  }
+
+  @Test
+  void testExistingUsableSchemaDoesNotInventPermissionFailure() throws 
Exception {
+    when(rows.next()).thenReturn(true);
+    when(rows.getBoolean(1)).thenReturn(true);
+    Assertions.assertThrows(NoSuchSchemaException.class, this::create);
+    verify(probeConnection).close();
+  }
+
+  @Test
+  void testSuccessfulCreateDoesNotBorrowForPermissionProbe() throws Exception {
+    doReturn(0).when(createStatement).executeUpdate(anyString());
+    Assertions.assertDoesNotThrow(this::create);
+    verify(source, times(1)).getConnection();
+    verify(createConnection).close();
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testProbeFailurePreservesCreateFailure(boolean queryFailure) throws 
Exception {
+    SQLException probeFailure = new SQLException("connection lost", "08006");
+    if (queryFailure) {
+      when(probeStatement.executeQuery()).thenThrow(probeFailure);
+    } else {
+      
when(source.getConnection()).thenReturn(createConnection).thenThrow(probeFailure);
+    }
+    ConnectionFailedException error =
+        Assertions.assertThrows(ConnectionFailedException.class, this::create);
+    Assertions.assertSame(probeFailure, error.getCause());
+    Assertions.assertEquals(1, error.getSuppressed().length);
+    NoSuchSchemaException createFailure =
+        Assertions.assertInstanceOf(NoSuchSchemaException.class, 
error.getSuppressed()[0]);
+    SQLException createCause =
+        Assertions.assertInstanceOf(SQLException.class, 
createFailure.getCause());
+    Assertions.assertEquals("3F000", createCause.getSQLState());
+    Assertions.assertEquals("no schema has been selected to create in", 
createCause.getMessage());
+    verify(createConnection).close();
+    if (queryFailure) {
+      verify(probeConnection).close();
+      verify(probeStatement).close();
+    }
+  }
+
+  private void create() {
+    operations.create(
+        "restricted",
+        "t",
+        new JdbcColumn[] {
+          JdbcColumn.builder()
+              .withName("id")
+              .withType(Types.IntegerType.get())
+              .withNullable(true)
+              .build()
+        },
+        null,
+        Map.of(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        Indexes.EMPTY_INDEXES);
+  }
+}
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
index 1db20ecf54..54ecea04c4 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperations.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import javax.sql.DataSource;
+import org.apache.commons.dbcp2.BasicDataSource;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.catalog.jdbc.JdbcColumn;
 import org.apache.gravitino.catalog.jdbc.JdbcTable;
@@ -37,7 +38,9 @@ import 
org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils;
 import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
+import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.rel.Column;
@@ -197,6 +200,154 @@ public class TestPostgreSqlTableOperations extends 
TestPostgreSql {
     }
   }
 
+  @Test
+  void testLeastPrivilegeCreateDistinguishesMissingSchemaFromForbidden() 
throws SQLException {
+    String role = RandomNameUtils.genRandomName("limited_role");
+    String schema = RandomNameUtils.genRandomName("restricted_schema");
+    String table = RandomNameUtils.genRandomName("permission_table");
+    try (Connection admin = DATA_SOURCE.getConnection()) {
+      JdbcConnectorUtils.executeUpdate(
+          admin, "CREATE ROLE " + role + " LOGIN PASSWORD 
'test-only-password'");
+      JdbcConnectorUtils.executeUpdate(admin, "CREATE SCHEMA " + schema);
+      try {
+        JdbcConnectorUtils.executeUpdate(admin, "REVOKE ALL ON SCHEMA " + 
schema + " FROM PUBLIC");
+        Map<String, String> properties = new HashMap<>();
+        properties.put("jdbc-driver", CONTAINER.getDriverClassName());
+        properties.put("jdbc-url", CONTAINER.getJdbcUrl());
+        properties.put("jdbc-database", admin.getCatalog());
+        properties.put("jdbc-user", role);
+        properties.put("jdbc-password", "test-only-password");
+        properties.put("jdbc.pool.max-size", "1");
+        properties.put("jdbc.pool.min-size", "1");
+        try (BasicDataSource limitedSource =
+            (BasicDataSource) DataSourceUtils.createDataSource(properties)) {
+          PostgreSqlTableOperations limited = new PostgreSqlTableOperations();
+          limited.initialize(
+              limitedSource,
+              JDBC_EXCEPTION_CONVERTER,
+              new PostgreSqlTypeConverter(),
+              new PostgreSqlColumnDefaultValueConverter(),
+              properties);
+          JdbcColumn[] columns = {
+            
JdbcColumn.builder().withName("id").withType(INT).withNullable(true).build()
+          };
+          Assertions.assertThrows(
+              ForbiddenException.class,
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          JdbcConnectorUtils.executeUpdate(
+              admin, "GRANT USAGE ON SCHEMA " + schema + " TO " + role);
+          Assertions.assertThrows(
+              ForbiddenException.class,
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          JdbcConnectorUtils.executeUpdate(
+              admin, "GRANT CREATE ON SCHEMA " + schema + " TO " + role);
+          Assertions.assertDoesNotThrow(
+              () ->
+                  limited.create(
+                      schema,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+          Assertions.assertEquals(table, TABLE_OPERATIONS.load(schema, 
table).name());
+          String absent = RandomNameUtils.genRandomName("missing_schema");
+          Assertions.assertThrows(
+              NoSuchSchemaException.class,
+              () ->
+                  limited.create(
+                      absent,
+                      table,
+                      columns,
+                      null,
+                      Map.of(),
+                      null,
+                      Distributions.NONE,
+                      Indexes.EMPTY_INDEXES));
+        }
+      } finally {
+        JdbcConnectorUtils.executeUpdate(admin, "DROP SCHEMA IF EXISTS " + 
schema + " CASCADE");
+        JdbcConnectorUtils.executeUpdate(admin, "DROP ROLE IF EXISTS " + role);
+      }
+    }
+  }
+
+  @Test
+  void testNulValidationPreventsCreateAndPartialAlterDdl() {
+    String table = RandomNameUtils.genRandomName("nul_validation");
+    String invalidTable = RandomNameUtils.genRandomName("nul_create");
+    JdbcColumn[] columns = {
+      
JdbcColumn.builder().withName("col1").withType(VARCHAR).withNullable(true).build()
+    };
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TABLE_OPERATIONS.create(
+                TEST_DB_NAME,
+                invalidTable,
+                columns,
+                "bad\0comment",
+                Map.of(),
+                null,
+                Distributions.NONE,
+                Indexes.EMPTY_INDEXES));
+    
Assertions.assertFalse(TABLE_OPERATIONS.listTables(TEST_DB_NAME).contains(invalidTable));
+    TABLE_OPERATIONS.create(
+        TEST_DB_NAME,
+        table,
+        columns,
+        "original",
+        Map.of(),
+        null,
+        Distributions.NONE,
+        Indexes.EMPTY_INDEXES);
+    try {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              TABLE_OPERATIONS.alterTable(
+                  TEST_DB_NAME,
+                  table,
+                  TableChange.addColumn(new String[] {"must_not_exist"}, INT),
+                  TableChange.updateComment("bad\0comment")));
+      JdbcTable loaded = TABLE_OPERATIONS.load(TEST_DB_NAME, table);
+      Assertions.assertEquals(1, loaded.columns().length);
+      Assertions.assertEquals("original", loaded.comment());
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              TABLE_OPERATIONS.alterTable(
+                  TEST_DB_NAME,
+                  table,
+                  TableChange.updateColumnDefaultValue(
+                      new String[] {"col1"}, 
Literals.stringLiteral("bad\0default"))));
+      Assertions.assertEquals(
+          Literals.NULL, TABLE_OPERATIONS.load(TEST_DB_NAME, 
table).columns()[0].defaultValue());
+    } finally {
+      TABLE_OPERATIONS.drop(TEST_DB_NAME, table);
+    }
+  }
+
   @Test
   public void testOperationTable() {
     String tableName = RandomNameUtils.genRandomName("op_table_");
diff --git 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
index 3f2e81eaa7..ef659ec49b 100644
--- 
a/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
+++ 
b/catalogs/catalog-jdbc-postgresql/src/test/java/org/apache/gravitino/catalog/postgresql/operation/TestPostgreSqlTableOperationsSqlGeneration.java
@@ -23,6 +23,7 @@ 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.postgresql.converter.PostgreSqlColumnDefaultValueConverter;
 import 
org.apache.gravitino.catalog.postgresql.converter.PostgreSqlTypeConverter;
 import org.apache.gravitino.dto.requests.TableUpdateRequest;
 import org.apache.gravitino.rel.TableChange;
@@ -47,6 +48,11 @@ public class TestPostgreSqlTableOperationsSqlGeneration {
       super.columnDefaultValueConverter = new 
JdbcColumnDefaultValueConverter();
     }
 
+    private 
TestablePostgreSqlTableOperations(PostgreSqlColumnDefaultValueConverter 
converter) {
+      this();
+      super.columnDefaultValueConverter = converter;
+    }
+
     public String createTableSql(String tableName, JdbcColumn[] columns) {
       return createTableSql(tableName, columns, "comment");
     }
@@ -156,6 +162,105 @@ public class TestPostgreSqlTableOperationsSqlGeneration {
     }
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"table", "column", "alterTable", "alterColumn", 
"addColumn"})
+  void testNulCommentsAreRejectedBeforeDdl(String path) {
+    TestablePostgreSqlTableOperations ops = new 
TestablePostgreSqlTableOperations();
+    JdbcColumn column =
+        JdbcColumn.builder()
+            .withName("col1")
+            .withType(Types.StringType.get())
+            .withNullable(true)
+            .build();
+    String bad = "before\0after";
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> {
+              switch (path) {
+                case "table":
+                  ops.createTableSql("t", new JdbcColumn[] {column}, bad);
+                  break;
+                case "column":
+                  ops.createTableSql(
+                      "t",
+                      new JdbcColumn[] {
+                        JdbcColumn.builder()
+                            .withName("col1")
+                            .withType(Types.StringType.get())
+                            .withNullable(true)
+                            .withComment(bad)
+                            .build()
+                      });
+                  break;
+                case "alterTable":
+                  ops.alterTableSql("t", TableChange.updateComment(bad));
+                  break;
+                case "alterColumn":
+                  ops.alterTableSql(
+                      "t", TableChange.updateColumnComment(new String[] 
{"col1"}, bad));
+                  break;
+                case "addColumn":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.addColumn(new String[] {"col2"}, 
Types.StringType.get(), bad));
+                  break;
+                default:
+                  throw new AssertionError(path);
+              }
+            });
+    Assertions.assertTrue(error.getMessage().contains("comment"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"create", "add", "alter"})
+  void testNulDefaultsAreRejectedOnEveryDdlPath(String path) {
+    TestablePostgreSqlTableOperations ops =
+        new TestablePostgreSqlTableOperations(new 
PostgreSqlColumnDefaultValueConverter());
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> {
+              switch (path) {
+                case "create":
+                  ops.createTableSql(
+                      "t",
+                      new JdbcColumn[] {
+                        JdbcColumn.builder()
+                            .withName("col1")
+                            .withType(Types.StringType.get())
+                            .withNullable(true)
+                            
.withDefaultValue(Literals.stringLiteral("bad\0default"))
+                            .build()
+                      });
+                  break;
+                case "add":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.addColumn(
+                          new String[] {"col2"},
+                          Types.StringType.get(),
+                          null,
+                          TableChange.ColumnPosition.defaultPos(),
+                          true,
+                          false,
+                          Literals.stringLiteral("bad\0default")));
+                  break;
+                case "alter":
+                  ops.alterTableSql(
+                      "t",
+                      TableChange.updateColumnDefaultValue(
+                          new String[] {"col1"}, 
Literals.stringLiteral("bad\0default")));
+                  break;
+                default:
+                  throw new AssertionError(path);
+              }
+            });
+    Assertions.assertTrue(error.getMessage().contains("default"));
+    Assertions.assertTrue(error.getMessage().contains("U+0000"));
+  }
+
   @ParameterizedTest
   @ValueSource(booleans = {false, true})
   void testDeleteIndexesDropsOnlyConstraints(boolean ifExists) {
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java 
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index 2e1bd9f0a8..d0e850b402 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -1280,7 +1280,7 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
       throws NoSuchCatalogException, IllegalArgumentException, IOException {
     SecretMaterialsHolder writtenSecretMaterials = new SecretMaterialsHolder();
     boolean alterCommitted = false;
-    try {
+    try (CatalogLease lease = acquireCatalogLease(ident)) {
       CatalogEntity updatedCatalog =
           store.update(
               ident,
@@ -1292,20 +1292,38 @@ public class CatalogManager implements 
CatalogDispatcher, Closeable {
                         ? new HashMap<>()
                         : new HashMap<>(existing.getProperties());
 
+                // Validate a transient plaintext view before secret writes. 
Failed validation
+                // must not overwrite or delete a previously stored 
write-through secret.
+                CatalogChange[] proposedChanges =
+                    SecretAlterChanges.prepareCatalogChangesForTest(
+                        secretManager, existing.id(), changes);
+                CatalogEntity proposed =
+                    updateEntity(
+                            newCatalogBuilder(ident.namespace(), existing),
+                            new HashMap<>(currentProperties),
+                            proposedChanges)
+                        .build();
+                Map<String, String> proposedConfiguration =
+                    
secretManager.toPlaintextProperties(proposed.getProperties());
+                lease
+                    .wrapper()
+                    .classLoader
+                    .withClassLoader(
+                        cl -> {
+                          
lease.catalog().validateConfiguration(proposedConfiguration);
+                          return null;
+                        },
+                        IllegalArgumentException.class);
+
                 Pair<CatalogChange[], List<SecretMaterial>> secretResult =
                     SecretAlterChanges.prepareCatalogChanges(
                         secretManager, currentProperties, existing.id(), 
changes);
                 writtenSecretMaterials.set(secretResult.getRight());
-                CatalogChange[] effectiveChanges = secretResult.getLeft();
-
-                CatalogEntity.Builder newCatalogBuilder =
-                    newCatalogBuilder(ident.namespace(), existing);
-
-                Map<String, String> newProps =
-                    existing.getProperties() == null
-                        ? new HashMap<>()
-                        : new HashMap<>(existing.getProperties());
-                return updateEntity(newCatalogBuilder, newProps, 
effectiveChanges).build();
+                return updateEntity(
+                        newCatalogBuilder(ident.namespace(), existing),
+                        new HashMap<>(currentProperties),
+                        secretResult.getLeft())
+                    .build();
               });
       alterCommitted = true;
       return updatedCatalog;
@@ -1847,6 +1865,9 @@ public class CatalogManager implements CatalogDispatcher, 
Closeable {
           cl -> {
             validatePropertyForCreate(catalog.catalogPropertiesMetadata(), 
propsToValidate);
             catalog.validateCredentialProviders(propsToValidate);
+            if (propsToValidate != null) {
+              catalog.validateConfiguration();
+            }
             if (propsToValidate == null) {
               // Loading stays lenient for catalogs persisted with unsupported 
providers, so only
               // warn about them. Providers persisted as a secret are checked 
by their plaintext.
diff --git a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java 
b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
index 22491e48b9..afa2daaec0 100644
--- a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
+++ b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
@@ -222,6 +222,29 @@ public abstract class BaseCatalog<T extends BaseCatalog>
     return ops;
   }
 
+  /**
+   * Validates the initialized configuration without contacting the catalog 
backend.
+   *
+   * @throws IllegalArgumentException if the configuration is invalid
+   */
+  public final void validateConfiguration() {
+    validateConfiguration(conf);
+  }
+
+  /**
+   * Validates the complete proposed catalog configuration without contacting 
its backend.
+   *
+   * <p>Called on explicit create and alter operations, independently of 
optional backend
+   * verification, with the catalog ClassLoader installed. The configuration 
may differ from the
+   * current entity on alter. Implementations must not mutate it, initialize 
operations, open
+   * connections, or retain resources. The default accepts the configuration 
for compatibility.
+   * Existing catalogs are not validated on ordinary reads.
+   *
+   * @param configuration the complete effective configuration, with secret 
references resolved
+   * @throws IllegalArgumentException if the configuration is invalid
+   */
+  public void validateConfiguration(Map<String, String> configuration) {}
+
   /**
    * Whether to validate this catalog against its backend at create time 
rather than on first use.
    *
diff --git a/core/src/test/java/org/apache/gravitino/TestCatalog.java 
b/core/src/test/java/org/apache/gravitino/TestCatalog.java
index 6d5a27bee0..b306f91157 100644
--- a/core/src/test/java/org/apache/gravitino/TestCatalog.java
+++ b/core/src/test/java/org/apache/gravitino/TestCatalog.java
@@ -65,6 +65,16 @@ public class TestCatalog extends BaseCatalog<TestCatalog> {
     return new TestCatalogOperations(config);
   }
 
+  /** {@inheritDoc} */
+  @Override
+  public void validateConfiguration(Map<String, String> configuration) {
+    int minimum = 
Integer.parseInt(configuration.getOrDefault("validation-min", "0"));
+    int maximum = 
Integer.parseInt(configuration.getOrDefault("validation-max", "2"));
+    if (minimum > maximum) {
+      throw new IllegalArgumentException("validation-min must not exceed 
validation-max");
+    }
+  }
+
   @Override
   public boolean shouldValidateOnCreate() {
     Map<String, String> properties = entity().getProperties();
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index e86579c433..31bdae35c1 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -746,6 +746,75 @@ public class TestCatalogManager {
     
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(skipIdent));
   }
 
+  @Test
+  void testConfigurationValidationOnCreateWithoutBackendVerification() {
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"static-invalid-create");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                "validation-min",
+                "3",
+                "validation-max",
+                "2",
+                TestCatalogOperations.FAIL_INITIALIZE,
+                "true"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            catalogManager.createCatalog(
+                ident, Catalog.Type.RELATIONAL, provider, "comment", 
properties));
+    
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(ident));
+    properties.put("validation-max", "4");
+    // The identifier is reusable after validation rollback. No backend 
initialization is needed.
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.createCatalog(
+                ident, Catalog.Type.RELATIONAL, provider, "comment", 
properties));
+  }
+
+  @Test
+  void testConfigurationValidationOnAlterUsesCompleteProposedProperties() {
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"static-invalid-alter");
+    Map<String, String> properties =
+        Maps.newHashMap(
+            ImmutableMap.of(
+                PROPERTY_KEY1,
+                "value1",
+                PROPERTY_KEY2,
+                "value2",
+                PROPERTY_KEY5_PREFIX + "1",
+                "value3",
+                "validation-min",
+                "1",
+                "validation-max",
+                "2",
+                TestCatalogOperations.FAIL_INITIALIZE,
+                "true"));
+    catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
"comment", properties);
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> catalogManager.alterCatalog(ident, 
CatalogChange.setProperty("validation-min", "3")));
+    Assertions.assertEquals(
+        "1", 
catalogManager.loadCatalog(ident).properties().get("validation-min"));
+    Assertions.assertDoesNotThrow(
+        () ->
+            catalogManager.alterCatalog(
+                ident,
+                CatalogChange.setProperty("validation-min", "3"),
+                CatalogChange.setProperty("validation-max", "4")));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> catalogManager.alterCatalog(ident, 
CatalogChange.removeProperty("validation-max")));
+    Assertions.assertEquals(
+        "4", 
catalogManager.loadCatalog(ident).properties().get("validation-max"));
+  }
+
   @Test
   public void testCreateCatalogVerifiesBackendViaTestConnection() throws 
Exception {
     // Opted in via validateOnCreate() force-probe, and the probe reports the 
backend unreachable:
@@ -2280,6 +2349,37 @@ public class TestCatalogManager {
     }
   }
 
+  @Test
+  void testInvalidConfigurationAlterDoesNotMutateSecrets() throws Exception {
+    try (SecretManager secrets = memorySecretManager();
+        CatalogManager manager =
+            new CatalogManager(config, entityStore, new RandomIdGenerator(), 
secrets)) {
+      NameIdentifier ident = NameIdentifier.of("metalake", 
"invalid_settings_secret");
+      manager.createCatalog(
+          ident,
+          Catalog.Type.RELATIONAL,
+          provider,
+          "comment",
+          catalogProps(),
+          Map.of(PROPERTY_KEY4, new SecretBinding("memory", 
"original-secret")),
+          Map.of());
+      CatalogEntity original = entityStore.get(ident, EntityType.CATALOG, 
CatalogEntity.class);
+      String urn = original.getProperties().get(PROPERTY_KEY4);
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              manager.alterCatalog(
+                  ident,
+                  CatalogChange.setProperty("validation-min", "3"),
+                  CatalogChange.setSecretBinding(
+                      PROPERTY_KEY4, new SecretBinding("memory", 
"replacement"))));
+      Assertions.assertEquals("original-secret", 
secrets.readSecret(SecretUrn.parse(urn)));
+      Assertions.assertEquals(
+          original.getProperties(),
+          entityStore.get(ident, EntityType.CATALOG, 
CatalogEntity.class).getProperties());
+    }
+  }
+
   @Test
   void testConnectionChangesDoNotMutateSecrets() throws Exception {
     try (SecretManager secrets = memorySecretManager();
diff --git a/docs/jdbc-catalog-connection-validation.md 
b/docs/jdbc-catalog-connection-validation.md
index 13cedaafd7..c5ba0307f0 100644
--- a/docs/jdbc-catalog-connection-validation.md
+++ b/docs/jdbc-catalog-connection-validation.md
@@ -65,7 +65,7 @@ it, and closes it. If it fails, the create is rejected and 
rolled back (the cata
 persisted), and the caller receives a connection error that names the backend 
`host:port` and the
 underlying driver message.
 
-To register a catalog whose backend is temporarily unavailable, set the hidden 
catalog property
+To register a catalog whose backend is temporarily unavailable, set the 
catalog property
 `verify-on-create` to `false`, which skips the create-time check:
 
 ```json
@@ -74,6 +74,11 @@ To register a catalog whose backend is temporarily 
unavailable, set the hidden c
 }
 ```
 
+This setting skips only the backend connection probe. Driver/URL 
compatibility, boolean values,
+and pool bounds are still checked on catalog creation and alteration, without 
opening a database
+connection. Invalid alterations leave the stored configuration unchanged.
+
+
 The default is `true` (verify on create). The connection is still opened 
lazily on first use when
 the check is skipped.
 

Reply via email to