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 ed5694877f [#12843] fix(clickhouse): Expose native partition 
expression via partition-key property (#13080)
ed5694877f is described below

commit ed5694877faa3f4a8a9b39c7096c33012cb4b64f
Author: cwq222 <[email protected]>
AuthorDate: Thu Sep 17 21:00:22 2026 +0800

    [#12843] fix(clickhouse): Expose native partition expression via 
partition-key property (#13080)
    
    ### What changes were proposed in this pull request?
    Remove the partition-specific SHOW CREATE regex parsing path and treat
    `system.tables.partition_key` as the single authoritative source of the
    native partition expression.
    
    Introduce a visible, read-only `partition-key` table property that
    carries the canonical native expression for every loaded table:
    - identity / `toYear` / `toDate` / `toYYYYMM` mappings keep producing
    their existing structured `Transform`s, and now also expose the
    canonical native expression through `partition-key`.
    - A native expression that cannot be structured (e.g.
    `cityHash64(toString(sm4_cipher_msg)) % 7`) no longer throws;
    `partitioning()` stays empty and the raw expression is preserved
    verbatim in `partition-key`.
    - An unpartitioned table exposes `partition-key` as an empty string so
    the property key is always present and callers can rely on
    `Map.get(...)` without a `containsKey` check.
    
    Supporting changes:
    - Make structural conversion best-effort: `parsePartitionExpression`
    returns `null` for unsupported expressions instead of throwing, and
    `parsePartitioning` returns an empty array when any element of the
    partition key cannot be structured.
    - Require a strict (plain-column) identifier for the existing
    `toYear`/`toDate`/`toYYYYMM` mappings so nested expressions such as
    `toYear(f(x))` are no longer misrepresented as a single-field transform.
    - Fix `convertFromJdbcProperties`/`transformToJdbcProperties` to pass
    through all properties (keeping the existing `ENGINE` key rename) so
    that `partition-key` and other properties survive the conversion; filter
    `partition-key` out of `transformToJdbcProperties` so it is never
    written back to ClickHouse.
    - Register `partition-key` as a reserved, immutable property, which
    makes the framework reject it on create, set, and remove.
    -
    ### Why are the changes needed?
    Loading a valid MergeTree table with `PARTITION BY
    cityHash64(toString(sm4_cipher_msg)) % 7` currently throws
    `UnsupportedOperationException` from the strict SHOW CREATE parser,
    blocking ordinary table metadata access. Returning only an empty
    `Transform` array would also be ambiguous, since callers could not
    distinguish an unpartitioned table from a native expression that
    Gravitino cannot structure.
    
    Fix: #12843
    
    ### Does this PR introduce _any_ user-facing change?
    Yes. Loaded ClickHouse tables now expose a visible, read-only
    `partition-key` property:
    - Non-empty value: ClickHouse's canonical native partition expression as
    returned by `system.tables.partition_key`.
    - Empty string: the table is unpartitioned (the key is always present).
    `Table.partitioning()` is unchanged for structured expressions and
    returns an empty array for native expressions that have no equivalent
    structured `Transform`. The property is read-only: callers cannot supply
    it on create, set it on alter, or remove it.
    
    Known limitation:
    - A partition key is structured on an all-or-nothing basis: if any
    element of a tuple partition key cannot be structured, `partitioning()`
    is empty and the full expression is preserved verbatim in
    `partition-key`; callers cannot tell which tuple elements were
    individually structurable.
    
    ### How was this patch tested?
    - Unit test `TestClickHouseTableOperationsPartitioning`: structured
    expressions (identity/year/month/day/tuple) and unsupported
    native/nested expressions.
    - New IT
    `CatalogClickHouseIT.testLoadTableWithNativePartitionExpression`: native
    expression loads successfully and is preserved in `partition-key`.
    - New IT `CatalogClickHouseIT.testLoadTableWithoutPartition`:
    unpartitioned table exposes `partition-key` as an empty string.
    - Manual end-to-end: real ClickHouse + Gravitino server + REST,
    verifying all four scenarios (native expression, unpartitioned,
    `toDate`, tuple) return the expected `partitioning()` and
    `partition-key` values.
---
 .../catalog/clickhouse/ClickHouseConstants.java    |  7 ++
 .../ClickHouseTablePropertiesMetadata.java         | 53 ++++++++------
 .../operations/ClickHouseTableOperations.java      | 80 +++++----------------
 .../operations/ClickHouseTableSqlUtils.java        | 66 +++++++++--------
 .../TestClickHouseTablePropertiesMetadata.java     | 83 ++++++++++++++++++++++
 .../integration/test/CatalogClickHouseIT.java      | 45 ++++++++++++
 .../TestClickHouseTableOperationsPartitioning.java | 27 ++++++-
 docs/jdbc-clickhouse-catalog.md                    |  3 +
 docs/partitions.md                                 |  7 +-
 9 files changed, 251 insertions(+), 120 deletions(-)

diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseConstants.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseConstants.java
index b040ba4bda..94420487d7 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseConstants.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseConstants.java
@@ -51,6 +51,13 @@ public class ClickHouseConstants {
 
     /** Parameters for supported parameterized MergeTree engines, without 
outer parentheses. */
     public static final String ENGINE_PARAMETERS = "engine_parameters";
+
+    /**
+     * Read-only property that exposes ClickHouse's canonical native partition 
expression as
+     * returned by system.tables.partition_key. It carries expressions that 
cannot be mapped to a
+     * structured Transform (identity, year, month, or day).
+     */
+    public static final String PARTITION_KEY = "partition-key";
   }
 
   public static final class IndexConstants {
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseTablePropertiesMetadata.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseTablePropertiesMetadata.java
index 50ab7af396..cdcd83b051 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseTablePropertiesMetadata.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/ClickHouseTablePropertiesMetadata.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.clickhouse;
 
 import static 
org.apache.gravitino.connector.PropertyEntry.enumImmutablePropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.stringOptionalPropertyEntry;
+import static org.apache.gravitino.connector.PropertyEntry.stringPropertyEntry;
 import static 
org.apache.gravitino.connector.PropertyEntry.stringReservedPropertyEntry;
 
 import java.util.Collections;
@@ -110,6 +111,21 @@ public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetada
           "",
           false);
 
+  /**
+   * ClickHouse's canonical native partition expression as returned by 
system.tables.partition_key.
+   * Read-only: populated when loading a table, exposing an empty string for 
unpartitioned tables so
+   * that the property key is always present.
+   */
+  public static final PropertyEntry<String> PARTITION_KEY_PROPERTY_ENTRY =
+      stringPropertyEntry(
+          TableConstants.PARTITION_KEY,
+          "The canonical native partition expression of a ClickHouse table",
+          false,
+          true,
+          "",
+          false,
+          true);
+
   private static final Map<String, PropertyEntry<?>> PROPERTIES_METADATA =
       createPropertiesMetadata();
 
@@ -137,6 +153,7 @@ public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetada
     map.put(CLUSTER_REMOTE_TABLE_PROPERTY_ENTRY.getName(), 
CLUSTER_REMOTE_TABLE_PROPERTY_ENTRY);
     map.put(CLUSTER_SHARDING_KEY_PROPERTY_ENTRY.getName(), 
CLUSTER_SHARDING_KEY_PROPERTY_ENTRY);
     map.put(ENGINE_PARAMETERS_PROPERTY_ENTRY.getName(), 
ENGINE_PARAMETERS_PROPERTY_ENTRY);
+    map.put(PARTITION_KEY_PROPERTY_ENTRY.getName(), 
PARTITION_KEY_PROPERTY_ENTRY);
 
     return Collections.unmodifiableMap(map);
   }
@@ -237,33 +254,25 @@ public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetada
 
   @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_CLICKHOUSE.containsKey(key)) {
-                    put(GRAVITINO_CONFIG_TO_CLICKHOUSE.get(key), value);
-                  }
-                });
-          }
-        });
+    // Reuse the parent implementation to filter out the internal 
gravitino.identifier property.
+    Map<String, String> transformed = new 
HashMap<>(super.transformToJdbcProperties(properties));
+    // partition-key is read-only metadata; never write it back to ClickHouse.
+    transformed.remove(TableConstants.PARTITION_KEY);
+    // Rename the engine key to the ClickHouse-specific form.
+    String engine = transformed.remove(GRAVITINO_ENGINE_KEY);
+    if (engine != null) {
+      transformed.put(CLICKHOUSE_ENGINE_KEY, engine);
+    }
+    return Collections.unmodifiableMap(transformed);
   }
 
   @Override
   public Map<String, String> convertFromJdbcProperties(Map<String, String> 
properties) {
     BidiMap<String, String> clickhouseConfigToGravitino =
         GRAVITINO_CONFIG_TO_CLICKHOUSE.inverseBidiMap();
-    return Collections.unmodifiableMap(
-        new HashMap<String, String>() {
-          {
-            properties.forEach(
-                (key, value) -> {
-                  if (clickhouseConfigToGravitino.containsKey(key)) {
-                    put(clickhouseConfigToGravitino.get(key), value);
-                  }
-                });
-          }
-        });
+    Map<String, String> converted = new HashMap<>();
+    properties.forEach(
+        (key, value) -> 
converted.put(clickhouseConfigToGravitino.getOrDefault(key, key), value));
+    return Collections.unmodifiableMap(converted);
   }
 }
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
index e21109f84d..b6f566a4e0 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableOperations.java
@@ -42,7 +42,6 @@ import java.sql.DatabaseMetaData;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.sql.Statement;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -92,7 +91,6 @@ import 
org.apache.gravitino.rel.expressions.sorts.SortDirection;
 import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.sorts.SortOrders;
 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.apache.gravitino.rel.types.Type;
@@ -112,9 +110,6 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
               ENGINE.SUMMINGMERGETREE,
               ENGINE.COLLAPSINGMERGETREE,
               ENGINE.VERSIONEDCOLLAPSINGMERGETREE));
-  private static final Pattern PARTITION_BY_PATTERN =
-      Pattern.compile(
-          
"(?is)\\bPARTITION\\s+BY\\s*(.+?)(?=\\bORDER\\s+BY\\b|\\bPRIMARY\\s+KEY\\b|\\bSAMPLE\\s+BY\\b|\\bTTL\\b|\\bSETTINGS\\b|\\bCOMMENT\\b|$)");
   private static final Pattern SETTINGS_PATTERN =
       Pattern.compile("(?is)\\bSETTINGS\\s+(.+?)(?=\\bCOMMENT\\b|$)");
   private static final Pattern DISTRIBUTED_ENGINE_PATTERN =
@@ -878,12 +873,8 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
 
       SystemTableMetadata systemTableMetadata =
           getSystemTableMetadata(connection, databaseName, tableName);
-      ShowCreateTableMetadata showCreateMetadata = 
parseShowCreateTable(connection, tableName);
-      Transform[] partitioning = showCreateMetadata.partitioning;
-      if (ArrayUtils.isEmpty(partitioning)) {
-        partitioning = getTablePartitioning(connection, databaseName, 
tableName);
-      }
-      jdbcTableBuilder.withPartitioning(partitioning);
+      String partitionKey = getPartitionKey(connection, databaseName, 
tableName);
+      jdbcTableBuilder.withPartitioning(parsePartitioning(partitionKey));
       jdbcTableBuilder.withSortOrders(systemTableMetadata.sortOrders());
 
       Distribution distribution = getDistributionInfo(connection, 
databaseName, tableName);
@@ -895,12 +886,14 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       // mistaken for table SETTINGS. These values take precedence
       // over any settings.* keys that might exist in system.tables (though 
getTableProperties()
       // currently does not read SETTINGS from system.tables, so no overlap 
occurs in practice).
+      Map<String, String> merged = new HashMap<>(tableProperties);
       if (!systemTableMetadata.settings().isEmpty()) {
-        Map<String, String> merged = new HashMap<>(tableProperties);
         merged.putAll(systemTableMetadata.settings());
-        tableProperties = Collections.unmodifiableMap(merged);
       }
-      jdbcTableBuilder.withProperties(tableProperties);
+      // Expose ClickHouse's canonical native partition expression. 
Unpartitioned tables expose an
+      // empty string so that the property key is always present.
+      merged.put(TableConstants.PARTITION_KEY, 
StringUtils.defaultString(partitionKey));
+      jdbcTableBuilder.withProperties(Collections.unmodifiableMap(merged));
 
       correctJdbcTableFields(connection, databaseName, tableName, 
jdbcTableBuilder);
 
@@ -950,6 +943,13 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   @Override
   protected Transform[] getTablePartitioning(
       Connection connection, String databaseName, String tableName) throws 
SQLException {
+    return parsePartitioning(getPartitionKey(connection, databaseName, 
tableName));
+  }
+
+  @VisibleForTesting
+  @Nullable
+  String getPartitionKey(Connection connection, String databaseName, String 
tableName)
+      throws SQLException {
     try (PreparedStatement statement =
         connection.prepareStatement(
             "SELECT partition_key FROM system.tables WHERE database = ? AND 
name = ?")) {
@@ -957,22 +957,12 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       statement.setString(2, tableName);
       try (ResultSet resultSet = statement.executeQuery()) {
         if (resultSet.next()) {
-          String partitionKey = resultSet.getString("partition_key");
-          try {
-            return parsePartitioning(partitionKey);
-          } catch (IllegalArgumentException | UnsupportedOperationException e) 
{
-            LOG.warn(
-                "Skip unsupported partition expression {} for {}.{}",
-                partitionKey,
-                databaseName,
-                tableName);
-            return Transforms.EMPTY_TRANSFORM;
-          }
+          return resultSet.getString("partition_key");
         }
       }
     }
 
-    return Transforms.EMPTY_TRANSFORM;
+    return null;
   }
 
   @Override
@@ -1474,24 +1464,10 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   }
 
   @VisibleForTesting
-  Transform[] parsePartitioning(String partitionKey) {
+  Transform[] parsePartitioning(@Nullable String partitionKey) {
     return ClickHouseTableSqlUtils.parsePartitioning(partitionKey);
   }
 
-  private ShowCreateTableMetadata parseCreateStatement(String createSql) {
-    ShowCreateTableMetadata metadata = new ShowCreateTableMetadata();
-    if (StringUtils.isBlank(createSql)) {
-      return metadata;
-    }
-
-    Matcher partitionMatcher = PARTITION_BY_PATTERN.matcher(createSql);
-    if (partitionMatcher.find()) {
-      metadata.partitioning = parsePartitioning(partitionMatcher.group(1));
-    }
-
-    return metadata;
-  }
-
   // Parses "key1 = val1, key2 = val2" from a SETTINGS clause.
   // Keys are prefixed with "settings." to match the write path convention in
   // appendTableProperties(). ClickHouse SETTINGS values are scalar (UInt64, 
Bool,
@@ -1524,24 +1500,6 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     return Collections.emptyMap();
   }
 
-  private ShowCreateTableMetadata parseShowCreateTable(Connection connection, 
String tableName)
-      throws SQLException {
-    String createSql = parseShowCreateTableSql(connection, tableName);
-    return parseCreateStatement(createSql);
-  }
-
-  private String parseShowCreateTableSql(Connection connection, String 
tableName)
-      throws SQLException {
-    String sql = "SHOW CREATE TABLE " + quoteIdentifier(tableName);
-    try (Statement statement = connection.createStatement();
-        ResultSet resultSet = statement.executeQuery(sql)) {
-      if (resultSet.next()) {
-        return resultSet.getString(1);
-      }
-      throw new SQLException("SHOW CREATE TABLE returned no rows for " + 
tableName);
-    }
-  }
-
   private String unquote(String value) {
     String trimmed = StringUtils.trimToEmpty(value);
     if (StringUtils.length(trimmed) >= 2) {
@@ -1659,10 +1617,6 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     }
   }
 
-  private static final class ShowCreateTableMetadata {
-    private Transform[] partitioning = Transforms.EMPTY_TRANSFORM;
-  }
-
   private static final class TablePropertiesWithClusterMetadata {
     private final Map<String, String> properties;
     private final boolean hasClusterMetadata;
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableSqlUtils.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableSqlUtils.java
index 9681c0d301..bcf1dbeb0b 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableSqlUtils.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/main/java/org/apache/gravitino/catalog/clickhouse/operations/ClickHouseTableSqlUtils.java
@@ -26,6 +26,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
@@ -44,7 +45,7 @@ final class ClickHouseTableSqlUtils {
 
   private ClickHouseTableSqlUtils() {}
 
-  static Transform[] parsePartitioning(String partitionKey) {
+  static Transform[] parsePartitioning(@Nullable String partitionKey) {
     if (StringUtils.isBlank(partitionKey)) {
       return Transforms.EMPTY_TRANSFORM;
     }
@@ -61,7 +62,13 @@ final class ClickHouseTableSqlUtils {
       if (StringUtils.isBlank(expression)) {
         continue;
       }
-      transforms.add(parsePartitionExpression(expression, partitionKey));
+      Transform transform = parsePartitionExpression(expression);
+      if (transform == null) {
+        // A single unsupported native expression means the whole partition 
key cannot be
+        // represented as structured transforms.
+        return Transforms.EMPTY_TRANSFORM;
+      }
+      transforms.add(transform);
     }
 
     return transforms.toArray(new Transform[0]);
@@ -178,49 +185,48 @@ final class ClickHouseTableSqlUtils {
     return normalizeIndexExpression(current);
   }
 
-  private static Transform parsePartitionExpression(
-      String expression, String originalPartitionKey) {
+  @Nullable
+  private static Transform parsePartitionExpression(String expression) {
     String trimmedExpression = StringUtils.trim(expression);
 
     Matcher toYearMatcher = TO_YEAR_PATTERN.matcher(trimmedExpression);
     if (toYearMatcher.matches()) {
-      String identifier = normalizeIdentifier(toYearMatcher.group(1));
-      Preconditions.checkArgument(
-          StringUtils.isNotBlank(identifier),
-          "Unsupported partition expression: " + originalPartitionKey);
-      return Transforms.year(identifier);
+      String identifier = extractPartitionIdentifier(toYearMatcher.group(1));
+      return identifier == null ? null : Transforms.year(identifier);
     }
 
     Matcher toYYYYMMMatcher = TO_MONTH_PATTERN.matcher(trimmedExpression);
     if (toYYYYMMMatcher.matches()) {
-      String identifier = normalizeIdentifier(toYYYYMMMatcher.group(1));
-      Preconditions.checkArgument(
-          StringUtils.isNotBlank(identifier),
-          "Unsupported partition expression: " + originalPartitionKey);
-      return Transforms.month(identifier);
+      String identifier = extractPartitionIdentifier(toYYYYMMMatcher.group(1));
+      return identifier == null ? null : Transforms.month(identifier);
     }
 
     Matcher toDateMatcher = TO_DATE_PATTERN.matcher(trimmedExpression);
     if (toDateMatcher.matches()) {
-      String identifier = normalizeIdentifier(toDateMatcher.group(1));
-      Preconditions.checkArgument(
-          StringUtils.isNotBlank(identifier),
-          "Unsupported partition expression: " + originalPartitionKey);
-      return Transforms.day(identifier);
+      String identifier = extractPartitionIdentifier(toDateMatcher.group(1));
+      return identifier == null ? null : Transforms.day(identifier);
     }
 
-    if (trimmedExpression.contains("(") && trimmedExpression.contains(")")) {
-      throw new UnsupportedOperationException(
-          "Currently Gravitino only supports toYear, toYYYYMM, toDate 
partition expressions, but got: "
-              + trimmedExpression);
-    }
+    String identifier = extractPartitionIdentifier(trimmedExpression);
+    return identifier == null ? null : Transforms.identity(identifier);
+  }
 
-    String identifier = normalizeIdentifier(trimmedExpression);
-    Preconditions.checkArgument(
-        isStrictIdentifier(identifier),
-        "Only simple identifier is supported for partition expression, but 
got: "
-            + originalPartitionKey);
-    return Transforms.identity(identifier);
+  /**
+   * Extracts a partition column name from an expression. A backtick-quoted 
identifier (which may
+   * contain special characters such as {@code -}) is always treated as a 
column name. Otherwise the
+   * expression must match the strict column-name pattern. Returns {@code 
null} for arbitrary
+   * expressions such as {@code f(x)} that cannot be represented as a single 
column reference.
+   */
+  @Nullable
+  private static String extractPartitionIdentifier(String expression) {
+    String trimmed = StringUtils.trim(expression);
+    if (StringUtils.startsWith(trimmed, "`")
+        && StringUtils.endsWith(trimmed, "`")
+        && trimmed.length() >= 2) {
+      String inner = trimmed.substring(1, trimmed.length() - 1);
+      return StringUtils.isNotBlank(inner) ? inner : null;
+    }
+    return isStrictIdentifier(trimmed) ? trimmed : null;
   }
 
   private static String normalizePartitionKey(String partitionKey) {
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/TestClickHouseTablePropertiesMetadata.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/TestClickHouseTablePropertiesMetadata.java
new file mode 100644
index 0000000000..81b6fbba79
--- /dev/null
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/TestClickHouseTablePropertiesMetadata.java
@@ -0,0 +1,83 @@
+/*
+ * 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.clickhouse;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.StringIdentifier;
+import 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.TableConstants;
+import org.apache.gravitino.connector.PropertyEntry;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestClickHouseTablePropertiesMetadata {
+
+  private ClickHouseTablePropertiesMetadata metadata;
+
+  @BeforeEach
+  void setUp() {
+    metadata = new ClickHouseTablePropertiesMetadata();
+  }
+
+  @Test
+  void testTransformToJdbcProperties() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(StringIdentifier.ID_KEY, "gravitino-id-123");
+    properties.put(TableConstants.PARTITION_KEY, "cityHash64(x) % 7");
+    properties.put(TableConstants.ENGINE, "MergeTree");
+    properties.put(TableConstants.SETTINGS_PREFIX + "index_granularity", 
"8192");
+
+    Map<String, String> jdbcProperties = 
metadata.transformToJdbcProperties(properties);
+
+    // gravitino.identifier and partition-key must not be written to 
ClickHouse.
+    
Assertions.assertFalse(jdbcProperties.containsKey(StringIdentifier.ID_KEY));
+    
Assertions.assertFalse(jdbcProperties.containsKey(TableConstants.PARTITION_KEY));
+    // engine is renamed to the ClickHouse-specific key.
+    Assertions.assertEquals("MergeTree", 
jdbcProperties.get(TableConstants.ENGINE_UPPER));
+    Assertions.assertFalse(jdbcProperties.containsKey(TableConstants.ENGINE));
+    // Other properties pass through unchanged.
+    Assertions.assertEquals(
+        "8192", jdbcProperties.get(TableConstants.SETTINGS_PREFIX + 
"index_granularity"));
+  }
+
+  @Test
+  void testConvertFromJdbcProperties() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(TableConstants.ENGINE_UPPER, "MergeTree");
+    properties.put(TableConstants.PARTITION_KEY, "cityHash64(x) % 7");
+
+    Map<String, String> gravitinoProperties = 
metadata.convertFromJdbcProperties(properties);
+
+    // engine is restored to the Gravitino key, and partition-key is preserved.
+    Assertions.assertEquals("MergeTree", 
gravitinoProperties.get(TableConstants.ENGINE));
+    Assertions.assertEquals(
+        "cityHash64(x) % 7", 
gravitinoProperties.get(TableConstants.PARTITION_KEY));
+  }
+
+  @Test
+  void testPartitionKeyPropertyEntry() {
+    PropertyEntry<?> entry = 
ClickHouseTablePropertiesMetadata.PARTITION_KEY_PROPERTY_ENTRY;
+    Assertions.assertEquals(TableConstants.PARTITION_KEY, entry.getName());
+    Assertions.assertTrue(entry.isReserved());
+    Assertions.assertTrue(entry.isImmutable());
+    Assertions.assertFalse(entry.isHidden());
+    Assertions.assertEquals("", entry.getDefaultValue());
+  }
+}
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
index a9ce6f64ea..a6619bd95d 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
@@ -515,6 +515,8 @@ public class CatalogClickHouseIT extends BaseIT {
     Assertions.assertEquals(Transforms.NAME_OF_MONTH, partitioning[0].name());
     Assertions.assertArrayEquals(
         new String[] {"event_time"}, ((NamedReference) 
partitioning[0].arguments()[0]).fieldName());
+    Assertions.assertEquals(
+        "toYYYYMM(event_time)", 
loaded.properties().get(TableConstants.PARTITION_KEY));
 
     Index[] indexes = loaded.index();
     Assertions.assertTrue(
@@ -590,6 +592,49 @@ public class CatalogClickHouseIT extends BaseIT {
         "Recreated table must not contain a fabricated replacement index: " + 
recreatedCreateSql);
   }
 
+  @Test
+  void testLoadTableWithNativePartitionExpression() {
+    // A valid MergeTree table whose PARTITION BY uses a native expression 
outside the structured
+    // identity/year/month/day subset must still be loadable. partitioning() 
stays empty, and the
+    // canonical native expression is exposed through the read-only 
partition-key property.
+    String name = GravitinoITUtils.genRandomName("native_partition_expr");
+    clickhouseService.executeQuery(
+        String.format(
+            "CREATE TABLE `%s`.`%s` (\n"
+                + "  `id` UInt64,\n"
+                + "  `sm4_cipher_msg` String\n"
+                + ")\n"
+                + "ENGINE = MergeTree\n"
+                + "PARTITION BY cityHash64(toString(sm4_cipher_msg)) %% 7\n"
+                + "ORDER BY id",
+            schemaName, name));
+
+    Table loaded = 
catalog.asTableCatalog().loadTable(NameIdentifier.of(schemaName, name));
+    Assertions.assertEquals(0, loaded.partitioning().length);
+    Assertions.assertEquals(
+        "cityHash64(toString(sm4_cipher_msg)) % 7",
+        loaded.properties().get(TableConstants.PARTITION_KEY));
+  }
+
+  @Test
+  void testLoadTableWithoutPartition() {
+    // An unpartitioned table exposes an empty partition-key property so the 
key is always present,
+    // and partitioning() stays empty.
+    String name = GravitinoITUtils.genRandomName("no_partition");
+    clickhouseService.executeQuery(
+        String.format(
+            "CREATE TABLE `%s`.`%s` (\n"
+                + "  `id` UInt64\n"
+                + ")\n"
+                + "ENGINE = MergeTree\n"
+                + "ORDER BY id",
+            schemaName, name));
+
+    Table loaded = 
catalog.asTableCatalog().loadTable(NameIdentifier.of(schemaName, name));
+    Assertions.assertEquals(0, loaded.partitioning().length);
+    Assertions.assertEquals("", 
loaded.properties().get(TableConstants.PARTITION_KEY));
+  }
+
   @Test
   void testCreateAndLoadCompositePrimaryKey() {
     String table = GravitinoITUtils.genRandomName("composite_primary_key");
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsPartitioning.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsPartitioning.java
index 24cf0c574a..00c0895a08 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsPartitioning.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsPartitioning.java
@@ -40,9 +40,9 @@ public class TestClickHouseTableOperationsPartitioning {
     Assertions.assertEquals(1, yearPartitions.length);
     assertSingleFieldTransform(yearPartitions[0], Transforms.NAME_OF_YEAR, 
"event_time");
 
-    Assertions.assertThrows(
-        UnsupportedOperationException.class,
-        () -> operations.parsePartitioning("cityHash64(user_id) % 16"));
+    // A native expression that cannot be structured returns an empty 
transform array. The raw
+    // expression is instead exposed through the read-only partition-key 
property during load.
+    Assertions.assertEquals(0, 
operations.parsePartitioning("cityHash64(user_id) % 16").length);
 
     Transform[] identityPartitions = 
operations.parsePartitioning("metric_type");
     Assertions.assertEquals(1, identityPartitions.length);
@@ -57,6 +57,27 @@ public class TestClickHouseTableOperationsPartitioning {
     Assertions.assertEquals(0, operations.parsePartitioning("  ").length);
   }
 
+  @Test
+  public void testNestedExpressionInsideKnownTransformIsNotMisrepresented() {
+    // A nested expression inside toYear cannot be mapped to a single-field 
year transform, so the
+    // whole partition key is treated as unsupported and returns an empty 
transform array rather
+    // than misrepresenting it as year("f(x)").
+    Assertions.assertEquals(0, 
operations.parsePartitioning("toYear(toString(event_time))").length);
+  }
+
+  @Test
+  public void testBacktickQuotedColumnNameIsStructured() {
+    // A backtick-quoted column name (which may contain special characters 
such as "-") is a real
+    // column reference and must be structured rather than rejected.
+    Transform[] monthPartitions = 
operations.parsePartitioning("toYYYYMM(`event-time`)");
+    Assertions.assertEquals(1, monthPartitions.length);
+    assertSingleFieldTransform(monthPartitions[0], Transforms.NAME_OF_MONTH, 
"event-time");
+
+    Transform[] identityPartitions = 
operations.parsePartitioning("`event-time`");
+    Assertions.assertEquals(1, identityPartitions.length);
+    assertSingleFieldTransform(identityPartitions[0], 
Transforms.NAME_OF_IDENTITY, "event-time");
+  }
+
   private void assertSingleFieldTransform(
       Transform transform, String expectedName, String expectedColumn) {
     Assertions.assertEquals(expectedName, transform.name());
diff --git a/docs/jdbc-clickhouse-catalog.md b/docs/jdbc-clickhouse-catalog.md
index 468d80909d..845964b021 100644
--- a/docs/jdbc-clickhouse-catalog.md
+++ b/docs/jdbc-clickhouse-catalog.md
@@ -234,6 +234,7 @@ If you need Gravitino to manage an existing cluster 
database or table, recreate
 | `cluster-remote-table`    | Remote table for `Distributed` engine            
                                                        | (none)        | 
No\*\*     | No       | No        |
 | `cluster-sharding-key`    | Sharding key for `Distributed` engine 
(expression allowed; referenced columns must be non-null integral) | (none)     
   | No\*\*     | No       | No        |
 | `settings.<name>`         | ClickHouse engine setting forwarded as `SETTINGS 
<name>=<value>`                                         | (none)        | No    
     | No       | No        |
+| `partition-key`           | ClickHouse's canonical native partition 
expression (from `system.tables.partition_key`). Read-only; always present on 
load, empty string means unpartitioned. | `""`          | No         | Yes      
| Yes       |
 
 \* Required when `on-cluster=true` or `engine=Distributed`.  
 \*\* Required when `engine=Distributed`.
@@ -269,6 +270,8 @@ The `engine_parameters` property applies to 
`ReplacingMergeTree`, `SummingMergeT
    - Functions: `PARTITION BY toDate(column_name)`, `PARTITION BY 
toYear(column_name)`, `PARTITION BY toYYYYMM(column_name)`. Other functions are 
not supported.
    - Not support: `PARTITION BY (column_name + 1)`, `PARTITION BY 
(toYear(column_name) + 1)`, etc. (Note: ClickHouse itself does support 
arbitrary partitioning expressions, but Gravitino supports only the above 
patterns for partitioning). 
 
+   The patterns above apply when creating a table. When loading a table, 
Gravitino preserves ClickHouse's canonical native partition expression (as 
returned by `system.tables.partition_key`) in the read-only `partition-key` 
property. An arbitrary native expression is therefore retained on load even 
when it cannot be mapped to one of the supported `Transform`s; in that case 
`Table.partitioning()` is empty and the full expression is exposed through 
`partition-key`.
+
 - Distribution: fixed to `Distributions.NONE`. For a `Distributed` engine 
table, you can specify the sharding key and remote database/table through table 
properties to fulfill the same use cases. We will later consider adding more 
flexible distribution strategies if there is demand.
 
 ### Create a Table
diff --git a/docs/partitions.md b/docs/partitions.md
index 2a3c25106d..1195b9226d 100644
--- a/docs/partitions.md
+++ b/docs/partitions.md
@@ -94,8 +94,11 @@ Individual partitions are not listed, and adding or dropping 
one goes through th
 
 The display comes from the table itself, so it works for every relational 
catalog including Iceberg.
 An Iceberg table partitioned by `day(event_time)` shows that transform like 
any other table, because
-the partition spec is read back and presented in the same form. A count of 
zero means the table
-declares no partitioning rather than that partitions could not be read.
+the partition spec is read back and presented in the same form. A count of 
zero usually means the
+table declares no partitioning rather than that partitions could not be read. 
One exception is
+ClickHouse: a table whose `PARTITION BY` uses a native expression that 
Gravitino cannot structure
+shows zero partition fields, with the canonical expression preserved in the 
read-only `partition-key`
+property instead.
 
 ## Permissions
 

Reply via email to