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 c20fc0c4b7 [#11973] feat(clickhouse): Preserve projection definitions 
on table round-trip (#13498)
c20fc0c4b7 is described below

commit c20fc0c4b7f0dc5e5352e1aa1ffed24472b3d49b
Author: StormSpirit <[email protected]>
AuthorDate: Wed Sep 30 17:08:07 2026 +0800

    [#11973] feat(clickhouse): Preserve projection definitions on table 
round-trip (#13498)
    
    ### What changes were proposed in this pull request?
    
    - Read safely reconstructable `Normal` and `Aggregate` projection
    definitions from ClickHouse `system.projections` and expose them through
    the immutable, ClickHouse-specific `clickhouse.projections` table
    property.
    - Detect the optional `settings` column through `system.columns`;
    preserve safely reconstructable settings when the server exposes them.
    ClickHouse 24.9 does not expose this column.
    - If a server-provided projection definition or setting fails
    validation, log a warning and omit the entire property while preserving
    other table metadata; permission and other unexpected SQL errors still
    propagate, and user input on `createTable` remains strictly validated.
    - Validate and emit equivalent inline projection clauses during
    `createTable`, including definitions originally added with native `ALTER
    TABLE ... ADD PROJECTION`.
    - Limit round-trip support to ClickHouse 24.9+ and the non-replicated
    MergeTree-family engines currently supported by this catalog:
    `MergeTree`, `ReplacingMergeTree`, `SummingMergeTree`,
    `AggregatingMergeTree`, `CollapsingMergeTree`,
    `VersionedCollapsingMergeTree`, and `GraphiteMergeTree`. Replicated
    MergeTree engines are out of scope.
    - Projection-level `WHERE` filters, lightweight `_part_offset`
    projections, and compact `PROJECTION ... INDEX` syntax are outside the
    round-trip scope.
    - Keep projection metadata in a connector-specific property. This change
    does not add a shared Projection API, parse table DDL as a fallback,
    manage projection ALTER/materialization operations, or copy projection
    data.
    
    ### Why are the changes needed?
    
    The ClickHouse catalog currently drops projection definitions from
    loaded table metadata. Creating a table from that metadata therefore
    loses the source table's projections. This change preserves supported
    definitions through the `loadTable`/`createTable` round-trip while
    keeping the general ClickHouse catalog baseline at 24.8.14.
    
    Fix: #11973
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds the immutable `clickhouse.projections` table property for
    projection metadata round-tripping. Projection round-trip requires a
    server that provides `system.projections` (ClickHouse 24.9 or later);
    ordinary catalog metadata loading on 24.8 remains supported. Tables
    without projections do not receive this property.
    
    If validation rejects a server-provided projection definition or
    setting, `loadTable` still returns the other table metadata but omits
    the entire property with a warning. Recreating a table from that
    metadata does not preserve its source projections.
    
    ### How was this patch tested?
    
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:spotlessCheck`:
    passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test -PskipITs`:
    144 tests passed, with no failures, errors, or skips.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test --tests
    
'org.apache.gravitino.catalog.clickhouse.integration.test.CatalogClickHouseProjectionIT'
    -PskipDockerTests=false`: 2 tests passed against ClickHouse 24.9.1.3278
    and 25.12.2.54; the 25.12 case also verifies that a table with a native
    `_part_offset` projection remains loadable without
    `clickhouse.projections`.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test --tests
    
'org.apache.gravitino.catalog.clickhouse.integration.test.CatalogClickHouseIT.testCreateAndLoadClickhouseTable'
    -PskipDockerTests=false`: 1 test passed against ClickHouse 24.8.14.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test --tests
    
'org.apache.gravitino.catalog.clickhouse.integration.test.CatalogClickHouseClusterIT'
    -PskipDockerTests=false`: 20 tests passed.
    - `./gradlew rat`: passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:build -x test`:
    passed.
    
    These local results were obtained on the final code and test state
    pushed as commit 2f012c4.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
---
 .../catalog-jdbc-clickhouse/build.gradle.kts       |   1 +
 .../ClickHouseTablePropertiesMetadata.java         |  15 +
 .../operations/ClickHouseTableOperations.java      | 117 +++++++
 .../operations/ClickHouseTableSqlUtils.java        | 298 ++++++++++++++++
 .../TestClickHouseTablePropertiesMetadata.java     |  19 +
 .../test/CatalogClickHouseProjectionIT.java        | 315 +++++++++++++++++
 .../TestClickHouseTableOperationsUnit.java         | 384 +++++++++++++++++++++
 docs/jdbc-clickhouse-catalog.md                    |  33 ++
 8 files changed, 1182 insertions(+)

diff --git a/catalogs-contrib/catalog-jdbc-clickhouse/build.gradle.kts 
b/catalogs-contrib/catalog-jdbc-clickhouse/build.gradle.kts
index 65ec197473..7739b79574 100644
--- a/catalogs-contrib/catalog-jdbc-clickhouse/build.gradle.kts
+++ b/catalogs-contrib/catalog-jdbc-clickhouse/build.gradle.kts
@@ -37,6 +37,7 @@ dependencies {
   implementation(libs.commons.collections4)
   implementation(libs.commons.lang3)
   implementation(libs.guava)
+  implementation(libs.jackson.databind)
 
   testImplementation(project(":api"))
   testImplementation(project(":catalogs:catalog-jdbc-common", "testArtifacts"))
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 cdcd83b051..31d3ae58d5 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
@@ -37,6 +37,8 @@ import org.apache.gravitino.connector.PropertyEntry;
 public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetadata {
   public static final String GRAVITINO_ENGINE_KEY = TableConstants.ENGINE;
   public static final String CLICKHOUSE_ENGINE_KEY = 
TableConstants.ENGINE_UPPER;
+  /** Property key for the structured ClickHouse projection definitions. */
+  public static final String CLICKHOUSE_PROJECTIONS_KEY = 
"clickhouse.projections";
 
   // The following two properties are mapped to ClickHouse table properties 
and can be used by
   // tables with different engines.
@@ -126,6 +128,18 @@ public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetada
           false,
           true);
 
+  /**
+   * Structured ClickHouse projection definitions. This property is immutable 
because this connector
+   * supports projection metadata round-tripping during table creation, not 
ALTER.
+   */
+  public static final PropertyEntry<String> 
CLICKHOUSE_PROJECTIONS_PROPERTY_ENTRY =
+      stringOptionalPropertyEntry(
+          CLICKHOUSE_PROJECTIONS_KEY,
+          "Structured ClickHouse projection definitions for table creation",
+          true,
+          null,
+          false);
+
   private static final Map<String, PropertyEntry<?>> PROPERTIES_METADATA =
       createPropertiesMetadata();
 
@@ -154,6 +168,7 @@ public class ClickHouseTablePropertiesMetadata extends 
JdbcTablePropertiesMetada
     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);
+    map.put(CLICKHOUSE_PROJECTIONS_PROPERTY_ENTRY.getName(), 
CLICKHOUSE_PROJECTIONS_PROPERTY_ENTRY);
 
     return Collections.unmodifiableMap(map);
   }
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 e2236b4003..3af9eac869 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
@@ -30,6 +30,7 @@ import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexC
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.RANDOM_SEED;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.SET_MAX_VALUES;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.CLICKHOUSE_ENGINE_KEY;
+import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE_PROPERTY_ENTRY;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.GRAVITINO_ENGINE_KEY;
 import static 
org.apache.gravitino.catalog.clickhouse.operations.ClickHouseClusterUtils.escapeSingleQuotes;
@@ -56,6 +57,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -103,6 +105,8 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       "Clickhouse does not support nested column names.";
   private static final String INVALID_SETTINGS_METADATA_MSG =
       "Invalid ClickHouse table SETTINGS metadata";
+  private static final int ERROR_CODE_UNKNOWN_TABLE = 60;
+  private static final Set<String> REQUIRED_PROJECTION_COLUMNS = 
Set.of("name", "type", "query");
   /** Default GRANULARITY for data skipping indexes, matching ClickHouse's own 
default. */
   private static final long DEFAULT_INDEX_GRANULARITY = 1;
 
@@ -153,6 +157,8 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       "SELECT name, type, expr, granularity FROM system.data_skipping_indices "
           + "WHERE database = ? AND table = ? ORDER BY name";
 
+  private final AtomicBoolean projectionSourceUnavailableWarningLogged = new 
AtomicBoolean();
+
   @Override
   public void create(
       String databaseName,
@@ -352,6 +358,23 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     // Add Create table clause; capture whether ON CLUSTER is in use
     boolean onCluster = appendCreateTableClause(notNullProperties, sqlBuilder, 
tableName);
 
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> projections =
+        ClickHouseTableSqlUtils.parseProjectionDefinitions(
+            notNullProperties.get(CLICKHOUSE_PROJECTIONS_KEY));
+    if (!projections.isEmpty()) {
+      Preconditions.checkArgument(
+          ArrayUtils.isNotEmpty(columns),
+          "ClickHouse projections require an explicit column definition");
+      String configuredEngine = notNullProperties.get(GRAVITINO_ENGINE_KEY);
+      ENGINE projectionEngine =
+          StringUtils.isBlank(configuredEngine)
+              ? ENGINE_PROPERTY_ENTRY.getDefaultValue()
+              : ENGINE.fromString(configuredEngine);
+      Preconditions.checkArgument(
+          projectionEngine.acceptPartition(),
+          "ClickHouse projections are supported only for MergeTree-family 
engines");
+    }
+
     // We still allow empty columns when the engine is distributed.
     if (columns.length > 0) {
       buildColumnsDefinition(columns, sqlBuilder);
@@ -359,6 +382,9 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       // Index definition
       appendIndexesSql(indexes, sqlBuilder);
 
+      // Projection definitions are part of the parenthesized CREATE TABLE 
definition.
+      
sqlBuilder.append(ClickHouseTableSqlUtils.formatProjectionClauses(projections));
+
       sqlBuilder.append("\n)");
     }
 
@@ -900,6 +926,10 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
       // 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);
+      String projectionProperty = getProjectionProperty(connection, 
databaseName, tableName);
+      if (StringUtils.isNotEmpty(projectionProperty)) {
+        merged.put(CLICKHOUSE_PROJECTIONS_KEY, projectionProperty);
+      }
       if (!systemTableMetadata.settings().isEmpty()) {
         merged.putAll(systemTableMetadata.settings());
       }
@@ -953,6 +983,93 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     throw new NoSuchTableException("Table %s does not exist in %s.", 
tableName, databaseName);
   }
 
+  @VisibleForTesting
+  @Nullable
+  String getProjectionProperty(Connection connection, String databaseName, 
String tableName)
+      throws SQLException {
+    // Probe the stable columns directly so ClickHouse 24.8, which has no 
system.projections table,
+    // can continue serving ordinary catalog metadata. Only UNKNOWN_TABLE 
means the capability is
+    // unavailable; access and other query errors must remain visible to the 
caller.
+    try (PreparedStatement probe =
+            connection.prepareStatement("SELECT database FROM 
system.projections LIMIT 0");
+        ResultSet probeResult = probe.executeQuery()) {
+      if (probeResult.next()) {
+        throw new SQLException(
+            "Unexpected row returned by the system.projections capability 
probe");
+      }
+    } catch (SQLException e) {
+      if (e.getErrorCode() == ERROR_CODE_UNKNOWN_TABLE) {
+        if (projectionSourceUnavailableWarningLogged.compareAndSet(false, 
true)) {
+          LOG.warn(
+              "ClickHouse does not provide system.projections; projection 
metadata round-trip "
+                  + "requires ClickHouse 24.9 or later");
+        }
+        return null;
+      }
+      throw e;
+    }
+
+    Set<String> projectionColumns = new HashSet<>();
+    try (PreparedStatement statement =
+            connection.prepareStatement(
+                "SELECT name FROM system.columns "
+                    + "WHERE database = 'system' AND table = 'projections'");
+        ResultSet resultSet = statement.executeQuery()) {
+      while (resultSet.next()) {
+        projectionColumns.add(resultSet.getString("name"));
+      }
+    }
+    if (!projectionColumns.containsAll(REQUIRED_PROJECTION_COLUMNS)) {
+      Set<String> missingColumns = new HashSet<>(REQUIRED_PROJECTION_COLUMNS);
+      missingColumns.removeAll(projectionColumns);
+      throw new SQLException(
+          "system.projections is missing required columns: "
+              + missingColumns.stream().sorted().collect(Collectors.joining(", 
")));
+    }
+
+    boolean hasSettingsColumn = projectionColumns.contains("settings");
+    String query =
+        "SELECT name, type, query"
+            + (hasSettingsColumn ? ", toJSONString(settings) AS settings_json" 
: "")
+            + " FROM system.projections WHERE database = ? AND table = ? ORDER 
BY name";
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> definitions = new 
ArrayList<>();
+    try (PreparedStatement statement = connection.prepareStatement(query)) {
+      statement.setString(1, databaseName);
+      statement.setString(2, tableName);
+      try (ResultSet resultSet = statement.executeQuery()) {
+        while (resultSet.next()) {
+          Map<String, String> settings;
+          try {
+            settings =
+                hasSettingsColumn
+                    ? ClickHouseTableSqlUtils.parseProjectionSettings(
+                        resultSet.getString("settings_json"))
+                    : Collections.emptyMap();
+          } catch (IllegalArgumentException e) {
+            LOG.warn(
+                "Skip projection metadata of {}.{}: {}", databaseName, 
tableName, e.getMessage());
+            return null;
+          }
+          definitions.add(
+              new ClickHouseTableSqlUtils.ProjectionDefinition(
+                  resultSet.getString("name"),
+                  resultSet.getString("type"),
+                  resultSet.getString("query"),
+                  settings));
+        }
+      }
+    }
+    if (definitions.isEmpty()) {
+      return null;
+    }
+    try {
+      return 
ClickHouseTableSqlUtils.serializeProjectionDefinitions(definitions);
+    } catch (IllegalArgumentException e) {
+      LOG.warn("Skip projection metadata of {}.{}: {}", databaseName, 
tableName, e.getMessage());
+      return null;
+    }
+  }
+
   @Override
   protected Transform[] getTablePartitioning(
       Connection connection, String databaseName, String tableName) throws 
SQLException {
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 c525dfc2a5..905a090fe2 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
@@ -18,12 +18,25 @@
  */
 package org.apache.gravitino.catalog.clickhouse.operations;
 
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import javax.annotation.Nullable;
@@ -47,9 +60,294 @@ final class ClickHouseTableSqlUtils {
       Pattern.compile("toStartOfMonth[(](.+)[)]", Pattern.CASE_INSENSITIVE);
   private static final Pattern FUNCTION_WRAPPER_PATTERN =
       Pattern.compile("^\\s*([A-Za-z0-9_]+)\\((.*)\\)\\s*$");
+  private static final Pattern PROJECTION_SETTING_NAME_PATTERN =
+      Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
+  private static final Pattern NUMERIC_SETTING_LITERAL_PATTERN =
+      Pattern.compile("^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$");
+  private static final Pattern IDENTIFIER_SETTING_LITERAL_PATTERN =
+      Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
+  private static final Set<String> PROJECTION_PROPERTY_FIELDS =
+      Set.of("name", "type", "query", "settings");
+  private static final ObjectMapper PROJECTION_JSON_MAPPER =
+      new ObjectMapper()
+          .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
+          .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
 
   private ClickHouseTableSqlUtils() {}
 
+  record ProjectionDefinition(
+      String name, String type, String query, Map<String, String> settings) {}
+
+  static String serializeProjectionDefinitions(List<ProjectionDefinition> 
definitions) {
+    ArrayNode root = PROJECTION_JSON_MAPPER.createArrayNode();
+    Set<String> projectionNames = new HashSet<>();
+    definitions.stream()
+        .sorted(Comparator.comparing(ProjectionDefinition::name))
+        .forEach(
+            definition -> {
+              validateProjectionDefinition(definition);
+              Preconditions.checkArgument(
+                  projectionNames.add(definition.name()), "Duplicate 
ClickHouse projection name");
+              ObjectNode projection = root.addObject();
+              projection.put("name", definition.name());
+              projection.put("type", definition.type());
+              projection.put("query", definition.query().trim());
+              ObjectNode settings = projection.putObject("settings");
+              new TreeMap<>(definition.settings()).forEach(settings::put);
+            });
+    try {
+      return PROJECTION_JSON_MAPPER.writeValueAsString(root);
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Unable to serialize ClickHouse 
projection metadata", e);
+    }
+  }
+
+  static List<ProjectionDefinition> parseProjectionDefinitions(@Nullable 
String json) {
+    if (StringUtils.isBlank(json)) {
+      return Collections.emptyList();
+    }
+
+    JsonNode root;
+    try {
+      root = PROJECTION_JSON_MAPPER.readTree(json);
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("Invalid ClickHouse projection 
property JSON", e);
+    }
+    Preconditions.checkArgument(
+        root != null && root.isArray(), "Projection property must be a JSON 
array");
+
+    List<ProjectionDefinition> definitions = new ArrayList<>();
+    Set<String> projectionNames = new HashSet<>();
+    for (JsonNode projection : root) {
+      Preconditions.checkArgument(
+          projection.isObject(), "Each projection definition must be a JSON 
object");
+      Iterator<String> fields = projection.fieldNames();
+      while (fields.hasNext()) {
+        String field = fields.next();
+        Preconditions.checkArgument(
+            PROJECTION_PROPERTY_FIELDS.contains(field),
+            "Unsupported ClickHouse projection property field: %s",
+            field);
+      }
+
+      String name = requiredText(projection, "name");
+      String type = requiredText(projection, "type");
+      String query = requiredText(projection, "query");
+      Map<String, String> settings = 
parseProjectionSettings(projection.get("settings"));
+      ProjectionDefinition definition = new ProjectionDefinition(name, type, 
query, settings);
+      validateProjectionDefinition(definition);
+      Preconditions.checkArgument(
+          projectionNames.add(name), "Duplicate ClickHouse projection name");
+      definitions.add(definition);
+    }
+    definitions.sort(Comparator.comparing(ProjectionDefinition::name));
+    return Collections.unmodifiableList(definitions);
+  }
+
+  static String formatProjectionClauses(List<ProjectionDefinition> 
definitions) {
+    StringBuilder sqlBuilder = new StringBuilder();
+    for (ProjectionDefinition definition : definitions) {
+      validateProjectionDefinition(definition);
+      sqlBuilder
+          .append(",\n PROJECTION ")
+          .append(quoteProjectionIdentifier(definition.name()))
+          .append(" (\n  ")
+          .append(definition.query().trim())
+          .append("\n )");
+      if (!definition.settings().isEmpty()) {
+        String settings =
+            new TreeMap<>(definition.settings())
+                .entrySet().stream()
+                    .map(entry -> entry.getKey() + " = " + entry.getValue())
+                    .collect(java.util.stream.Collectors.joining(", "));
+        sqlBuilder.append(" WITH SETTINGS (").append(settings).append(")");
+      }
+    }
+    return sqlBuilder.toString();
+  }
+
+  private static String requiredText(JsonNode object, String field) {
+    JsonNode value = object.get(field);
+    Preconditions.checkArgument(
+        value != null && value.isTextual() && 
StringUtils.isNotBlank(value.asText()),
+        "ClickHouse projection property must include non-empty text field 
'%s'",
+        field);
+    return value.asText();
+  }
+
+  static Map<String, String> parseProjectionSettings(String json) {
+    JsonNode settingsNode;
+    try {
+      settingsNode = PROJECTION_JSON_MAPPER.readTree(json);
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("Invalid ClickHouse projection 
settings JSON", e);
+    }
+    return parseProjectionSettings(settingsNode);
+  }
+
+  private static Map<String, String> parseProjectionSettings(@Nullable 
JsonNode settingsNode) {
+    if (settingsNode == null) {
+      return Collections.emptyMap();
+    }
+    Preconditions.checkArgument(
+        settingsNode.isObject(), "Projection settings must be a JSON object");
+
+    Map<String, String> settings = new TreeMap<>();
+    Iterator<Map.Entry<String, JsonNode>> fields = settingsNode.fields();
+    while (fields.hasNext()) {
+      Map.Entry<String, JsonNode> field = fields.next();
+      JsonNode value = field.getValue();
+      Preconditions.checkArgument(
+          PROJECTION_SETTING_NAME_PATTERN.matcher(field.getKey()).matches(),
+          "Invalid ClickHouse projection setting name");
+      Preconditions.checkArgument(
+          value.isTextual() && isSafeSettingLiteral(value.asText()),
+          "Unsupported ClickHouse projection setting value");
+      settings.put(field.getKey(), value.asText().trim());
+    }
+    return Collections.unmodifiableMap(settings);
+  }
+
+  private static void validateProjectionDefinition(ProjectionDefinition 
definition) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(definition.name()), "ClickHouse projection name 
is required");
+    Preconditions.checkArgument(
+        definition.name().chars().noneMatch(Character::isISOControl),
+        "ClickHouse projection name contains a control character");
+    Preconditions.checkArgument(
+        "Normal".equals(definition.type()) || 
"Aggregate".equals(definition.type()),
+        "Unsupported ClickHouse projection type: %s",
+        definition.type());
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(definition.query()) && 
isSafeProjectionQuery(definition.query()),
+        "Invalid ClickHouse projection query");
+    Preconditions.checkArgument(
+        definition.settings() != null, "Projection settings cannot be null");
+    definition
+        .settings()
+        .forEach(
+            (name, value) -> {
+              Preconditions.checkArgument(
+                  PROJECTION_SETTING_NAME_PATTERN.matcher(name).matches(),
+                  "Invalid ClickHouse projection setting name");
+              Preconditions.checkArgument(
+                  isSafeSettingLiteral(value), "Unsupported ClickHouse 
projection setting value");
+            });
+  }
+
+  private static boolean isSafeSettingLiteral(String value) {
+    String literal = StringUtils.trimToEmpty(value);
+    return NUMERIC_SETTING_LITERAL_PATTERN.matcher(literal).matches()
+        || "true".equalsIgnoreCase(literal)
+        || "false".equalsIgnoreCase(literal)
+        || IDENTIFIER_SETTING_LITERAL_PATTERN.matcher(literal).matches()
+        || isValidQuotedSettingLiteral(literal);
+  }
+
+  private static boolean isValidQuotedSettingLiteral(String literal) {
+    if (literal.length() < 2
+        || literal.charAt(0) != '\''
+        || literal.charAt(literal.length() - 1) != '\'') {
+      return false;
+    }
+    for (int i = 1; i < literal.length() - 1; i++) {
+      char current = literal.charAt(i);
+      if (current == '\\') {
+        if (i + 1 >= literal.length() - 1) {
+          return false;
+        }
+        i++;
+      } else if (current == '\'') {
+        if (i + 1 >= literal.length() - 1 || literal.charAt(i + 1) != '\'') {
+          return false;
+        }
+        i++;
+      }
+    }
+    return true;
+  }
+
+  private static boolean isSafeProjectionQuery(String query) {
+    int depth = 0;
+    char quote = 0;
+    boolean identifierQuote = false;
+    StringBuilder quotedIdentifier = new StringBuilder();
+    boolean hasSelect = false;
+    StringBuilder token = new StringBuilder();
+    for (int i = 0; i < query.length(); i++) {
+      char current = query.charAt(i);
+      if (quote != 0) {
+        if (current == '\\' && i + 1 < query.length()) {
+          char escaped = query.charAt(++i);
+          if (identifierQuote) {
+            quotedIdentifier.append(escaped);
+          }
+        } else if (current == quote) {
+          if (i + 1 < query.length() && query.charAt(i + 1) == quote) {
+            if (identifierQuote) {
+              quotedIdentifier.append(current);
+            }
+            i++;
+          } else {
+            if (identifierQuote && 
"_part_offset".equalsIgnoreCase(quotedIdentifier.toString())) {
+              return false;
+            }
+            quote = 0;
+            identifierQuote = false;
+            quotedIdentifier.setLength(0);
+          }
+        } else if (identifierQuote) {
+          quotedIdentifier.append(current);
+        }
+        continue;
+      }
+
+      if (Character.isLetterOrDigit(current) || current == '_') {
+        token.append(Character.toLowerCase(current));
+        continue;
+      }
+      if (!token.isEmpty()) {
+        String value = token.toString();
+        if ("_part_offset".equals(value) || (depth == 0 && 
"where".equals(value))) {
+          return false;
+        }
+        hasSelect |= "select".equals(value);
+        token.setLength(0);
+      }
+
+      if (current == '\'' || current == '"' || current == '`') {
+        quote = current;
+        identifierQuote = current != '\'';
+        quotedIdentifier.setLength(0);
+      } else if (current == ';' || current == '#') {
+        return false;
+      } else if (current == '-' && i + 1 < query.length() && query.charAt(i + 
1) == '-') {
+        return false;
+      } else if (current == '/'
+          && i + 1 < query.length()
+          && (query.charAt(i + 1) == '*' || query.charAt(i + 1) == '/')) {
+        return false;
+      } else if (current == '(') {
+        depth++;
+      } else if (current == ')' && --depth < 0) {
+        return false;
+      }
+    }
+    if (!token.isEmpty()) {
+      String value = token.toString();
+      if ("_part_offset".equals(value) || (depth == 0 && 
"where".equals(value))) {
+        return false;
+      }
+      hasSelect |= "select".equals(value);
+    }
+    return quote == 0 && depth == 0 && hasSelect;
+  }
+
+  private static String quoteProjectionIdentifier(String identifier) {
+    Preconditions.checkArgument(StringUtils.isNotBlank(identifier), 
"Projection name is required");
+    return "`" + identifier.replace("\\", "\\\\").replace("`", "\\`") + "`";
+  }
+
   static Transform[] parsePartitioning(@Nullable String partitionKey) {
     if (StringUtils.isBlank(partitionKey)) {
       return Transforms.EMPTY_TRANSFORM;
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
index 81b6fbba79..53197ec1b9 100644
--- 
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
@@ -43,6 +43,10 @@ public class TestClickHouseTablePropertiesMetadata {
     properties.put(TableConstants.PARTITION_KEY, "cityHash64(x) % 7");
     properties.put(TableConstants.ENGINE, "MergeTree");
     properties.put(TableConstants.SETTINGS_PREFIX + "index_granularity", 
"8192");
+    properties.put(
+        ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY,
+        "[{\"name\":\"by_name\",\"type\":\"Normal\","
+            + "\"query\":\"SELECT name ORDER BY name\",\"settings\":{}}]");
 
     Map<String, String> jdbcProperties = 
metadata.transformToJdbcProperties(properties);
 
@@ -55,6 +59,9 @@ public class TestClickHouseTablePropertiesMetadata {
     // Other properties pass through unchanged.
     Assertions.assertEquals(
         "8192", jdbcProperties.get(TableConstants.SETTINGS_PREFIX + 
"index_granularity"));
+    Assertions.assertEquals(
+        
properties.get(ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY),
+        
jdbcProperties.get(ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY));
   }
 
   @Test
@@ -80,4 +87,16 @@ public class TestClickHouseTablePropertiesMetadata {
     Assertions.assertFalse(entry.isHidden());
     Assertions.assertEquals("", entry.getDefaultValue());
   }
+
+  @Test
+  void testClickHouseProjectionsPropertyEntry() {
+    PropertyEntry<?> entry =
+        
ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_PROPERTY_ENTRY;
+    Assertions.assertEquals(
+        ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY, 
entry.getName());
+    Assertions.assertTrue(entry.isImmutable());
+    Assertions.assertFalse(entry.isReserved());
+    Assertions.assertFalse(entry.isHidden());
+    Assertions.assertNull(entry.getDefaultValue());
+  }
 }
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseProjectionIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseProjectionIT.java
new file mode 100644
index 0000000000..e6f0e9ce47
--- /dev/null
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseProjectionIT.java
@@ -0,0 +1,315 @@
+/*
+ * 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.integration.test;
+
+import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.TableConstants;
+import org.apache.gravitino.catalog.jdbc.config.JdbcConfig;
+import org.apache.gravitino.client.GravitinoMetalake;
+import org.apache.gravitino.integration.test.container.ClickHouseContainer;
+import org.apache.gravitino.integration.test.util.BaseIT;
+import org.apache.gravitino.integration.test.util.GravitinoITUtils;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableCatalog;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+/**
+ * Integration tests for projection metadata round-tripping across ClickHouse 
system-table schemas.
+ */
+@Tag("gravitino-docker-test")
+@TestInstance(Lifecycle.PER_CLASS)
+public class CatalogClickHouseProjectionIT extends BaseIT {
+
+  private static final String CLICKHOUSE_24_9_IMAGE = 
"clickhouse/clickhouse-server:24.9.1.3278";
+  private static final String CLICKHOUSE_25_12_IMAGE = 
"clickhouse/clickhouse-server:25.12.2.54";
+  private static final String DATABASE = "gravitino_projection_it";
+  private static final String SOURCE_TABLE = "projection_source";
+  private static final String TARGET_TABLE = "projection_target";
+  private static final String NO_PROJECTION_TABLE = "no_projection_source";
+  private static final String UNSUPPORTED_PROJECTION_TABLE = 
"unsupported_projection_source";
+
+  private GravitinoMetalake metalake;
+  private String metalakeName;
+
+  @BeforeAll
+  void createMetalake() {
+    metalakeName = 
GravitinoITUtils.genRandomName("clickhouse_projection_it_metalake");
+    client.createMetalake(
+        metalakeName, "projection metadata integration test", 
Collections.emptyMap());
+    metalake = client.loadMetalake(metalakeName);
+  }
+
+  @AfterAll
+  void dropMetalake() {
+    if (metalake != null) {
+      client.disableMetalake(metalakeName);
+      client.dropMetalake(metalakeName, true);
+    }
+  }
+
+  @Test
+  void testProjectionRoundTripOnClickHouse24_9WithoutSettingsColumn() throws 
Exception {
+    runProjectionRoundTrip(CLICKHOUSE_24_9_IMAGE, false);
+  }
+
+  @Test
+  void testProjectionSettingsRoundTripOnClickHouse25_12() throws Exception {
+    runProjectionRoundTrip(CLICKHOUSE_25_12_IMAGE, true);
+  }
+
+  private void runProjectionRoundTrip(String image, boolean 
withProjectionSettings)
+      throws Exception {
+    try (HostMappedClickHouseContainer container = new 
HostMappedClickHouseContainer(image)) {
+      container.start();
+      String catalogName = 
GravitinoITUtils.genRandomName("clickhouse_projection_it_catalog");
+      Catalog catalog = createCatalog(catalogName, container);
+      try {
+        catalog.asSchemas().createSchema(DATABASE, null, 
Collections.emptyMap());
+        createSourceTable(container, withProjectionSettings);
+
+        TableCatalog tableCatalog = catalog.asTableCatalog();
+        NameIdentifier sourceIdentifier = NameIdentifier.of(DATABASE, 
SOURCE_TABLE);
+        Table loaded = tableCatalog.loadTable(sourceIdentifier);
+        String projectionProperty = 
loaded.properties().get(CLICKHOUSE_PROJECTIONS_KEY);
+        Assertions.assertNotNull(projectionProperty, 
loaded.properties().toString());
+        JsonNode definitions = new ObjectMapper().readTree(projectionProperty);
+        Assertions.assertEquals(4, definitions.size());
+        JsonNode orderedProjection = null;
+        JsonNode aggregateProjection = null;
+        Set<String> projectionNames = new HashSet<>();
+        for (JsonNode definition : definitions) {
+          String projectionName = definition.get("name").asText();
+          projectionNames.add(projectionName);
+          if ("by date".equals(projectionName)) {
+            orderedProjection = definition;
+          } else if ("by_region".equals(projectionName)) {
+            aggregateProjection = definition;
+          }
+        }
+        Assertions.assertEquals(
+            Set.of("by date", "by_region", "by expression", "by_customer"), 
projectionNames);
+        Assertions.assertNotNull(orderedProjection);
+        Assertions.assertEquals("Normal", 
orderedProjection.get("type").asText());
+        Assertions.assertNotNull(aggregateProjection);
+        Assertions.assertEquals("Aggregate", 
aggregateProjection.get("type").asText());
+        if (withProjectionSettings) {
+          Assertions.assertEquals(
+              "128", 
orderedProjection.get("settings").get("index_granularity").asText());
+        } else {
+          Assertions.assertTrue(orderedProjection.get("settings").isEmpty());
+        }
+
+        NameIdentifier targetIdentifier = NameIdentifier.of(DATABASE, 
TARGET_TABLE);
+        Table recreated =
+            tableCatalog.createTable(
+                targetIdentifier,
+                loaded.columns(),
+                loaded.comment(),
+                loaded.properties(),
+                loaded.partitioning(),
+                loaded.distribution(),
+                loaded.sortOrder(),
+                loaded.index());
+        Assertions.assertEquals(TARGET_TABLE, recreated.name());
+
+        Table loadedTarget = tableCatalog.loadTable(targetIdentifier);
+        Assertions.assertEquals(
+            projectionProperty, 
loadedTarget.properties().get(CLICKHOUSE_PROJECTIONS_KEY));
+        String sourceCreate = readCreateTable(container, SOURCE_TABLE);
+        String targetCreate = readCreateTable(container, TARGET_TABLE);
+        for (String projectionName : projectionNames) {
+          Assertions.assertTrue(sourceCreate.contains(projectionName), 
sourceCreate);
+          Assertions.assertTrue(targetCreate.contains(projectionName), 
targetCreate);
+        }
+        Assertions.assertTrue(sourceCreate.contains("PROJECTION"), 
sourceCreate);
+        Assertions.assertTrue(targetCreate.contains("PROJECTION"), 
targetCreate);
+        if (withProjectionSettings) {
+          Assertions.assertTrue(targetCreate.contains("index_granularity"), 
targetCreate);
+        }
+        List<List<String>> sourceRows =
+            readProjectionRows(container, SOURCE_TABLE, 
withProjectionSettings);
+        List<List<String>> targetRows =
+            readProjectionRows(container, TARGET_TABLE, 
withProjectionSettings);
+        Assertions.assertEquals(4, sourceRows.size());
+        Assertions.assertEquals(sourceRows, targetRows);
+
+        Table withoutProjections =
+            tableCatalog.loadTable(NameIdentifier.of(DATABASE, 
NO_PROJECTION_TABLE));
+        Assertions.assertFalse(
+            
withoutProjections.properties().containsKey(CLICKHOUSE_PROJECTIONS_KEY));
+        if (withProjectionSettings) {
+          Table withUnsupportedProjection =
+              tableCatalog.loadTable(NameIdentifier.of(DATABASE, 
UNSUPPORTED_PROJECTION_TABLE));
+          Assertions.assertEquals(2, 
withUnsupportedProjection.columns().length);
+          Assertions.assertEquals(
+              "MergeTree", 
withUnsupportedProjection.properties().get(TableConstants.ENGINE));
+          Assertions.assertFalse(
+              
withUnsupportedProjection.properties().containsKey(CLICKHOUSE_PROJECTIONS_KEY));
+        }
+      } finally {
+        metalake.disableCatalog(catalogName);
+        metalake.dropCatalog(catalogName, true);
+      }
+    }
+  }
+
+  private Catalog createCatalog(String catalogName, 
HostMappedClickHouseContainer container) {
+    Map<String, String> properties =
+        Map.of(
+            JdbcConfig.JDBC_URL.getKey(), container.getJdbcUrl(),
+            JdbcConfig.JDBC_DRIVER.getKey(), 
"com.clickhouse.jdbc.ClickHouseDriver",
+            JdbcConfig.USERNAME.getKey(), container.getUsername(),
+            JdbcConfig.PASSWORD.getKey(), container.getPassword());
+    metalake.createCatalog(
+        catalogName, Catalog.Type.RELATIONAL, "jdbc-clickhouse", "projection 
catalog", properties);
+    return metalake.loadCatalog(catalogName);
+  }
+
+  private static void createSourceTable(
+      ClickHouseContainer container, boolean withProjectionSettings) throws 
SQLException {
+    String customSettings =
+        withProjectionSettings ? " WITH SETTINGS (index_granularity = 128)" : 
"";
+    String createTable =
+        "CREATE TABLE "
+            + DATABASE
+            + "."
+            + SOURCE_TABLE
+            + " ("
+            + "id UInt64, event_date Date, region String, customer String, "
+            + "PROJECTION `by date` (SELECT event_date, id ORDER BY 
event_date, id)"
+            + customSettings
+            + ", PROJECTION by_region (SELECT region, count() GROUP BY region)"
+            + ", PROJECTION `by expression` (SELECT lower(concat(customer, 
'O''Reilly')) "
+            + "ORDER BY lower(concat(customer, 'O''Reilly')))"
+            + ") ENGINE = MergeTree ORDER BY id";
+    String createTableWithoutProjections =
+        "CREATE TABLE "
+            + DATABASE
+            + "."
+            + NO_PROJECTION_TABLE
+            + " (id UInt64) ENGINE = MergeTree ORDER BY id";
+    String addProjection =
+        "ALTER TABLE "
+            + DATABASE
+            + "."
+            + SOURCE_TABLE
+            + " ADD PROJECTION by_customer (SELECT customer ORDER BY 
customer)";
+
+    try (Connection connection =
+            DriverManager.getConnection(
+                container.getJdbcUrl(), container.getUsername(), 
container.getPassword());
+        Statement statement = connection.createStatement()) {
+      statement.execute(createTable);
+      statement.execute(addProjection);
+      statement.execute(createTableWithoutProjections);
+      if (withProjectionSettings) {
+        statement.execute(
+            "CREATE TABLE "
+                + DATABASE
+                + "."
+                + UNSUPPORTED_PROJECTION_TABLE
+                + " (a UInt64, b String, PROJECTION p (SELECT _part_offset 
ORDER BY b))"
+                + " ENGINE = MergeTree ORDER BY a");
+      }
+    }
+  }
+
+  private static List<List<String>> readProjectionRows(
+      ClickHouseContainer container, String table, boolean includeSettings) 
throws SQLException {
+    String query =
+        "SELECT name, type, query"
+            + (includeSettings ? ", toJSONString(settings) AS settings_json" : 
"")
+            + " FROM system.projections WHERE database = ? AND table = ? ORDER 
BY name";
+    List<List<String>> rows = new ArrayList<>();
+    try (Connection connection =
+            DriverManager.getConnection(
+                container.getJdbcUrl(), container.getUsername(), 
container.getPassword());
+        PreparedStatement statement = connection.prepareStatement(query)) {
+      statement.setString(1, DATABASE);
+      statement.setString(2, table);
+      try (ResultSet resultSet = statement.executeQuery()) {
+        while (resultSet.next()) {
+          rows.add(
+              List.of(
+                  resultSet.getString("name"),
+                  resultSet.getString("type"),
+                  resultSet.getString("query"),
+                  includeSettings ? resultSet.getString("settings_json") : 
"{}"));
+        }
+      }
+    }
+    return rows;
+  }
+
+  private static String readCreateTable(ClickHouseContainer container, String 
table)
+      throws SQLException {
+    String query = "SHOW CREATE TABLE " + DATABASE + "." + table;
+    try (Connection connection =
+            DriverManager.getConnection(
+                container.getJdbcUrl(), container.getUsername(), 
container.getPassword());
+        Statement statement = connection.createStatement();
+        ResultSet resultSet = statement.executeQuery(query)) {
+      Assertions.assertTrue(resultSet.next(), query);
+      return resultSet.getString(1);
+    }
+  }
+
+  private static final class HostMappedClickHouseContainer extends 
ClickHouseContainer {
+    private HostMappedClickHouseContainer(String image) {
+      super(
+          image,
+          "gravitino-projection-it",
+          Set.of(CLICKHOUSE_PORT),
+          Map.of(),
+          Map.of(),
+          Map.of("CLICKHOUSE_PASSWORD", PASSWORD),
+          Optional.empty());
+    }
+
+    @Override
+    public String getJdbcUrl() {
+      return "jdbc:clickhouse:http://%s:%d?compress=0";
+          .formatted(getContainer().getHost(), getMappedPort(CLICKHOUSE_PORT));
+    }
+  }
+}
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
index a1e10b116a..fb915f4136 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/operations/TestClickHouseTableOperationsUnit.java
@@ -18,9 +18,11 @@
  */
 package org.apache.gravitino.catalog.clickhouse.operations;
 
+import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.CLICKHOUSE_PROJECTIONS_KEY;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseUtils.getSortOrders;
 
 import java.sql.Connection;
+import java.sql.DatabaseMetaData;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
@@ -69,6 +71,11 @@ public class TestClickHouseTableOperationsUnit {
       return getSystemTableMetadata(connection, databaseName, tableName);
     }
 
+    String callGetProjectionProperty(Connection connection, String 
databaseName, String tableName)
+        throws Exception {
+      return getProjectionProperty(connection, databaseName, tableName);
+    }
+
     Map<String, String> callGetTableProperties(Connection connection, String 
tableName)
         throws Exception {
       return getTableProperties(connection, tableName);
@@ -486,6 +493,383 @@ public class TestClickHouseTableOperationsUnit {
     Assertions.assertTrue(metadata.settings().isEmpty());
   }
 
+  @Test
+  void testProjectionPropertyRoundTripAndDeterministicOrdering() {
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> definitions =
+        List.of(
+            new ClickHouseTableSqlUtils.ProjectionDefinition(
+                "z_aggregate",
+                "Aggregate",
+                "SELECT region, count() GROUP BY region",
+                Map.of("index_granularity", "128")),
+            new ClickHouseTableSqlUtils.ProjectionDefinition(
+                "a projection", "Normal", "SELECT concat(name, ')') ORDER BY 
name", Map.of()));
+
+    String property = 
ClickHouseTableSqlUtils.serializeProjectionDefinitions(definitions);
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> parsed =
+        ClickHouseTableSqlUtils.parseProjectionDefinitions(property);
+
+    Assertions.assertEquals("a projection", parsed.get(0).name());
+    Assertions.assertEquals("z_aggregate", parsed.get(1).name());
+    Assertions.assertEquals(Map.of("index_granularity", "128"), 
parsed.get(1).settings());
+    String clauses = ClickHouseTableSqlUtils.formatProjectionClauses(parsed);
+    Assertions.assertTrue(clauses.contains("PROJECTION `a projection`"), 
clauses);
+    Assertions.assertTrue(clauses.contains("PROJECTION `z_aggregate`"), 
clauses);
+    Assertions.assertTrue(clauses.contains("WITH SETTINGS (index_granularity = 
128)"), clauses);
+    Assertions.assertTrue(
+        clauses.indexOf("a projection") < clauses.indexOf("z_aggregate"), 
clauses);
+  }
+
+  @Test
+  void testProjectionPropertyParserRejectsMalformedOrUnsafeValues() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> ClickHouseTableSqlUtils.parseProjectionDefinitions("not json"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\","
+                    + "\"query\":\"SELECT x\"}] trailing content"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\",\"query\":\"SELECT 
x)\"}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\","
+                    + "\"query\":\"SELECT x; DROP TABLE t\"}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\",\"query\":\"SELECT x\","
+                    + "\"settings\":{\"index_granularity\":\"1; DROP TABLE 
t\"}}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Lightweight\",\"query\":\"SELECT 
x\"}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\"," + "\"query\":\"SELECT 
x WHERE x > 0\"}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\","
+                    + "\"query\":\"SELECT _part_offset ORDER BY 
_part_offset\"}]"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClickHouseTableSqlUtils.parseProjectionDefinitions(
+                "[{\"name\":\"p\",\"type\":\"Normal\","
+                    + "\"query\":\"SELECT `_part_offset` ORDER BY 
tuple()\"}]"));
+  }
+
+  @Test
+  void testGenerateCreateTableSqlAppendsProjectionInsideDefinition() {
+    String property =
+        ClickHouseTableSqlUtils.serializeProjectionDefinitions(
+            List.of(
+                new ClickHouseTableSqlUtils.ProjectionDefinition(
+                    "by_name", "Normal", "SELECT name ORDER BY name", 
Map.of())));
+
+    String sql = 
newOps().callGenerateCreateTableSql(Map.of(CLICKHOUSE_PROJECTIONS_KEY, 
property));
+
+    Assertions.assertTrue(
+        sql.contains(",\n PROJECTION `by_name` (\n  SELECT name ORDER BY 
name\n )"), sql);
+    Assertions.assertTrue(sql.indexOf("PROJECTION") < sql.indexOf("\n)"), sql);
+    
Assertions.assertFalse(newOps().callGenerateCreateTableSql(Map.of()).contains("PROJECTION"));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            newOps()
+                .callGenerateCreateTableSql(
+                    Map.of(
+                        CLICKHOUSE_PROJECTIONS_KEY,
+                        property,
+                        TableConstants.ENGINE,
+                        ENGINE.LOG.getValue())));
+  }
+
+  @Test
+  void testGetProjectionPropertyReads24_9SchemaWithoutSettings() throws 
Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+    ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe)
+        .thenReturn(columns)
+        .thenReturn(projections);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(probeResult.next()).thenReturn(false);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, true, false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type", 
"query");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(true, false);
+    Mockito.when(projectionResult.getString("name")).thenReturn("by_name");
+    Mockito.when(projectionResult.getString("type")).thenReturn("Normal");
+    Mockito.when(projectionResult.getString("query")).thenReturn("SELECT name 
ORDER BY name");
+
+    String property = ops.callGetProjectionProperty(connection, "db", "table");
+
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> definitions =
+        ClickHouseTableSqlUtils.parseProjectionDefinitions(property);
+    Assertions.assertEquals(1, definitions.size());
+    Assertions.assertEquals("by_name", definitions.get(0).name());
+    Assertions.assertTrue(definitions.get(0).settings().isEmpty());
+    Mockito.verify(connection, 
Mockito.times(3)).prepareStatement(sqlCaptor.capture());
+    
Assertions.assertFalse(sqlCaptor.getAllValues().get(2).contains("settings"));
+    Mockito.verify(projections).setString(1, "db");
+    Mockito.verify(projections).setString(2, "table");
+  }
+
+  @Test
+  void testGetProjectionPropertyReadsSettingsWhenSystemTableExposesColumn() 
throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+    ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe)
+        .thenReturn(columns)
+        .thenReturn(projections);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(probeResult.next()).thenReturn(false);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, true, true, 
false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type", 
"query", "settings");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(true, false);
+    Mockito.when(projectionResult.getString("name")).thenReturn("by_name");
+    Mockito.when(projectionResult.getString("type")).thenReturn("Normal");
+    Mockito.when(projectionResult.getString("query")).thenReturn("SELECT name 
ORDER BY name");
+    Mockito.when(projectionResult.getString("settings_json"))
+        .thenReturn("{\"index_granularity\":\"128\"}");
+
+    String property = ops.callGetProjectionProperty(connection, "db", "table");
+
+    List<ClickHouseTableSqlUtils.ProjectionDefinition> definitions =
+        ClickHouseTableSqlUtils.parseProjectionDefinitions(property);
+    Assertions.assertEquals(Map.of("index_granularity", "128"), 
definitions.get(0).settings());
+    Mockito.verify(connection, 
Mockito.times(3)).prepareStatement(sqlCaptor.capture());
+    
Assertions.assertTrue(sqlCaptor.getAllValues().get(2).contains("toJSONString(settings)"));
+  }
+
+  @Test
+  void testGetProjectionPropertySkipsAllDefinitionsForUnsupportedQuery() 
throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe, columns, projections);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, true, false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type", 
"query");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(true, true, false);
+    Mockito.when(projectionResult.getString("name")).thenReturn("by_name", 
"by_offset");
+    Mockito.when(projectionResult.getString("type")).thenReturn("Normal", 
"Normal");
+    Mockito.when(projectionResult.getString("query"))
+        .thenReturn("SELECT name ORDER BY name", "SELECT _part_offset ORDER BY 
b");
+
+    Assertions.assertNull(ops.callGetProjectionProperty(connection, "db", 
"table"));
+  }
+
+  @Test
+  void testGetProjectionPropertySkipsUnsupportedSettings() throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe, columns, projections);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, true, true, 
false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type", 
"query", "settings");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(true, false);
+    Mockito.when(projectionResult.getString("settings_json"))
+        .thenReturn("{\"index_granularity\":\"1 + 1\"}");
+
+    Assertions.assertNull(ops.callGetProjectionProperty(connection, "db", 
"table"));
+  }
+
+  @Test
+  void testLoadKeepsOtherPropertiesWhenProjectionIsUnsupported() throws 
Exception {
+    DataSource dataSource = Mockito.mock(DataSource.class);
+    Connection connection = Mockito.mock(Connection.class);
+    DatabaseMetaData metadata = Mockito.mock(DatabaseMetaData.class);
+    ResultSet tableResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    PreparedStatement systemTable = Mockito.mock(PreparedStatement.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projectionColumns = 
Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet systemTableResult = Mockito.mock(ResultSet.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionColumnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+
+    Mockito.when(dataSource.getConnection()).thenReturn(connection);
+    Mockito.when(connection.getCatalog()).thenReturn("db");
+    Mockito.when(connection.getMetaData()).thenReturn(metadata);
+    Mockito.when(metadata.getTables("db", null, "t", 
null)).thenReturn(tableResult);
+    Mockito.when(metadata.getColumns("db", "db", "t", 
null)).thenReturn(columnResult);
+    Mockito.when(tableResult.next()).thenReturn(true, false);
+    Mockito.when(tableResult.getString("TABLE_NAME")).thenReturn("t");
+    Mockito.when(columnResult.next()).thenReturn(true, false);
+    Mockito.when(columnResult.getString("TABLE_NAME")).thenReturn("t");
+    Mockito.when(columnResult.getString("COLUMN_NAME")).thenReturn("a");
+    Mockito.when(columnResult.getString("TYPE_NAME")).thenReturn("Int32");
+    Mockito.when(columnResult.getString("IS_AUTOINCREMENT")).thenReturn("NO");
+
+    ClickHouseTableOperations ops = Mockito.spy(new 
ClickHouseTableOperations());
+    ops.initialize(
+        dataSource,
+        new ClickHouseExceptionConverter(),
+        new ClickHouseTypeConverter(),
+        new ClickHouseColumnDefaultValueConverter(),
+        new HashMap<>());
+    Mockito.doReturn(Map.of()).when(ops).getDefaultKinds(connection, "db", 
"t");
+    Mockito.doReturn(List.of()).when(ops).getIndexes(connection, "db", "t");
+    Mockito.doReturn("").when(ops).getPartitionKey(connection, "db", "t");
+    Mockito.doReturn(Map.of(TableConstants.ENGINE, "MergeTree"))
+        .when(ops)
+        .getTableProperties(connection, "t");
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(systemTable, probe, projectionColumns, projections);
+    Mockito.when(systemTable.executeQuery()).thenReturn(systemTableResult);
+    Mockito.when(systemTableResult.next()).thenReturn(true, false);
+    Mockito.when(systemTableResult.getString("sorting_key")).thenReturn("a");
+    
Mockito.when(systemTableResult.getString("engine_full")).thenReturn("MergeTree 
ORDER BY a");
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    
Mockito.when(projectionColumns.executeQuery()).thenReturn(projectionColumnResult);
+    Mockito.when(projectionColumnResult.next()).thenReturn(true, true, true, 
false);
+    Mockito.when(projectionColumnResult.getString("name")).thenReturn("name", 
"type", "query");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(true, false);
+    Mockito.when(projectionResult.getString("name")).thenReturn("p");
+    Mockito.when(projectionResult.getString("type")).thenReturn("Normal");
+    Mockito.when(projectionResult.getString("query")).thenReturn("SELECT 
_part_offset ORDER BY a");
+
+    JdbcTable table = ops.load("db", "t");
+
+    Assertions.assertEquals(1, table.columns().length);
+    Assertions.assertEquals("MergeTree", 
table.properties().get(TableConstants.ENGINE));
+    Assertions.assertEquals("", 
table.properties().get(TableConstants.PARTITION_KEY));
+    
Assertions.assertFalse(table.properties().containsKey(CLICKHOUSE_PROJECTIONS_KEY));
+  }
+
+  @Test
+  void testGetProjectionPropertyReturnsNullWhenAvailableTableHasNoRows() 
throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    PreparedStatement projections = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+    ResultSet projectionResult = Mockito.mock(ResultSet.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe)
+        .thenReturn(columns)
+        .thenReturn(projections);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(probeResult.next()).thenReturn(false);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, true, false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type", 
"query");
+    Mockito.when(projections.executeQuery()).thenReturn(projectionResult);
+    Mockito.when(projectionResult.next()).thenReturn(false);
+
+    Assertions.assertNull(ops.callGetProjectionProperty(connection, "db", 
"table"));
+  }
+
+  @Test
+  void testGetProjectionPropertyRejectsMissingRequiredSystemColumn() throws 
Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection connection = Mockito.mock(Connection.class);
+    PreparedStatement probe = Mockito.mock(PreparedStatement.class);
+    PreparedStatement columns = Mockito.mock(PreparedStatement.class);
+    ResultSet probeResult = Mockito.mock(ResultSet.class);
+    ResultSet columnResult = Mockito.mock(ResultSet.class);
+
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(probe)
+        .thenReturn(columns);
+    Mockito.when(probe.executeQuery()).thenReturn(probeResult);
+    Mockito.when(probeResult.next()).thenReturn(false);
+    Mockito.when(columns.executeQuery()).thenReturn(columnResult);
+    Mockito.when(columnResult.next()).thenReturn(true, true, false);
+    Mockito.when(columnResult.getString("name")).thenReturn("name", "type");
+
+    SQLException exception =
+        Assertions.assertThrows(
+            SQLException.class, () -> 
ops.callGetProjectionProperty(connection, "db", "table"));
+
+    Assertions.assertTrue(exception.getMessage().contains("query"));
+    Mockito.verify(connection, 
Mockito.times(2)).prepareStatement(Mockito.anyString());
+  }
+
+  @Test
+  void 
testGetProjectionPropertyHandlesUnavailableSystemTableAndPropagatesOtherErrors()
+      throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    Connection missingConnection = Mockito.mock(Connection.class);
+    PreparedStatement missingProbe = Mockito.mock(PreparedStatement.class);
+    
Mockito.when(missingConnection.prepareStatement(Mockito.anyString())).thenReturn(missingProbe);
+    Mockito.when(missingProbe.executeQuery())
+        .thenThrow(new SQLException("Unknown table system.projections", "", 
60));
+    Assertions.assertNull(ops.callGetProjectionProperty(missingConnection, 
"db", "table"));
+
+    Connection deniedConnection = Mockito.mock(Connection.class);
+    PreparedStatement deniedProbe = Mockito.mock(PreparedStatement.class);
+    
Mockito.when(deniedConnection.prepareStatement(Mockito.anyString())).thenReturn(deniedProbe);
+    SQLException permissionError = new SQLException("Not enough privileges", 
"", 497);
+    Mockito.when(deniedProbe.executeQuery()).thenThrow(permissionError);
+    Assertions.assertSame(
+        permissionError,
+        Assertions.assertThrows(
+            SQLException.class,
+            () -> ops.callGetProjectionProperty(deniedConnection, "db", 
"table")));
+  }
+
   @Test
   void testGetSystemTableMetadataParsesSettingsFromEngineFull() throws 
Exception {
     ExposedClickHouseTableOperations ops = newOps();
diff --git a/docs/jdbc-clickhouse-catalog.md b/docs/jdbc-clickhouse-catalog.md
index 0cf90512a0..abbf4d6b8b 100644
--- a/docs/jdbc-clickhouse-catalog.md
+++ b/docs/jdbc-clickhouse-catalog.md
@@ -177,6 +177,21 @@ See [Manage Catalogs and 
Schemas](./manage-catalogs-and-schemas.md#schema-operat
 | Column defaults     | Supported.                                             
                                                                                
                                                                                
                                                                                
                                                                                
                                                                                
   |
 | Unsupported         | Engine and connector-owned property changes after 
creation; mixing table setting changes with schema changes; auto-increment 
columns.                                                                        
                                                                                
                                                                                
                                                                                
            |
 
+Projection round-trip requires `system.projections` (ClickHouse 24.9 or later) 
and supports
+safely reconstructable `Normal` and `Aggregate` definitions on these 
non-replicated MergeTree-family
+engines: `MergeTree`, `ReplacingMergeTree`, `SummingMergeTree`, 
`AggregatingMergeTree`,
+`CollapsingMergeTree`, `VersionedCollapsingMergeTree`, and 
`GraphiteMergeTree`. Projection-level
+`WHERE` filters, lightweight `_part_offset` projections, and compact 
`PROJECTION ... INDEX` syntax
+are outside this scope. If projection validation rejects any definition or 
setting, `loadTable` logs
+a warning and omits the entire `clickhouse.projections` property while loading 
the other table
+metadata. Recreating a table from that metadata omits all source projections, 
including otherwise
+supported ones.
+
+ClickHouse 24.9 does not expose the system table's optional `settings` column; 
when a newer server
+exposes it, safely reconstructable projection settings are preserved. 
ClickHouse 24.8 projection
+round-trip is deferred, while ordinary catalog metadata loading remains 
supported. Projection data
+is not copied or materialized, and Gravitino does not manage projection `ALTER 
TABLE` operations.
+
 ### Table Column Types
 
 | Gravitino Type      | ClickHouse Type                        |
@@ -213,6 +228,9 @@ Use `String` for unlimited text or `FixedChar(n)` for 
fixed-length values.
   contain multiple setting operations of the same form, but cannot mix set and 
remove operations
   or combine settings with schema, comment, or index changes.
 - The `engine` value is immutable after creation.
+- `clickhouse.projections` is an immutable structured property for preserving 
projection names,
+  types, queries, and safely reconstructable settings during CREATE TABLE. The 
`settings` object is
+  empty on ClickHouse 24.9, whose `system.projections` table has no `settings` 
column.
 :::
 
 :::warning
@@ -242,6 +260,21 @@ If you need Gravitino to manage an existing cluster 
database or table, recreate
 | `cluster-sharding-key`    | Sharding key for `Distributed` engine 
(expression allowed; referenced columns must be non-null integral)              
                                        | (none)        | No\*\*   | No       | 
No        |
 | `settings.<name>`         | ClickHouse engine setting forwarded as `SETTINGS 
<name>=<scalar-literal>`; supports settings-only set or remove requests after 
creation                       | (none)        | No       | No       | No       
 |
 | `partition-key`           | ClickHouse's canonical native partition 
expression (from `system.tables.partition_key`). Read-only; always present on 
load, empty string means unpartitioned. | `""`          | No       | Yes      | 
Yes       |
+| `clickhouse.projections`  | Structured projection definitions read from 
`system.projections`; used to recreate definitions during CREATE TABLE. 
Read-only after creation.                 | (none)        | No       | No       
| Yes       |
+
+The `clickhouse.projections` value is a JSON array. Projection setting values 
are strings containing
+supported ClickHouse scalar literals.
+
+```json
+[
+  {
+    "name": "by_event_date",
+    "type": "Normal",
+    "query": "SELECT event_date, id ORDER BY event_date, id",
+    "settings": {}
+  }
+]
+```
 
 \* Required when `on-cluster=true` or `engine=Distributed`.  
 \*\* Required when `engine=Distributed`.

Reply via email to