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 93e487707c [#12787] fix(clickhouse): preserve SET index parameters on 
table load (#12826)
93e487707c is described below

commit 93e487707ccf86e16e6dff2dc78c8673ea4cc5fa
Author: StormSpirit <[email protected]>
AuthorDate: Mon Sep 21 15:07:55 2026 +0800

    [#12787] fix(clickhouse): preserve SET index parameters on table load 
(#12826)
    
    ### What changes were proposed in this pull request?
    
    This pull request restores the `set_max_values` property when loading
    ClickHouse `DATA_SKIPPING_SET` indexes from
    `system.data_skipping_indices.type_full`.
    
    The implementation adds a narrow SET parameter parser with overflow-safe
    validation, preserves the default `set(0)` behavior, supports the modern
    `type_full` path and legacy parameterized fallback, and keeps malformed
    or out-of-range metadata from being silently degraded. Existing
    ngram/token expression-skip behavior is preserved.
    
    The tests cover modern and legacy metadata, default values, malformed
    metadata, `Integer.MAX_VALUE`, `Integer.MAX_VALUE + 1`, the unsigned
    64-bit maximum, granularity coexistence, and a real
    create/load/alter/adoption/recreate lifecycle.
    
    ### Why are the changes needed?
    
    ClickHouse preserves a SET index parameter such as `set(100)` in its
    metadata, but the ClickHouse catalog previously loaded only the index
    type and granularity. The missing parameter caused a later table
    recreation to emit `set(0)`, changing the index configuration and
    potentially its data-skipping behavior.
    
    Fix: #12787
    
    ### Does this PR introduce _any_ user-facing change?
    
    Loaded SET indexes with `set_max_values` in the supported range
    `0..Integer.MAX_VALUE` now expose the canonical `set_max_values`
    property. The default value `0` remains omitted from
    `Index.properties()`. Malformed SET metadata now fails with an
    index-specific error identifying the metadata source. Out-of-range
    metadata additionally reports the supported range.
    
    No public API, OpenAPI field, or ClickHouse server-version support claim
    is changed.
    
    ### How was this patch tested?
    
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:spotlessCheck` —
    passed.
    - `./gradlew rat` — passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test -PskipITs` —
    94 tests passed, 0 failures, 0 errors, 0 skipped.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test --tests
    
'org.apache.gravitino.catalog.clickhouse.integration.test.CatalogClickHouseIT.testSetIndexParameterReadbackLifecycle'
    -PskipDockerTests=false --console=plain --no-daemon` on ClickHouse
    `24.8.14` — 1 test passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:test --tests
    
'org.apache.gravitino.catalog.clickhouse.integration.test.CatalogClickHouseClusterIT'
    -PskipDockerTests=false --console=plain --no-daemon` on ClickHouse
    `24.8.14` — 18 tests passed.
    - `./gradlew :catalogs-contrib:catalog-jdbc-clickhouse:build -x test` —
    passed.
    - Supplemental local Gravitino precheck — passed with no errors.
    
    The focused and cluster Docker tests passed before the final
    diagnostics-only review follow-up; the follow-up changed only SET error
    wording and mocked query-path coverage, without changing
    CREATE/ALTER/readback lifecycle behavior, so the long IT suites were not
    rerun.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
---
 .../operations/ClickHouseTableOperations.java      | 157 +++++++++++-
 .../integration/test/CatalogClickHouseIT.java      | 117 +++++++++
 .../TestClickHouseTableOperationsUnit.java         | 284 ++++++++++++++++++++-
 3 files changed, 538 insertions(+), 20 deletions(-)

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 21f824a9fe..e2236b4003 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
@@ -37,6 +37,7 @@ 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.math.BigInteger;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.PreparedStatement;
@@ -105,6 +106,12 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
   /** Default GRANULARITY for data skipping indexes, matching ClickHouse's own 
default. */
   private static final long DEFAULT_INDEX_GRANULARITY = 1;
 
+  private static final BigInteger MIN_SET_MAX_VALUES = BigInteger.ZERO;
+  private static final BigInteger MAX_SET_MAX_VALUES = 
BigInteger.valueOf(Integer.MAX_VALUE);
+  private static final String SET_MAX_VALUES_RANGE =
+      "[%s, %s]".formatted(MIN_SET_MAX_VALUES, MAX_SET_MAX_VALUES);
+  private static final Pattern SET_MAX_VALUES_PATTERN = 
Pattern.compile("[+-]?[0-9]+");
+
   private static final Set<ENGINE> GENERIC_ENGINE_PARAMETER_ENGINES =
       Collections.unmodifiableSet(
           EnumSet.of(
@@ -1895,7 +1902,6 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
           String expression = resultSet.getString("expr");
           long granularity = resultSet.getLong("granularity");
           Index.IndexType indexType;
-          String[][] fields;
           try {
             indexType = getClickHouseIndexType(type);
           } catch (IllegalArgumentException ignored) {
@@ -1907,6 +1913,9 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
                 type);
             continue;
           }
+
+          Map<String, String> parameterProperties = Collections.emptyMap();
+          String[][] fields;
           try {
             fields = parseIndexFields(expression);
           } catch (IllegalArgumentException ignored) {
@@ -1923,6 +1932,25 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
             continue;
           }
 
+          if (indexType == Index.IndexType.DATA_SKIPPING_SET
+              || isParameterizedBloomFilterIndex(indexType)) {
+            try {
+              parameterProperties =
+                  parseIndexPropertiesForQuery(indexType, parameterSource, 
name, !includesTypeFull);
+            } catch (IllegalArgumentException e) {
+              throw new IllegalArgumentException(
+                  "Failed to load data skipping index '%s' from %s.%s with %s 
'%s': %s"
+                      .formatted(
+                          name,
+                          databaseName,
+                          tableName,
+                          parameterSourceName,
+                          parameterSource,
+                          e.getMessage()),
+                  e);
+            }
+          }
+
           // Only include granularity in properties when it differs from the 
default,
           // so that indexes created without explicit granularity have empty 
properties
           // and match the original creation state (avoids false index-change 
diffs).
@@ -1930,20 +1958,9 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
           if (granularity != DEFAULT_INDEX_GRANULARITY) {
             properties.put(GRANULARITY, String.valueOf(granularity));
           }
-          Map<String, String> bloomFilterProperties;
-          try {
-            bloomFilterProperties =
-                parseBloomFilterPropertiesForQuery(
-                    indexType, parameterSource, name, !includesTypeFull);
-          } catch (IllegalArgumentException e) {
-            throw new IllegalArgumentException(
-                "Failed to load data skipping index '%s' from %s.%s with %s 
'%s'"
-                    .formatted(name, databaseName, tableName, 
parameterSourceName, parameterSource),
-                e);
-          }
           if (!includesTypeFull
               && isParameterizedBloomFilterIndex(indexType)
-              && bloomFilterProperties.isEmpty()) {
+              && parameterProperties.isEmpty()) {
             LOG.warn(
                 "Legacy ClickHouse metadata does not expose bloom-filter 
parameters for "
                     + "{} index '{}' on {}.{}; loaded Index.properties() is 
incomplete",
@@ -1952,7 +1969,18 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
                 databaseName,
                 tableName);
           }
-          properties.putAll(bloomFilterProperties);
+          if (!includesTypeFull
+              && indexType == Index.IndexType.DATA_SKIPPING_SET
+              && parameterProperties.isEmpty()
+              && !StringUtils.contains(parameterSource, "(")) {
+            LOG.warn(
+                "Legacy ClickHouse metadata does not expose SET max-values 
parameters for "
+                    + "SET index '{}' on {}.{}; loaded Index.properties() is 
incomplete",
+                name,
+                databaseName,
+                tableName);
+          }
+          properties.putAll(parameterProperties);
           secondaryIndexes.add(Indexes.of(indexType, name, fields, 
properties));
         }
       }
@@ -1977,6 +2005,107 @@ public class ClickHouseTableOperations extends 
JdbcTableOperations {
     return false;
   }
 
+  /**
+   * Parses the single positional parameter returned by ClickHouse for a SET 
data skipping index.
+   *
+   * @param indexType the mapped Gravitino index type
+   * @param typeFull the complete ClickHouse index type expression
+   * @param indexName the index name for validation messages
+   * @return the SET index properties, or an empty map for non-SET index types 
and {@code set(0)}
+   * @throws IllegalArgumentException if a SET index has malformed or 
out-of-range parameters
+   */
+  @VisibleForTesting
+  static Map<String, String> parseSetProperties(
+      Index.IndexType indexType, String typeFull, String indexName) {
+    if (indexType != Index.IndexType.DATA_SKIPPING_SET) {
+      return Collections.emptyMap();
+    }
+
+    String normalizedTypeFull = StringUtils.trimToEmpty(typeFull);
+    int paramsStart = normalizedTypeFull.indexOf('(');
+    int paramsEnd = normalizedTypeFull.lastIndexOf(')');
+    Preconditions.checkArgument(
+        paramsStart > 0 && paramsEnd == normalizedTypeFull.length() - 1,
+        "Invalid SET metadata '%s' for index '%s'",
+        typeFull,
+        indexName);
+    Preconditions.checkArgument(
+        StringUtils.equalsIgnoreCase(
+            DATA_SKIPPING_SET, normalizedTypeFull.substring(0, 
paramsStart).trim()),
+        "SET metadata '%s' does not match SET index '%s'",
+        typeFull,
+        indexName);
+
+    String[] params = normalizedTypeFull.substring(paramsStart + 1, 
paramsEnd).split(",", -1);
+    Preconditions.checkArgument(
+        params.length == 1,
+        "Invalid SET metadata '%s' for SET index '%s': expected one parameter 
but got %s",
+        typeFull,
+        indexName,
+        params.length);
+
+    String rawValue = params[0].trim();
+    Preconditions.checkArgument(
+        !rawValue.isEmpty(),
+        "Invalid SET metadata '%s' for SET index '%s': set_max_values is 
required",
+        typeFull,
+        indexName);
+    Preconditions.checkArgument(
+        SET_MAX_VALUES_PATTERN.matcher(rawValue).matches(),
+        "Invalid SET metadata '%s' for SET index '%s': set_max_values '%s' is 
not a valid decimal integer",
+        typeFull,
+        indexName,
+        rawValue);
+    BigInteger value;
+    try {
+      value = new BigInteger(rawValue);
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException(
+          "Invalid SET metadata '%s' for SET index '%s': set_max_values '%s' 
is not a valid decimal integer"
+              .formatted(typeFull, indexName, rawValue),
+          e);
+    }
+
+    if (value.compareTo(MIN_SET_MAX_VALUES) < 0 || 
value.compareTo(MAX_SET_MAX_VALUES) > 0) {
+      throw new IllegalArgumentException(
+          "Invalid SET metadata '%s' for SET index '%s': set_max_values '%s' 
is outside supported range %s"
+              .formatted(typeFull, indexName, rawValue, SET_MAX_VALUES_RANGE));
+    }
+    if (value.equals(MIN_SET_MAX_VALUES)) {
+      return Collections.emptyMap();
+    }
+    return Map.of(SET_MAX_VALUES, value.toString());
+  }
+
+  private static Map<String, String> parseIndexPropertiesForQuery(
+      Index.IndexType indexType,
+      String parameterSource,
+      String indexName,
+      boolean allowBareLegacyType) {
+    switch (indexType) {
+      case DATA_SKIPPING_SET:
+        return parseSetPropertiesForQuery(
+            indexType, parameterSource, indexName, allowBareLegacyType);
+      case DATA_SKIPPING_NGRAMBFV1:
+      case DATA_SKIPPING_TOKENBFV1:
+        return parseBloomFilterPropertiesForQuery(
+            indexType, parameterSource, indexName, allowBareLegacyType);
+      default:
+        return Collections.emptyMap();
+    }
+  }
+
+  private static Map<String, String> parseSetPropertiesForQuery(
+      Index.IndexType indexType,
+      String parameterSource,
+      String indexName,
+      boolean allowBareLegacyType) {
+    if (allowBareLegacyType && !StringUtils.contains(parameterSource, "(")) {
+      return Collections.emptyMap();
+    }
+    return parseSetProperties(indexType, parameterSource, indexName);
+  }
+
   /**
    * Parses the positional parameters returned by ClickHouse in {@code 
type_full} for the two
    * parameterized bloom-filter data skipping indexes.
diff --git 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
index 769fd71906..9e7dba6e81 100644
--- 
a/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
+++ 
b/catalogs-contrib/catalog-jdbc-clickhouse/src/test/java/org/apache/gravitino/catalog/clickhouse/integration/test/CatalogClickHouseIT.java
@@ -18,6 +18,8 @@
  */
 package org.apache.gravitino.catalog.clickhouse.integration.test;
 
+import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.GRANULARITY;
+import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseConstants.IndexConstants.SET_MAX_VALUES;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE.MERGETREE;
 import static 
org.apache.gravitino.catalog.clickhouse.ClickHouseTablePropertiesMetadata.ENGINE.REPLACINGMERGETREE;
@@ -766,6 +768,121 @@ public class CatalogClickHouseIT extends BaseIT {
                         && Arrays.deepEquals(idx.fieldNames(), new String[][] 
{{"user_id"}})));
   }
 
+  @Test
+  void testSetIndexParameterReadbackLifecycle() {
+    TableCatalog tableCatalog = catalog.asTableCatalog();
+    Column[] columns =
+        new Column[] {
+          Column.of("id", Types.LongType.get(), "id", false, false, 
DEFAULT_VALUE_NOT_SET),
+          Column.of("value", Types.StringType.get(), "value", false, false, 
DEFAULT_VALUE_NOT_SET)
+        };
+    SortOrder[] sortOrders = getSortOrders("id");
+    Map<String, String> setProperties = Map.of(SET_MAX_VALUES, "100", 
GRANULARITY, "3");
+
+    String tableName = GravitinoITUtils.genRandomName("set_readback");
+    NameIdentifier tableIdentifier = NameIdentifier.of(schemaName, tableName);
+    tableCatalog.createTable(
+        tableIdentifier,
+        columns,
+        "SET index readback",
+        createProperties(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        sortOrders,
+        new Index[] {
+          Indexes.of(
+              Index.IndexType.DATA_SKIPPING_SET,
+              "idx_set",
+              new String[][] {{"value"}},
+              setProperties)
+        });
+
+    Table loaded = tableCatalog.loadTable(tableIdentifier);
+    assertSetIndexMetadata(loaded, "idx_set", setProperties);
+    assertSetIndexDdl(tableName, "set(100)", "GRANULARITY 3");
+    Index[] loadedIndexes = loaded.index();
+
+    String recreatedTableName = 
GravitinoITUtils.genRandomName("set_recreated");
+    NameIdentifier recreatedIdentifier = NameIdentifier.of(schemaName, 
recreatedTableName);
+    tableCatalog.createTable(
+        recreatedIdentifier,
+        columns,
+        "SET index recreated",
+        createProperties(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        sortOrders,
+        loadedIndexes);
+    Table recreated = tableCatalog.loadTable(recreatedIdentifier);
+    assertSetIndexMetadata(recreated, "idx_set", setProperties);
+    assertSetIndexDdl(recreatedTableName, "set(100)", "GRANULARITY 3");
+
+    tableCatalog.alterTable(
+        tableIdentifier,
+        TableChange.addIndex(
+            Index.IndexType.DATA_SKIPPING_SET,
+            "idx_set_alter",
+            new String[][] {{"value"}},
+            setProperties));
+    Table altered = tableCatalog.loadTable(tableIdentifier);
+    assertSetIndexMetadata(altered, "idx_set_alter", setProperties);
+    assertSetIndexDdl(tableName, "set(100)", "GRANULARITY 3");
+
+    String defaultTableName = GravitinoITUtils.genRandomName("set_default");
+    NameIdentifier defaultIdentifier = NameIdentifier.of(schemaName, 
defaultTableName);
+    tableCatalog.createTable(
+        defaultIdentifier,
+        columns,
+        "SET index default",
+        createProperties(),
+        Transforms.EMPTY_TRANSFORM,
+        Distributions.NONE,
+        sortOrders,
+        new Index[] {
+          Indexes.of(
+              Index.IndexType.DATA_SKIPPING_SET, "idx_set_default", new 
String[][] {{"value"}})
+        });
+    Table defaultLoaded = tableCatalog.loadTable(defaultIdentifier);
+    assertSetIndexMetadata(defaultLoaded, "idx_set_default", Map.of());
+    assertSetIndexDdl(defaultTableName, "set(0)");
+
+    String nativeTableName = GravitinoITUtils.genRandomName("set_native");
+    clickhouseService.executeQuery(
+        String.format(
+            "CREATE TABLE `%s`.`%s` ("
+                + "  `id` UInt64,"
+                + "  `value` String,"
+                + "  INDEX `idx_native_set` `value` TYPE set(100) GRANULARITY 
3"
+                + ") ENGINE = MergeTree ORDER BY id",
+            schemaName, nativeTableName));
+    Table nativeLoaded = tableCatalog.loadTable(NameIdentifier.of(schemaName, 
nativeTableName));
+    assertSetIndexMetadata(nativeLoaded, "idx_native_set", setProperties);
+  }
+
+  private void assertSetIndexMetadata(
+      Table table, String indexName, Map<String, String> expectedProperties) {
+    Index index =
+        Arrays.stream(table.index())
+            .filter(candidate -> Objects.equals(indexName, candidate.name()))
+            .findFirst()
+            .orElseThrow(() -> new AssertionError("Missing index " + 
indexName));
+    Assertions.assertEquals(Index.IndexType.DATA_SKIPPING_SET, index.type());
+    Assertions.assertArrayEquals(new String[][] {{"value"}}, 
index.fieldNames());
+    Assertions.assertEquals(expectedProperties, index.properties());
+  }
+
+  private void assertSetIndexDdl(String tableName, String... 
expectedFragments) {
+    String createSql =
+        clickhouseService.executeQueryForResult(
+            String.format("SHOW CREATE TABLE `%s`.`%s`", schemaName, 
tableName));
+    String normalizedCreateSql = createSql.replaceAll("\\s+", "");
+    for (String expectedFragment : expectedFragments) {
+      Assertions.assertTrue(
+          normalizedCreateSql.contains(expectedFragment.replaceAll("\\s+", 
"")),
+          "SHOW CREATE TABLE should contain " + expectedFragment + ": " + 
createSql);
+    }
+  }
+
   @Test
   void testCreateAndLoadWithCustomGranularity() {
     String table = GravitinoITUtils.genRandomName("granularity_roundtrip");
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 e5688697bc..0616db0516 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
@@ -917,6 +917,110 @@ public class TestClickHouseTableOperationsUnit {
     Assertions.assertTrue(exception.getMessage().contains("type_full"));
   }
 
+  @Test
+  void testParseSetPropertiesNormalizesValuesAndOmitsDefault() {
+    Assertions.assertEquals(
+        Map.of("set_max_values", "100"),
+        ClickHouseTableOperations.parseSetProperties(
+            Index.IndexType.DATA_SKIPPING_SET, " set ( 00100 ) ", "idx_set"));
+    Assertions.assertTrue(
+        ClickHouseTableOperations.parseSetProperties(
+                Index.IndexType.DATA_SKIPPING_SET, "set(0)", "idx_set")
+            .isEmpty());
+    Assertions.assertTrue(
+        ClickHouseTableOperations.parseSetProperties(
+                Index.IndexType.DATA_SKIPPING_MINMAX, "minmax", "idx_minmax")
+            .isEmpty());
+  }
+
+  @Test
+  void testParseSetPropertiesAcceptsIntegerMaxValue() {
+    Assertions.assertEquals(
+        Map.of("set_max_values", String.valueOf(Integer.MAX_VALUE)),
+        ClickHouseTableOperations.parseSetProperties(
+            Index.IndexType.DATA_SKIPPING_SET, "set(" + Integer.MAX_VALUE + 
")", "idx_set"));
+  }
+
+  @Test
+  void testParseSetPropertiesRejectsValuesOutsideIntegerRange() {
+    for (String value : List.of("-1", "2147483648", "18446744073709551615")) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  ClickHouseTableOperations.parseSetProperties(
+                      Index.IndexType.DATA_SKIPPING_SET, "set(" + value + ")", 
"idx_set"));
+      Assertions.assertTrue(exception.getMessage().contains("outside supported 
range"));
+      Assertions.assertTrue(exception.getMessage().contains(value));
+      Assertions.assertTrue(exception.getMessage().contains("[0, 
2147483647]"));
+      Assertions.assertTrue(exception.getMessage().contains("idx_set"));
+    }
+  }
+
+  @Test
+  void testParseSetPropertiesRejectsMalformedMetadata() {
+    for (String typeFull : List.of("set()", "set(100, 200)", "set(abc)", 
"set(100")) {
+      IllegalArgumentException exception =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  ClickHouseTableOperations.parseSetProperties(
+                      Index.IndexType.DATA_SKIPPING_SET, typeFull, "idx_bad"));
+      Assertions.assertTrue(exception.getMessage().contains("idx_bad"));
+    }
+
+    IllegalArgumentException wrongTypeException =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                ClickHouseTableOperations.parseSetProperties(
+                    Index.IndexType.DATA_SKIPPING_SET, "tokenbf_v1(100)", 
"idx_bad"));
+    Assertions.assertTrue(wrongTypeException.getMessage().contains("idx_bad"));
+  }
+
+  @Test
+  void testGetIndexesFailsOnOutOfRangeSetMetadata() throws Exception {
+    for (String value : List.of("2147483648", "18446744073709551615")) {
+      IllegalArgumentException exception = 
getIndexesFailureForSetTypeFull("set(" + value + ")");
+      Assertions.assertTrue(exception.getMessage().contains("idx_overflow"));
+      Assertions.assertTrue(exception.getMessage().contains("type_full"));
+      Assertions.assertTrue(exception.getMessage().contains(value));
+      Assertions.assertTrue(exception.getMessage().contains("outside supported 
range"));
+      Assertions.assertTrue(exception.getMessage().contains("[0, 
2147483647]"));
+    }
+  }
+
+  @Test
+  void testGetIndexesReadsSetPropertiesWithGranularity() throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+
+    PreparedStatement primaryKeyStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet primaryKeyRs = Mockito.mock(ResultSet.class);
+    PreparedStatement secondaryStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet secondaryRs = Mockito.mock(ResultSet.class);
+
+    Mockito.when(primaryKeyRs.next()).thenReturn(false);
+    Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
+    Mockito.when(secondaryRs.next()).thenReturn(true, false);
+    Mockito.when(secondaryStmt.executeQuery()).thenReturn(secondaryRs);
+    Mockito.when(secondaryRs.getString("name")).thenReturn("idx_set");
+    Mockito.when(secondaryRs.getString("type")).thenReturn("set");
+    Mockito.when(secondaryRs.getString("type_full")).thenReturn("set(100)");
+    Mockito.when(secondaryRs.getString("expr")).thenReturn("col_1");
+    Mockito.when(secondaryRs.getLong("granularity")).thenReturn(3L);
+
+    Connection connection = Mockito.mock(Connection.class);
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(primaryKeyStmt)
+        .thenReturn(secondaryStmt);
+
+    List<Index> indexes = ops.callGetIndexes(connection, "db", "tbl");
+
+    Assertions.assertEquals(1, indexes.size());
+    Assertions.assertEquals(
+        Map.of("set_max_values", "100", "granularity", "3"), 
indexes.get(0).properties());
+  }
+
   @Test
   void testGetIndexesSkipsUnsupportedExpressionForParameterizedIndex() throws 
Exception {
     ExposedClickHouseTableOperations ops = newOps();
@@ -928,14 +1032,16 @@ public class TestClickHouseTableOperationsUnit {
 
     Mockito.when(primaryKeyRs.next()).thenReturn(false);
     Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
-    Mockito.when(secondaryRs.next()).thenReturn(true, true, false);
+    Mockito.when(secondaryRs.next()).thenReturn(true, true, true, false);
     Mockito.when(secondaryStmt.executeQuery()).thenReturn(secondaryRs);
-    Mockito.when(secondaryRs.getString("name")).thenReturn("idx_bad_expr", 
"idx_valid");
-    Mockito.when(secondaryRs.getString("type")).thenReturn("ngrambf_v1", 
"tokenbf_v1");
+    Mockito.when(secondaryRs.getString("name"))
+        .thenReturn("idx_bad_expr", "idx_valid", "idx_set_bad_expr");
+    Mockito.when(secondaryRs.getString("type")).thenReturn("ngrambf_v1", 
"tokenbf_v1", "set");
     Mockito.when(secondaryRs.getString("type_full"))
-        .thenReturn("ngrambf_v1(3, 512, 3, 0)", "tokenbf_v1(256, 2, 0)");
-    Mockito.when(secondaryRs.getString("expr")).thenReturn("lower(col_1)", 
"col_2");
-    Mockito.when(secondaryRs.getLong("granularity")).thenReturn(1L, 1L);
+        .thenReturn("ngrambf_v1(3, 512, 3, 0)", "tokenbf_v1(256, 2, 0)", 
"set(100)");
+    Mockito.when(secondaryRs.getString("expr"))
+        .thenReturn("lower(col_1)", "col_2", "cityHash64(col_3) % 16");
+    Mockito.when(secondaryRs.getLong("granularity")).thenReturn(1L, 1L, 1L);
 
     Connection connection = Mockito.mock(Connection.class);
     Mockito.when(connection.prepareStatement(Mockito.anyString()))
@@ -954,6 +1060,63 @@ public class TestClickHouseTableOperationsUnit {
             "hash_functions", "2",
             "random_seed", "0"),
         indexes.get(0).properties());
+    Assertions.assertFalse(
+        indexes.stream().anyMatch(index -> 
"idx_set_bad_expr".equals(index.name())));
+  }
+
+  @Test
+  void testGetIndexesFailsOnMalformedSetMetadataWithSupportedExpression() 
throws Exception {
+    IllegalArgumentException exception = 
getIndexesFailureForSetMetadata("set(abc)", "col_1");
+
+    
Assertions.assertTrue(exception.getMessage().contains("idx_bad_set_metadata"));
+    Assertions.assertTrue(exception.getMessage().contains("type_full"));
+    Assertions.assertTrue(exception.getMessage().contains("set(abc)"));
+    Assertions.assertTrue(exception.getMessage().contains("SET metadata"));
+  }
+
+  @Test
+  void 
testGetIndexesSkipsUnsupportedExpressionBeforeParsingOutOfRangeSetMetadata()
+      throws Exception {
+    List<Index> indexes = getIndexesForSetMetadata("set(2147483648)", 
"cityHash64(col_1) % 16");
+
+    Assertions.assertTrue(indexes.isEmpty());
+  }
+
+  @Test
+  void testGetIndexesFailsOnMalformedLegacySetMetadata() throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+
+    PreparedStatement primaryKeyStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet primaryKeyRs = Mockito.mock(ResultSet.class);
+    PreparedStatement modernSecondaryStmt = 
Mockito.mock(PreparedStatement.class);
+    PreparedStatement legacySecondaryStmt = 
Mockito.mock(PreparedStatement.class);
+    ResultSet legacySecondaryRs = Mockito.mock(ResultSet.class);
+
+    Mockito.when(primaryKeyRs.next()).thenReturn(false);
+    Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
+    Mockito.when(modernSecondaryStmt.executeQuery())
+        .thenThrow(new SQLException("Unknown identifier 'type_full'"));
+    
Mockito.when(legacySecondaryStmt.executeQuery()).thenReturn(legacySecondaryRs);
+    Mockito.when(legacySecondaryRs.next()).thenReturn(true, false);
+    
Mockito.when(legacySecondaryRs.getString("name")).thenReturn("idx_legacy_bad");
+    Mockito.when(legacySecondaryRs.getString("type")).thenReturn("set(abc)");
+    Mockito.when(legacySecondaryRs.getString("expr")).thenReturn("col_1");
+    Mockito.when(legacySecondaryRs.getLong("granularity")).thenReturn(1L);
+
+    Connection connection = Mockito.mock(Connection.class);
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(primaryKeyStmt)
+        .thenReturn(modernSecondaryStmt)
+        .thenReturn(legacySecondaryStmt);
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class, () -> 
ops.callGetIndexes(connection, "db", "tbl"));
+    Assertions.assertTrue(exception.getMessage().contains("idx_legacy_bad"));
+    Assertions.assertTrue(exception.getMessage().contains("legacy type"));
+    Assertions.assertTrue(exception.getMessage().contains("set(abc)"));
+    Assertions.assertTrue(exception.getMessage().contains("SET metadata"));
+    Assertions.assertFalse(exception.getMessage().contains("type_full"));
   }
 
   @Test
@@ -996,6 +1159,56 @@ public class TestClickHouseTableOperationsUnit {
         indexes.get(0).properties());
   }
 
+  @Test
+  void testGetIndexesFallsBackAndReadsLegacySetParameters() throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+
+    PreparedStatement primaryKeyStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet primaryKeyRs = Mockito.mock(ResultSet.class);
+    PreparedStatement modernSecondaryStmt = 
Mockito.mock(PreparedStatement.class);
+    PreparedStatement legacySecondaryStmt = 
Mockito.mock(PreparedStatement.class);
+    ResultSet legacySecondaryRs = Mockito.mock(ResultSet.class);
+
+    Mockito.when(primaryKeyRs.next()).thenReturn(false);
+    Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
+    Mockito.when(modernSecondaryStmt.executeQuery())
+        .thenThrow(new SQLException("Unknown identifier 'type_full'"));
+    
Mockito.when(legacySecondaryStmt.executeQuery()).thenReturn(legacySecondaryRs);
+    Mockito.when(legacySecondaryRs.next()).thenReturn(true, true, false);
+    Mockito.when(legacySecondaryRs.getString("name"))
+        .thenReturn("idx_legacy_set", "idx_legacy_bare");
+    Mockito.when(legacySecondaryRs.getString("type")).thenReturn("set(100)", 
"set");
+    Mockito.when(legacySecondaryRs.getString("expr")).thenReturn("col_1", 
"col_2");
+    Mockito.when(legacySecondaryRs.getLong("granularity")).thenReturn(1L, 1L);
+
+    Connection connection = Mockito.mock(Connection.class);
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(primaryKeyStmt)
+        .thenReturn(modernSecondaryStmt)
+        .thenReturn(legacySecondaryStmt);
+
+    List<Index> indexes = ops.callGetIndexes(connection, "db", "tbl");
+
+    Assertions.assertEquals(2, indexes.size());
+    Index parameterized =
+        indexes.stream()
+            .filter(index -> "idx_legacy_set".equals(index.name()))
+            .findFirst()
+            .orElseThrow();
+    Assertions.assertEquals("idx_legacy_set", parameterized.name());
+    Assertions.assertEquals(Index.IndexType.DATA_SKIPPING_SET, 
parameterized.type());
+    Assertions.assertEquals(Map.of("set_max_values", "100"), 
parameterized.properties());
+
+    Index bare =
+        indexes.stream()
+            .filter(index -> "idx_legacy_bare".equals(index.name()))
+            .findFirst()
+            .orElseThrow();
+    Assertions.assertEquals("idx_legacy_bare", bare.name());
+    Assertions.assertEquals(Index.IndexType.DATA_SKIPPING_SET, bare.type());
+    Assertions.assertTrue(bare.properties().isEmpty());
+  }
+
   @Test
   void testGetIndexesDoesNotFallbackForOtherSqlErrors() throws Exception {
     ExposedClickHouseTableOperations ops = newOps();
@@ -1232,4 +1445,63 @@ public class TestClickHouseTableOperationsUnit {
       this.updateStatement = updateStatement;
     }
   }
+
+  private IllegalArgumentException getIndexesFailureForSetTypeFull(String 
typeFull)
+      throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    PreparedStatement primaryKeyStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet primaryKeyRs = Mockito.mock(ResultSet.class);
+    PreparedStatement secondaryStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet secondaryRs = Mockito.mock(ResultSet.class);
+
+    Mockito.when(primaryKeyRs.next()).thenReturn(false);
+    Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
+    Mockito.when(secondaryRs.next()).thenReturn(true, false);
+    Mockito.when(secondaryStmt.executeQuery()).thenReturn(secondaryRs);
+    Mockito.when(secondaryRs.getString("name")).thenReturn("idx_overflow");
+    Mockito.when(secondaryRs.getString("type")).thenReturn("set");
+    Mockito.when(secondaryRs.getString("type_full")).thenReturn(typeFull);
+    Mockito.when(secondaryRs.getString("expr")).thenReturn("col_1");
+    Mockito.when(secondaryRs.getLong("granularity")).thenReturn(1L);
+
+    Connection connection = Mockito.mock(Connection.class);
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(primaryKeyStmt)
+        .thenReturn(secondaryStmt);
+
+    return Assertions.assertThrows(
+        IllegalArgumentException.class, () -> ops.callGetIndexes(connection, 
"db", "tbl"));
+  }
+
+  private List<Index> getIndexesForSetMetadata(String typeFull, String 
expression)
+      throws Exception {
+    ExposedClickHouseTableOperations ops = newOps();
+    PreparedStatement primaryKeyStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet primaryKeyRs = Mockito.mock(ResultSet.class);
+    PreparedStatement secondaryStmt = Mockito.mock(PreparedStatement.class);
+    ResultSet secondaryRs = Mockito.mock(ResultSet.class);
+
+    Mockito.when(primaryKeyRs.next()).thenReturn(false);
+    Mockito.when(primaryKeyStmt.executeQuery()).thenReturn(primaryKeyRs);
+    Mockito.when(secondaryRs.next()).thenReturn(true, false);
+    Mockito.when(secondaryStmt.executeQuery()).thenReturn(secondaryRs);
+    
Mockito.when(secondaryRs.getString("name")).thenReturn("idx_bad_set_metadata");
+    Mockito.when(secondaryRs.getString("type")).thenReturn("set");
+    Mockito.when(secondaryRs.getString("type_full")).thenReturn(typeFull);
+    Mockito.when(secondaryRs.getString("expr")).thenReturn(expression);
+    Mockito.when(secondaryRs.getLong("granularity")).thenReturn(1L);
+
+    Connection connection = Mockito.mock(Connection.class);
+    Mockito.when(connection.prepareStatement(Mockito.anyString()))
+        .thenReturn(primaryKeyStmt)
+        .thenReturn(secondaryStmt);
+
+    return ops.callGetIndexes(connection, "db", "tbl");
+  }
+
+  private IllegalArgumentException getIndexesFailureForSetMetadata(
+      String typeFull, String expression) throws Exception {
+    return Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
getIndexesForSetMetadata(typeFull, expression));
+  }
 }

Reply via email to