/*
 *  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.operations;

import static org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.DATA_SKIPPING_BLOOM_FILTER;
import static org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.DATA_SKIPPING_MINMAX_VALUE;
import static org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.CLICKHOUSE_ENGINE_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;
import static org.apache.gravitino.rel.Column.DEFAULT_VALUE_NOT_SET;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.sql.Connection;
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.StringIdentifier;
import org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.ClusterConstants;
import org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.DistributedTableConstants;
import org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.TableConstants;
import org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata;
import org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE;
import org.apache.gravitino.catalog.jdbc.JdbcColumn;
import org.apache.gravitino.catalog.jdbc.JdbcTable;
import org.apache.gravitino.catalog.jdbc.operation.JdbcTableOperations;
import org.apache.gravitino.catalog.jdbc.utils.JdbcConnectorUtils;
import org.apache.gravitino.exceptions.NoSuchTableException;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
import org.apache.gravitino.rel.expressions.Expression;
import org.apache.gravitino.rel.expressions.FunctionExpression;
import org.apache.gravitino.rel.expressions.NamedReference;
import org.apache.gravitino.rel.expressions.UnparsedExpression;
import org.apache.gravitino.rel.expressions.distributions.Distribution;
import org.apache.gravitino.rel.expressions.distributions.Distributions;
import org.apache.gravitino.rel.expressions.sorts.NullOrdering;
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;

public class ClickHouseTableOperations extends JdbcTableOperations {

  private static final String CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG =
      "Clickhouse does not support nested column names.";
  private static final Pattern ORDER_BY_PATTERN =
      Pattern.compile(
          "(?is)\\bORDER\\s+BY\\s*(.+?)(?=\\bPARTITION\\s+BY\\b|\\bPRIMARY\\s+KEY\\b|\\bSAMPLE\\s+BY\\b|\\bTTL\\b|\\bSETTINGS\\b|\\bCOMMENT\\b|$)");
  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 DISTRIBUTED_ENGINE_PATTERN =
      Pattern.compile(
          "(?i)^Distributed\\(([^,]+),\\s*([^,]+),\\s*([^,]+),\\s*(.+)\\)$", Pattern.DOTALL);

  private static final String QUERY_INDEXES_SQL =
      """
      SELECT NULL AS TABLE_CAT,
             system.tables.database AS TABLE_SCHEM,
             system.tables.name AS TABLE_NAME,
             trim(c.1) AS COLUMN_NAME,
             c.2 AS KEY_SEQ,
             'PRIMARY' AS PK_NAME
      FROM system.tables
      ARRAY JOIN arrayZip(splitByChar(',', primary_key), arrayEnumerate(splitByChar(',', primary_key))) as c
      WHERE system.tables.primary_key <> ''
        AND system.tables.database = '%s'
        AND system.tables.name = '%s'
      ORDER BY COLUMN_NAME
      """;

  @Override
  protected List<Index> getIndexes(Connection connection, String databaseName, String tableName) {
    // cause clickhouse not impl getPrimaryKeys yet, ref:
    // https://github.com/ClickHouse/clickhouse-java/issues/1625
    String sql = QUERY_INDEXES_SQL.formatted(databaseName, tableName);
    try (PreparedStatement preparedStatement = connection.prepareStatement(sql);
        ResultSet resultSet = preparedStatement.executeQuery()) {

      List<Index> indexes = new ArrayList<>();
      while (resultSet.next()) {
        String indexName = resultSet.getString("PK_NAME");
        String columnName = resultSet.getString("COLUMN_NAME");
        indexes.add(
            Indexes.of(Index.IndexType.PRIMARY_KEY, indexName, new String[][] {{columnName}}));
      }
      indexes.addAll(getSecondaryIndexes(connection, databaseName, tableName));
      return indexes;
    } catch (SQLException e) {
      throw exceptionMapper.toGravitinoException(e);
    }
  }

  @Override
  public boolean supportsTableSortOrder() {
    return true;
  }

  @Override
  protected String generateCreateTableSql(
      String tableName,
      JdbcColumn[] columns,
      String comment,
      Map<String, String> properties,
      Transform[] partitioning,
      Distribution distribution,
      Index[] indexes) {
    throw new UnsupportedOperationException(
        "generateCreateTableSql with out sortOrders in clickhouse is not supported");
  }

  @Override
  protected String generateCreateTableSql(
      String tableName,
      JdbcColumn[] columns,
      String comment,
      Map<String, String> properties,
      Transform[] partitioning,
      Distribution distribution,
      Index[] indexes,
      SortOrder[] sortOrders) {

    Preconditions.checkArgument(
        Distributions.NONE.equals(distribution), "ClickHouse does not support distribution");

    StringBuilder sqlBuilder = new StringBuilder();

    Map<String, String> notNullProperties =
        MapUtils.isNotEmpty(properties) ? properties : Collections.emptyMap();

    validateNoAutoIncrementColumns(columns);

    // Add Create table clause; capture whether ON CLUSTER is in use
    boolean onCluster = appendCreateTableClause(notNullProperties, sqlBuilder, tableName);

    // We still allow empty columns when the engine is distributed.
    if (columns.length > 0) {
      buildColumnsDefinition(columns, sqlBuilder);

      // Index definition
      appendIndexesSql(indexes, sqlBuilder);

      sqlBuilder.append("\n)");
    }

    // Extract engine from properties
    ClickHouseTablePropertiesMetadata.ENGINE engine =
        appendTableEngine(notNullProperties, sqlBuilder, columns);

    appendOrderBy(sortOrders, sqlBuilder, engine);

    appendPartitionClause(partitioning, sqlBuilder, engine);

    // Add table comment; embed cluster name so it can be recovered at DROP/ALTER time.
    // ClickHouse does not persist ON CLUSTER in SHOW CREATE TABLE (see ClickHouseClusterUtils).
    String storedComment =
        onCluster
            ? ClickHouseClusterUtils.embedClusterInComment(
                comment, notNullProperties.get(ClusterConstants.CLUSTER_NAME))
            : comment;
    if (StringUtils.isNotEmpty(storedComment)) {
      sqlBuilder.append(" COMMENT '%s'".formatted(escapeSingleQuotes(storedComment)));
    }

    // Add setting clause if specified, clickhouse only supports predefine settings
    appendTableProperties(notNullProperties, sqlBuilder);

    // Return the generated SQL statement
    String result = sqlBuilder.toString();

    LOG.info("Generated create table:{} sql: {}", tableName, result);
    return result;
  }

  /**
   * Append CREATE TABLE clause. If cluster name && on-cluster is specified in properties, append ON
   * CLUSTER clause.
   *
   * @param properties Table properties
   * @param sqlBuilder SQL builder
   * @return true if ON CLUSTER clause is appended, false otherwise
   */
  private boolean appendCreateTableClause(
      Map<String, String> properties, StringBuilder sqlBuilder, String tableName) {
    String clusterName = properties.get(ClusterConstants.CLUSTER_NAME);
    String onClusterValue = properties.get(ClusterConstants.ON_CLUSTER);

    boolean onCluster =
        StringUtils.isNotBlank(clusterName)
            && StringUtils.isNotBlank(onClusterValue)
            && Boolean.TRUE.equals(Boolean.parseBoolean(onClusterValue));

    if (onCluster) {
      sqlBuilder.append(
          "CREATE TABLE %s ON CLUSTER %s \n"
              .formatted(quoteIdentifier(tableName), quoteIdentifier(clusterName)));
    } else {
      sqlBuilder.append("CREATE TABLE %s \n".formatted(quoteIdentifier(tableName)));
    }

    return onCluster;
  }

  private static void appendTableProperties(
      Map<String, String> properties, StringBuilder sqlBuilder) {
    if (MapUtils.isEmpty(properties)) {
      return;
    }

    Map<String, String> settingMap =
        properties.entrySet().stream()
            .filter(entry -> entry.getKey().startsWith(TableConstants.SETTINGS_PREFIX))
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    if (MapUtils.isEmpty(settingMap)) {
      return;
    }

    String settings =
        settingMap.entrySet().stream()
            .map(
                entry ->
                    entry.getKey().substring(TableConstants.SETTINGS_PREFIX.length())
                        + " = "
                        + entry.getValue())
            .collect(Collectors.joining(",\n ", " \n SETTINGS ", ""));
    sqlBuilder.append(settings);
  }

  private void appendOrderBy(
      SortOrder[] sortOrders,
      StringBuilder sqlBuilder,
      ClickHouseTablePropertiesMetadata.ENGINE engine) {
    // ClickHouse requires ORDER BY clause for some engines, and currently only mergeTree family
    // requires ORDER BY clause.
    boolean requireOrderBy = engine.isRequireOrderBy();
    if (!requireOrderBy) {
      if (ArrayUtils.isNotEmpty(sortOrders)) {
        throw new UnsupportedOperationException(
            "ORDER BY clause is not supported for engine: " + engine.getValue());
      }

      // No need to add order by clause
      return;
    }

    if (ArrayUtils.isEmpty(sortOrders)) {
      throw new IllegalArgumentException(
          "ORDER BY clause is required for engine: " + engine.getValue());
    }

    List<String> orderBySql = new ArrayList<>();
    for (SortOrder sortOrder : sortOrders) {
      NullOrdering nullOrdering = sortOrder.nullOrdering();
      SortDirection sortDirection = sortOrder.direction();
      if (nullOrdering != null) {
        LOG.warn(
            "ClickHouse currently does not support nullOrdering: {}, and will ignore it",
            nullOrdering);
      }

      String exprSql = toOrderBySql(sortOrder.expression());
      if (sortDirection == SortDirection.DESCENDING) {
        exprSql = exprSql + " DESC";
      }
      orderBySql.add(exprSql);
    }

    String renderedOrderBy =
        orderBySql.size() == 1 ? orderBySql.get(0) : "(" + String.join(", ", orderBySql) + ")";
    sqlBuilder.append("\n ORDER BY ").append(renderedOrderBy).append("\n");
  }

  private ClickHouseTablePropertiesMetadata.ENGINE appendTableEngine(
      Map<String, String> properties, StringBuilder sqlBuilder, JdbcColumn[] columns) {
    ClickHouseTablePropertiesMetadata.ENGINE engine = ENGINE_PROPERTY_ENTRY.getDefaultValue();
    if (MapUtils.isNotEmpty(properties)) {
      String userSetEngine = properties.get(GRAVITINO_ENGINE_KEY);
      if (StringUtils.isNotEmpty(userSetEngine)) {
        engine = ClickHouseTablePropertiesMetadata.ENGINE.fromString(userSetEngine);
      }
    }

    if (engine == ENGINE.DISTRIBUTED) {
      handleDistributeTable(properties, sqlBuilder, columns);
      return engine;
    }

    sqlBuilder.append("\n ENGINE = %s".formatted(engine.getValue()));
    return engine;
  }

  private void handleDistributeTable(
      Map<String, String> properties, StringBuilder sqlBuilder, JdbcColumn[] columns) {

    // Check properties
    String clusterName = properties.get(ClusterConstants.CLUSTER_NAME);
    String remoteDatabase = properties.get(DistributedTableConstants.REMOTE_DATABASE);
    String remoteTable = properties.get(DistributedTableConstants.REMOTE_TABLE);
    String shardingKey = properties.get(DistributedTableConstants.SHARDING_KEY);

    Preconditions.checkArgument(
        StringUtils.isNotBlank(clusterName),
        "Cluster name must be specified when engine is Distributed");
    Preconditions.checkArgument(
        StringUtils.isNotBlank(remoteDatabase),
        "Remote database must be specified for Distributed");
    Preconditions.checkArgument(
        StringUtils.isNotBlank(remoteTable), "Remote table must be specified for Distributed");

    // User must ensure the sharding key is a trusted value.
    // TODO(yuqi) WE need to check the columns in shard keys should be integer and not nullable,
    //  as clickhouse distributed table requires the sharding key to be integer and not nullable.
    //  We can add this validation after we support user defined sharding key in index, as we can
    //  reuse the index field definition for validation.
    Preconditions.checkArgument(
        StringUtils.isNotBlank(shardingKey), "Sharding key must be specified for Distributed");

    // Users have defined the columns explicitly for the distributed table, we will check the
    // columns should contain the sharding key, as clickhouse requires the sharding key must be
    // defined in the columns of the distributed table.
    if (ArrayUtils.isNotEmpty(columns)) {
      List<String> shardingColumns = ClickHouseTableSqlUtils.extractShardingKeyColumns(shardingKey);
      if (CollectionUtils.isNotEmpty(shardingColumns)) {
        for (String columnName : shardingColumns) {
          JdbcColumn shardingColumn = findColumn(columns, columnName);
          Preconditions.checkArgument(
              shardingColumn != null,
              "Sharding key column %s must be defined in the table",
              columnName);
        }
      }
    }

    if (ArrayUtils.isEmpty(columns)) {
      sqlBuilder.append(" AS `%s`.`%s` ".formatted(remoteDatabase, remoteTable));
    }

    String sanitizedShardingKey = ClickHouseTableSqlUtils.formatShardingKey(shardingKey);
    sqlBuilder.append(
        " ENGINE = %s(`%s`,`%s`,`%s`,%s)"
            .formatted(
                ENGINE.DISTRIBUTED.getValue(),
                clusterName,
                remoteDatabase,
                remoteTable,
                sanitizedShardingKey));
  }

  private void appendPartitionClause(
      Transform[] partitioning,
      StringBuilder sqlBuilder,
      ClickHouseTablePropertiesMetadata.ENGINE engine) {
    if (ArrayUtils.isEmpty(partitioning)) {
      return;
    }

    if (!engine.acceptPartition()) {
      throw new UnsupportedOperationException(
          "Partitioning is only supported for MergeTree family engines");
    }

    List<String> partitionExprs =
        Arrays.stream(partitioning)
            .map(ClickHouseTableSqlUtils::toPartitionExpression)
            .collect(Collectors.toList());
    String partitionExpr =
        partitionExprs.size() == 1
            ? partitionExprs.get(0)
            : "tuple(" + String.join(", ", partitionExprs) + ")";
    sqlBuilder.append("\n PARTITION BY ").append(partitionExpr);
  }

  private JdbcColumn findColumn(JdbcColumn[] columns, String columnName) {
    if (ArrayUtils.isEmpty(columns)) {
      return null;
    }

    return Arrays.stream(columns)
        .filter(column -> StringUtils.equals(column.name(), columnName))
        .findFirst()
        .orElse(null);
  }

  private void validateNoAutoIncrementColumns(JdbcColumn[] columns) {
    if (ArrayUtils.isEmpty(columns)) {
      return;
    }

    for (JdbcColumn column : columns) {
      if (column.autoIncrement()) {
        throw new UnsupportedOperationException(
            "ClickHouse does not support auto increment column: '%s' in CREATE TABLE"
                .formatted(column.name()));
      }
    }
  }

  private void buildColumnsDefinition(JdbcColumn[] columns, StringBuilder sqlBuilder) {
    if (ArrayUtils.isEmpty(columns)) {
      return;
    }

    sqlBuilder.append(" (");
    for (int i = 0; i < columns.length; i++) {
      JdbcColumn column = columns[i];
      sqlBuilder.append("  %s".formatted(quoteIdentifier(column.name())));

      appendColumnDefinition(column, sqlBuilder);
      // Add a comma for the next column, unless it's the last one
      if (i < columns.length - 1) {
        sqlBuilder.append(",\n");
      }
    }
  }

  /**
   * ClickHouse supports primary key and data skipping indexes.
   *
   * <p>This method will not check the validity of the indexes. For ClickHouse, the primary key must
   * be a subset of the order by columns. We will leave the underlying clickhouse to validate it.
   */
  private void appendIndexesSql(Index[] indexes, StringBuilder sqlBuilder) {
    if (ArrayUtils.isEmpty(indexes)) {
      return;
    }

    for (Index index : indexes) {
      String fieldStr = getIndexFieldStr(index.fieldNames());
      sqlBuilder.append(",\n");
      switch (index.type()) {
        case PRIMARY_KEY:
          if (null != index.name()
              && !StringUtils.equalsIgnoreCase(index.name(), Indexes.DEFAULT_PRIMARY_KEY_NAME)) {
            LOG.warn(
                "Primary key name must be PRIMARY in ClickHouse, the name {} will be ignored.",
                index.name());
          }
          // fieldStr already quoted in getIndexFieldStr
          sqlBuilder.append(" PRIMARY KEY (").append(fieldStr).append(")");
          break;
        case DATA_SKIPPING_MINMAX:
          Preconditions.checkArgument(
              StringUtils.isNotBlank(index.name()), "Data skipping index name must not be blank");
          // The GRANULARITY value is always 1 here currently as we can't set it by Index: there is
          // no field for it.
          // TODO(yuqi) add a properties field to Index to support user defined GRANULARITY value.
          sqlBuilder.append(
              " INDEX %s %s TYPE minmax GRANULARITY 1"
                  .formatted(quoteIdentifier(index.name()), fieldStr));
          break;
        case DATA_SKIPPING_BLOOM_FILTER:
          // The GRANULARITY value is always 3 here currently.
          // TODO(yuqi) add a properties field to Index to support user defined GRANULARITY value.
          Preconditions.checkArgument(
              StringUtils.isNotBlank(index.name()), "Data skipping index name must not be blank");
          sqlBuilder.append(
              " INDEX %s %s TYPE bloom_filter GRANULARITY 3"
                  .formatted(quoteIdentifier(index.name()), fieldStr));
          break;
        default:
          throw new IllegalArgumentException(
              "Gravitino Clickhouse doesn't support index : " + index.type());
      }
    }
  }

  @Override
  protected boolean getAutoIncrementInfo(ResultSet resultSet) throws SQLException {
    return "YES".equalsIgnoreCase(resultSet.getString("IS_AUTOINCREMENT"));
  }

  @Override
  public void alterTable(String databaseName, String tableName, TableChange... changes)
      throws NoSuchTableException {
    LOG.info("Attempting to alter table {} from database {}", tableName, databaseName);
    try (Connection connection = getConnection(databaseName)) {
      String sql = generateAlterTableSql(databaseName, tableName, changes);
      if (StringUtils.isEmpty(sql)) {
        LOG.info("No changes to alter table {} from database {}", tableName, databaseName);
        return;
      }
      JdbcConnectorUtils.executeUpdate(connection, sql);
      LOG.info("Alter table {} from database {}", tableName, databaseName);
    } catch (final SQLException se) {
      throw this.exceptionMapper.toGravitinoException(se);
    }
  }

  @Override
  protected Map<String, String> getTableProperties(Connection connection, String tableName)
      throws SQLException {
    try (PreparedStatement statement =
        connection.prepareStatement("select * from system.tables where name = ? ")) {
      statement.setString(1, tableName);
      try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
          String name = resultSet.getString("name");
          if (Objects.equals(name, tableName)) {
            return Collections.unmodifiableMap(
                new HashMap<String, String>() {
                  {
                    // Extract cluster name embedded in the COMMENT at create time.
                    // SHOW CREATE TABLE does not include ON CLUSTER (see ClickHouseClusterUtils).
                    String storedComment = resultSet.getString(COMMENT);
                    String clusterName =
                        ClickHouseClusterUtils.extractClusterFromComment(storedComment);
                    put(COMMENT, ClickHouseClusterUtils.stripClusterMetadata(storedComment));
                    String engine = resultSet.getString(CLICKHOUSE_ENGINE_KEY);
                    put(GRAVITINO_ENGINE_KEY, engine);
                    if (StringUtils.isNotBlank(clusterName)) {
                      put(ClusterConstants.ON_CLUSTER, String.valueOf(true));
                      put(ClusterConstants.CLUSTER_NAME, clusterName);
                    } else {
                      put(ClusterConstants.ON_CLUSTER, String.valueOf(false));
                    }

                    if (StringUtils.equalsIgnoreCase(engine, ENGINE.DISTRIBUTED.getValue())) {
                      String engineFull = resultSet.getString("engine_full");
                      Matcher distributedEngineMatcher =
                          DISTRIBUTED_ENGINE_PATTERN.matcher(StringUtils.trimToEmpty(engineFull));
                      if (distributedEngineMatcher.matches()) {
                        String distributedClusterName = unquote(distributedEngineMatcher.group(1));
                        put(ClusterConstants.CLUSTER_NAME, distributedClusterName);
                        put(
                            DistributedTableConstants.REMOTE_DATABASE,
                            unquote(distributedEngineMatcher.group(2)));
                        put(
                            DistributedTableConstants.REMOTE_TABLE,
                            unquote(distributedEngineMatcher.group(3)));
                        put(
                            DistributedTableConstants.SHARDING_KEY,
                            StringUtils.trim(distributedEngineMatcher.group(4)));
                      }
                    }
                  }
                });
          }
        }

        throw new NoSuchTableException(
            "Table %s does not exist in %s.", tableName, connection.getCatalog());
      }
    }
  }

  @Override
  public JdbcTable load(String databaseName, String tableName) throws NoSuchTableException {
    try (Connection connection = getConnection(databaseName)) {
      ResultSet tables = getTable(connection, databaseName, tableName);
      JdbcTable.Builder jdbcTableBuilder = getTableBuilder(tables, databaseName, tableName);

      List<JdbcColumn> jdbcColumns = new ArrayList<>();
      ResultSet columns = getColumns(connection, databaseName, tableName);
      while (columns.next()) {
        JdbcColumn.Builder columnBuilder = getColumnBuilder(columns, databaseName, tableName);
        if (columnBuilder != null) {
          boolean autoIncrement = getAutoIncrementInfo(columns);
          columnBuilder.withAutoIncrement(autoIncrement);
          jdbcColumns.add(columnBuilder.build());
        }
      }
      jdbcTableBuilder.withColumns(jdbcColumns.toArray(new JdbcColumn[0]));

      List<Index> indexes = getIndexes(connection, databaseName, tableName);
      jdbcTableBuilder.withIndexes(indexes.toArray(new Index[0]));

      ShowCreateTableMetadata metadata = parseShowCreateTable(connection, tableName);
      Transform[] partitioning = metadata.partitioning;
      if (ArrayUtils.isEmpty(partitioning)) {
        partitioning = getTablePartitioning(connection, databaseName, tableName);
      }
      jdbcTableBuilder.withPartitioning(partitioning);
      jdbcTableBuilder.withSortOrders(metadata.sortOrders);

      Distribution distribution = getDistributionInfo(connection, databaseName, tableName);
      jdbcTableBuilder.withDistribution(distribution);

      Map<String, String> tableProperties = getTableProperties(connection, tableName);
      jdbcTableBuilder.withProperties(tableProperties);

      correctJdbcTableFields(connection, databaseName, tableName, jdbcTableBuilder);

      return jdbcTableBuilder.withTableOperation(this).build();
    } catch (SQLException e) {
      throw exceptionMapper.toGravitinoException(e);
    }
  }

  @Override
  protected Transform[] getTablePartitioning(
      Connection connection, String databaseName, String tableName) throws SQLException {
    try (PreparedStatement statement =
        connection.prepareStatement(
            "SELECT partition_key FROM system.tables WHERE database = ? AND name = ?")) {
      statement.setString(1, databaseName);
      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 Transforms.EMPTY_TRANSFORM;
  }

  protected ResultSet getTables(Connection connection) throws SQLException {
    final DatabaseMetaData metaData = connection.getMetaData();
    String catalogName = connection.getCatalog();
    String schemaName = connection.getSchema();
    // CK tables include : DICTIONARY", "LOG TABLE", "MEMORY TABLE",
    // "REMOTE TABLE", "TABLE", "VIEW", "SYSTEM TABLE", "TEMPORARY TABLE
    return metaData.getTables(catalogName, schemaName, null, new String[] {"TABLE"});
  }

  @Override
  protected void dropTable(String databaseName, String tableName) {
    LOG.info("Attempting to delete table {} from database {}", tableName, databaseName);
    try (Connection connection = getConnection(databaseName)) {
      Map<String, String> props = getTableProperties(connection, tableName);
      JdbcConnectorUtils.executeUpdate(connection, generateDropTableSql(tableName, props));
      LOG.info("Deleted table {} from database {}", tableName, databaseName);
    } catch (final SQLException se) {
      throw this.exceptionMapper.toGravitinoException(se);
    }
  }

  /**
   * Generates the SQL statement to drop a ClickHouse table. When the table was created with {@code
   * ON CLUSTER}, the DROP statement includes {@code ON CLUSTER `clusterName` SYNC} so the operation
   * is propagated to every cluster node synchronously.
   *
   * @param tableName The name of the table to drop.
   * @param properties The table properties as returned by {@link #getTableProperties}; used to
   *     determine whether the table is on a cluster.
   * @return The DROP TABLE SQL statement.
   */
  @VisibleForTesting
  String generateDropTableSql(String tableName, Map<String, String> properties) {
    String clusterName = properties == null ? null : properties.get(ClusterConstants.CLUSTER_NAME);
    boolean onCluster =
        properties != null
            && Boolean.parseBoolean(properties.getOrDefault(ClusterConstants.ON_CLUSTER, "false"));

    if (onCluster && StringUtils.isNotBlank(clusterName)) {
      return String.format(
          "DROP TABLE %s ON CLUSTER %s SYNC",
          quoteIdentifier(tableName), quoteIdentifier(clusterName));
    }
    return String.format("DROP TABLE %s", quoteIdentifier(tableName));
  }

  @Override
  protected String generatePurgeTableSql(String tableName) {
    throw new UnsupportedOperationException(
        "ClickHouse does not support purge table in Gravitino, please use drop table");
  }

  @Override
  protected String generateAlterTableSql(
      String databaseName, String tableName, TableChange... changes) {
    // Not all operations require the original table information, so lazy loading is used here
    JdbcTable lazyLoadTable = null;
    TableChange.UpdateComment updateComment = null;
    List<TableChange.SetProperty> setProperties = new ArrayList<>();
    List<String> alterSql = new ArrayList<>();

    for (TableChange change : changes) {
      if (change instanceof TableChange.UpdateComment) {
        updateComment = (TableChange.UpdateComment) change;

      } else if (change instanceof TableChange.SetProperty setProperty) {
        // The set attribute needs to be added at the end.
        setProperties.add(setProperty);

      } else if (change instanceof TableChange.RemoveProperty) {
        // Clickhouse does not support deleting table attributes, it can be replaced by Set Property
        throw new UnsupportedOperationException(
            "Remove property for ClickHouse is not supported yet");

      } else if (change instanceof TableChange.AddColumn addColumn) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(addColumnFieldDefinition(addColumn));

      } else if (change instanceof TableChange.RenameColumn renameColumn) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(renameColumnFieldDefinition(renameColumn));

      } else if (change instanceof TableChange.UpdateColumnDefaultValue updateColumnDefaultValue) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(
            updateColumnDefaultValueFieldDefinition(updateColumnDefaultValue, lazyLoadTable));

      } else if (change instanceof TableChange.UpdateColumnType updateColumnType) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(updateColumnTypeFieldDefinition(updateColumnType, lazyLoadTable));

      } else if (change instanceof TableChange.UpdateColumnComment updateColumnComment) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(updateColumnCommentFieldDefinition(updateColumnComment, lazyLoadTable));

      } else if (change instanceof TableChange.UpdateColumnPosition updateColumnPosition) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(updateColumnPositionFieldDefinition(updateColumnPosition, lazyLoadTable));

      } else if (change instanceof TableChange.DeleteColumn deleteColumn) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        String deleteColSql = deleteColumnFieldDefinition(deleteColumn, lazyLoadTable);

        if (StringUtils.isNotEmpty(deleteColSql)) {
          alterSql.add(deleteColSql);
        }

      } else if (change instanceof TableChange.UpdateColumnNullability) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(
            updateColumnNullabilityDefinition(
                (TableChange.UpdateColumnNullability) change, lazyLoadTable));

      } else if (change instanceof TableChange.AddIndex addIndex) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(addIndexDefinition(lazyLoadTable, addIndex));

      } else if (change instanceof TableChange.DeleteIndex) {
        lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
        alterSql.add(deleteIndexDefinition(lazyLoadTable, (TableChange.DeleteIndex) change));

      } else if (change instanceof TableChange.UpdateColumnAutoIncrement) {
        // Auto increment functionality was added in ClickHouse 25.1. Since this PR is based on
        // 23.x, we throw unsupported operation here.
        throw new UnsupportedOperationException(
            "ClickHouse auto increment is not supported in this version.");
      } else {
        throw new IllegalArgumentException(
            "Unsupported table change type: " + change.getClass().getName());
      }
    }

    // Last modified comment
    if (null != updateComment) {
      String newComment = updateComment.getNewComment();
      // Load the existing table once. We need it for two purposes:
      //   1. Preserve the Gravitino StringIdentifier embedded in the old comment, so Gravitino can
      //      still identify the table after the comment is changed.
      //   2. Re-embed the cluster name so it is not lost. ClickHouse does not persist ON CLUSTER
      //      in SHOW CREATE TABLE, so the cluster name lives only in the stored comment.
      lazyLoadTable = getOrCreateTable(databaseName, tableName, lazyLoadTable);
      if (null == StringIdentifier.fromComment(newComment)) {
        StringIdentifier identifier = StringIdentifier.fromComment(lazyLoadTable.comment());
        if (null != identifier) {
          newComment = StringIdentifier.addToComment(identifier, newComment);
        }
      }
      String clusterName = lazyLoadTable.properties().get(ClusterConstants.CLUSTER_NAME);
      if (StringUtils.isNotBlank(clusterName)) {
        newComment = ClickHouseClusterUtils.embedClusterInComment(newComment, clusterName);
      }
      alterSql.add(" MODIFY COMMENT '%s'".formatted(escapeSingleQuotes(newComment)));
    }

    if (!setProperties.isEmpty()) {
      alterSql.add(generateAlterTableProperties(setProperties));
    }

    // Remove all empty SQL statements
    List<String> nonEmptySQLs =
        alterSql.stream().filter(StringUtils::isNotEmpty).collect(Collectors.toList());
    if (CollectionUtils.isEmpty(nonEmptySQLs)) {
      return "";
    }

    // Return the generated SQL statement
    String result =
        "ALTER TABLE %s \n%s;"
            .formatted(quoteIdentifier(tableName), String.join(",\n", nonEmptySQLs));
    LOG.info("Generated alter table:{} sql: {}", databaseName + "." + tableName, result);
    return result;
  }

  private String addIndexDefinition(JdbcTable table, TableChange.AddIndex addIndex) {
    Preconditions.checkArgument(
        StringUtils.isNotBlank(addIndex.getName()), "Index name is required");
    Preconditions.checkArgument(
        ArrayUtils.isNotEmpty(addIndex.getFieldNames()), "Index field names are required");

    boolean indexExists =
        Arrays.stream(table.index()).anyMatch(index -> index.name().equals(addIndex.getName()));
    Preconditions.checkArgument(!indexExists, "Index '%s' already exists", addIndex.getName());

    String fieldStr = getIndexFieldStr(addIndex.getFieldNames());
    switch (addIndex.getType()) {
      case DATA_SKIPPING_MINMAX:
        return "ADD INDEX %s %s TYPE minmax GRANULARITY 1"
            .formatted(quoteIdentifier(addIndex.getName()), fieldStr);

      case DATA_SKIPPING_BLOOM_FILTER:
        return "ADD INDEX %s %s TYPE bloom_filter GRANULARITY 3"
            .formatted(quoteIdentifier(addIndex.getName()), fieldStr);

      case PRIMARY_KEY:
        throw new UnsupportedOperationException(
            "ClickHouse does not support adding primary key via ALTER TABLE");

      default:
        throw new IllegalArgumentException(
            "Gravitino ClickHouse doesn't support index : " + addIndex.getType());
    }
  }

  @VisibleForTesting
  private String deleteIndexDefinition(
      JdbcTable lazyLoadTable, TableChange.DeleteIndex deleteIndex) {
    boolean indexExists =
        Arrays.stream(lazyLoadTable.index())
            .anyMatch(index -> index.name().equals(deleteIndex.getName()));

    // Index does not exist
    if (!indexExists) {
      // If ifExists is true, return empty string to skip the operation
      if (deleteIndex.isIfExists()) {
        return "";
      } else {
        throw new IllegalArgumentException(
            "Index '%s' does not exist".formatted(deleteIndex.getName()));
      }
    }

    return "DROP INDEX %s ".formatted(quoteIdentifier(deleteIndex.getName()));
  }

  private String updateColumnNullabilityDefinition(
      TableChange.UpdateColumnNullability change, JdbcTable table) {
    validateUpdateColumnNullable(change, table);

    String col = change.fieldName()[0];
    JdbcColumn column = getJdbcColumnFromTable(table, col);
    JdbcColumn updateColumn =
        JdbcColumn.builder()
            .withName(col)
            .withDefaultValue(column.defaultValue())
            .withNullable(change.nullable())
            .withType(column.dataType())
            .withComment(column.comment())
            .withAutoIncrement(column.autoIncrement())
            .build();

    return "%s %s %s"
        .formatted(
            MODIFY_COLUMN,
            quoteIdentifier(col),
            appendColumnDefinition(updateColumn, new StringBuilder()));
  }

  private String generateAlterTableProperties(List<TableChange.SetProperty> setProperties) {
    if (CollectionUtils.isNotEmpty(setProperties)) {
      throw new UnsupportedOperationException(
          "Alter table properties in ClickHouse is not supported");
    }

    return "";
  }

  private String updateColumnCommentFieldDefinition(
      TableChange.UpdateColumnComment updateColumnComment, JdbcTable jdbcTable) {
    String newComment = updateColumnComment.getNewComment();
    if (updateColumnComment.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = updateColumnComment.fieldName()[0];
    JdbcColumn column = getJdbcColumnFromTable(jdbcTable, col);
    JdbcColumn updateColumn =
        JdbcColumn.builder()
            .withName(col)
            .withDefaultValue(column.defaultValue())
            .withNullable(column.nullable())
            .withType(column.dataType())
            .withComment(newComment)
            .withAutoIncrement(column.autoIncrement())
            .build();

    return "%s %s %s"
        .formatted(
            MODIFY_COLUMN,
            quoteIdentifier(col),
            appendColumnDefinition(updateColumn, new StringBuilder()));
  }

  private String addColumnFieldDefinition(TableChange.AddColumn addColumn) {
    String dataType = typeConverter.fromGravitino(addColumn.getDataType());
    if (addColumn.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = addColumn.fieldName()[0];
    StringBuilder columnDefinition = new StringBuilder();
    //  [IF NOT EXISTS] name [type] [default_expr] [codec] [AFTER name_after | FIRST]
    if (addColumn.isNullable()) {
      columnDefinition.append(
          "ADD COLUMN %s Nullable(%s) ".formatted(quoteIdentifier(col), dataType));
    } else {
      columnDefinition.append("ADD COLUMN %s %s ".formatted(quoteIdentifier(col), dataType));
    }

    if (addColumn.isAutoIncrement()) {
      throw new UnsupportedOperationException(
          "ClickHouse does not support adding auto increment column");
    }

    // Append default value if available
    if (!Column.DEFAULT_VALUE_NOT_SET.equals(addColumn.getDefaultValue())) {
      columnDefinition.append(
          "DEFAULT %s "
              .formatted(columnDefaultValueConverter.fromGravitino(addColumn.getDefaultValue())));
    }

    // Append comment if available after default value
    if (StringUtils.isNotEmpty(addColumn.getComment())) {
      String escapedComment = StringUtils.replace(addColumn.getComment(), "'", "''");
      columnDefinition.append(" COMMENT '%s' ".formatted(escapedComment));
    }

    // Append position if available
    if (addColumn.getPosition() instanceof TableChange.First) {
      columnDefinition.append(" FIRST ");
    } else if (addColumn.getPosition() instanceof TableChange.After afterPosition) {
      columnDefinition.append(" AFTER %s ".formatted(quoteIdentifier(afterPosition.getColumn())));
    }

    return columnDefinition.toString();
  }

  private String renameColumnFieldDefinition(TableChange.RenameColumn renameColumn) {
    if (renameColumn.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String oldColumnName = renameColumn.fieldName()[0];
    String newColumnName = renameColumn.getNewName();

    return "RENAME COLUMN %s TO %s"
        .formatted(quoteIdentifier(oldColumnName), quoteIdentifier(newColumnName));
  }

  private String updateColumnPositionFieldDefinition(
      TableChange.UpdateColumnPosition updateColumnPosition, JdbcTable jdbcTable) {
    if (updateColumnPosition.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = updateColumnPosition.fieldName()[0];
    JdbcColumn column = getJdbcColumnFromTable(jdbcTable, col);

    StringBuilder columnDefinition = new StringBuilder();
    columnDefinition.append(" %s %s ".formatted(MODIFY_COLUMN, quoteIdentifier(col)));
    appendColumnDefinition(column, columnDefinition);

    if (updateColumnPosition.getPosition() instanceof TableChange.First) {
      columnDefinition.append(" FIRST ");
    } else if (updateColumnPosition.getPosition() instanceof TableChange.After afterPosition) {
      columnDefinition.append(
          " %s %s ".formatted(AFTER, quoteIdentifier(afterPosition.getColumn())));
    } else {
      Arrays.stream(jdbcTable.columns())
          .reduce((column1, column2) -> column2)
          .map(Column::name)
          .ifPresent(s -> columnDefinition.append(" %s %s ".formatted(AFTER, quoteIdentifier(s))));
    }
    return columnDefinition.toString();
  }

  private String deleteColumnFieldDefinition(
      TableChange.DeleteColumn deleteColumn, JdbcTable jdbcTable) {
    if (deleteColumn.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = deleteColumn.fieldName()[0];
    boolean colExists = columnExists(jdbcTable, col);
    if (!colExists) {
      if (BooleanUtils.isTrue(deleteColumn.getIfExists())) {
        return "";
      } else {
        throw new IllegalArgumentException("Delete column '%s' does not exist.".formatted(col));
      }
    }

    return "DROP COLUMN %s".formatted(quoteIdentifier(col));
  }

  private String updateColumnDefaultValueFieldDefinition(
      TableChange.UpdateColumnDefaultValue updateColumnDefaultValue, JdbcTable jdbcTable) {
    if (updateColumnDefaultValue.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = updateColumnDefaultValue.fieldName()[0];
    JdbcColumn column = getJdbcColumnFromTable(jdbcTable, col);
    StringBuilder sqlBuilder = new StringBuilder(MODIFY_COLUMN + quoteIdentifier(col));
    JdbcColumn newColumn =
        JdbcColumn.builder()
            .withName(col)
            .withType(column.dataType())
            .withNullable(column.nullable())
            .withComment(column.comment())
            .withDefaultValue(updateColumnDefaultValue.getNewDefaultValue())
            .build();

    return appendColumnDefinition(newColumn, sqlBuilder).toString();
  }

  private String updateColumnTypeFieldDefinition(
      TableChange.UpdateColumnType updateColumnType, JdbcTable jdbcTable) {
    if (updateColumnType.fieldName().length > 1) {
      throw new UnsupportedOperationException(CLICKHOUSE_NOT_SUPPORT_NESTED_COLUMN_MSG);
    }

    String col = updateColumnType.fieldName()[0];
    JdbcColumn column = getJdbcColumnFromTable(jdbcTable, col);
    StringBuilder sqlBuilder =
        new StringBuilder("%s %s ".formatted(MODIFY_COLUMN, quoteIdentifier(col)));

    JdbcColumn newColumn =
        JdbcColumn.builder()
            .withName(col)
            .withType(updateColumnType.getNewDataType())
            .withComment(column.comment())
            .withDefaultValue(column.defaultValue())
            .withNullable(column.nullable())
            .withAutoIncrement(column.autoIncrement())
            .build();
    return appendColumnDefinition(newColumn, sqlBuilder).toString();
  }

  @VisibleForTesting
  Transform[] parsePartitioning(String partitionKey) {
    return ClickHouseTableSqlUtils.parsePartitioning(partitionKey);
  }

  private ShowCreateTableMetadata parseCreateStatement(String createSql) {
    ShowCreateTableMetadata metadata = new ShowCreateTableMetadata();
    if (StringUtils.isBlank(createSql)) {
      return metadata;
    }

    Matcher orderMatcher = ORDER_BY_PATTERN.matcher(createSql);
    if (orderMatcher.find()) {
      metadata.sortOrders = parseOrderByClause(orderMatcher.group(1));
    }

    Matcher partitionMatcher = PARTITION_BY_PATTERN.matcher(createSql);
    if (partitionMatcher.find()) {
      metadata.partitioning = parsePartitioning(partitionMatcher.group(1));
    }

    return metadata;
  }

  @VisibleForTesting
  SortOrder[] parseSortOrdersFromCreateSql(String createSql) {
    return parseCreateStatement(createSql).sortOrders;
  }

  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) {
      char first = trimmed.charAt(0);
      char last = trimmed.charAt(trimmed.length() - 1);
      if ((first == '\'' && last == '\'') || (first == '`' && last == '`')) {
        return trimmed.substring(1, trimmed.length() - 1);
      }
    }
    return trimmed;
  }

  private SortOrder[] parseOrderByClause(String orderClause) {
    if (StringUtils.isBlank(orderClause)) {
      return SortOrders.NONE;
    }
    String trimmed = orderClause.trim();
    if (trimmed.startsWith("(") && trimmed.endsWith(")")) {
      trimmed = trimmed.substring(1, trimmed.length() - 1);
    }

    List<String> expressions = splitExpressions(trimmed);
    List<SortOrder> sortOrders = new ArrayList<>();
    for (String expression : expressions) {
      Expression sortExpr = toSortExpression(expression);
      sortOrders.add(SortOrders.of(sortExpr, SortDirection.ASCENDING));
    }
    return sortOrders.toArray(new SortOrder[0]);
  }

  private List<String> splitExpressions(String expressionList) {
    List<String> expressions = new ArrayList<>();
    StringBuilder current = new StringBuilder();
    int depth = 0;
    for (int i = 0; i < expressionList.length(); i++) {
      char ch = expressionList.charAt(i);
      if (ch == '(') {
        depth++;
      } else if (ch == ')') {
        depth--;
      } else if (ch == ',' && depth == 0) {
        expressions.add(current.toString());
        current.setLength(0);
        continue;
      }
      current.append(ch);
    }
    if (current.length() > 0) {
      expressions.add(current.toString());
    }
    return expressions;
  }

  private Expression toSortExpression(String expression) {
    String trimmed = StringUtils.trim(expression);
    if (StringUtils.isBlank(trimmed)) {
      return UnparsedExpression.of(expression);
    }
    String unquoted = trimmed;
    if (StringUtils.startsWith(unquoted, "`") && StringUtils.endsWith(unquoted, "`")) {
      unquoted = unquoted.substring(1, unquoted.length() - 1);
    }
    if (unquoted.matches("[A-Za-z_][A-Za-z0-9_]*")) {
      return NamedReference.field(unquoted);
    }

    int firstParen = unquoted.indexOf('(');
    int lastParen = unquoted.lastIndexOf(')');
    if (firstParen > 0 && lastParen > firstParen) {
      String funcName = unquoted.substring(0, firstParen).trim();
      if (funcName.matches("[A-Za-z_][A-Za-z0-9_]*")) {
        String argsString = unquoted.substring(firstParen + 1, lastParen);
        List<String> args = splitExpressions(argsString);
        Expression[] parsedArgs =
            args.stream().map(this::toSortExpression).toArray(Expression[]::new);
        return FunctionExpression.of(funcName, parsedArgs);
      }
    }

    return UnparsedExpression.of(trimmed);
  }

  private String toOrderBySql(Expression expression) {
    if (expression instanceof NamedReference) {
      String[] parts = ((NamedReference) expression).fieldName();
      return "`" + String.join("`.`", parts) + "`";
    } else if (expression instanceof FunctionExpression) {
      FunctionExpression func = (FunctionExpression) expression;
      return func.functionName()
          + Arrays.stream(func.arguments())
              .map(this::toOrderBySql)
              .collect(Collectors.joining(", ", "(", ")"));
    } else if (expression instanceof UnparsedExpression) {
      return ((UnparsedExpression) expression).unparsedExpression();
    }
    return expression.toString();
  }

  private static final class ShowCreateTableMetadata {
    private Transform[] partitioning = Transforms.EMPTY_TRANSFORM;
    private SortOrder[] sortOrders = SortOrders.NONE;
  }

  @VisibleForTesting
  String[][] parseIndexFields(String expression) {
    return ClickHouseTableSqlUtils.parseIndexFields(expression);
  }

  private List<Index> getSecondaryIndexes(
      Connection connection, String databaseName, String tableName) throws SQLException {
    List<Index> secondaryIndexes = new ArrayList<>();
    try (PreparedStatement preparedStatement =
        connection.prepareStatement(
            "SELECT name, type, expr FROM system.data_skipping_indices "
                + "WHERE database = ? AND table = ? ORDER BY name")) {
      preparedStatement.setString(1, databaseName);
      preparedStatement.setString(2, tableName);
      try (ResultSet resultSet = preparedStatement.executeQuery()) {
        while (resultSet.next()) {
          String name = resultSet.getString("name");
          String type = resultSet.getString("type");
          String expression = resultSet.getString("expr");
          try {
            String[][] fields = parseIndexFields(expression);
            if (ArrayUtils.isEmpty(fields)) {
              continue;
            }
            secondaryIndexes.add(Indexes.of(getClickHouseIndexType(type), name, fields));
          } catch (IllegalArgumentException e) {
            LOG.warn(
                "Skip unsupported data skipping index {} for {}.{} with expression {}",
                name,
                databaseName,
                tableName,
                expression);
          }
        }
      }
    }

    return secondaryIndexes;
  }

  private Index.IndexType getClickHouseIndexType(String rawType) {
    if (StringUtils.isBlank(rawType)) {
      return Index.IndexType.DATA_SKIPPING_MINMAX;
    }

    switch (rawType) {
      case DATA_SKIPPING_MINMAX_VALUE:
        return Index.IndexType.DATA_SKIPPING_MINMAX;
      case DATA_SKIPPING_BLOOM_FILTER:
        return Index.IndexType.DATA_SKIPPING_BLOOM_FILTER;
      default:
        throw new IllegalArgumentException("Unsupported data skipping index type: " + rawType);
    }
  }

  private StringBuilder appendColumnDefinition(JdbcColumn column, StringBuilder sqlBuilder) {
    // Add Nullable data type
    String dataType = typeConverter.fromGravitino(column.dataType());
    if (column.nullable()) {
      sqlBuilder.append(" Nullable(%s) ".formatted(dataType));
    } else {
      sqlBuilder.append(" %s ".formatted(dataType));
    }

    // Add DEFAULT value if specified
    if (!DEFAULT_VALUE_NOT_SET.equals(column.defaultValue())) {
      sqlBuilder.append(
          " DEFAULT %s "
              .formatted(columnDefaultValueConverter.fromGravitino(column.defaultValue())));
    }

    // Add column comment if specified
    if (StringUtils.isNotEmpty(column.comment())) {
      String escapedComment = StringUtils.replace(column.comment(), "'", "''");
      sqlBuilder.append("COMMENT '%s' ".formatted(escapedComment));
    }

    return sqlBuilder;
  }
}
