This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new f31f199ebc [core] Define scalar primary-key global indexes (#8599)
f31f199ebc is described below
commit f31f199ebc25585a25079bf53708a5c2aa0e8a5b
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 13 19:46:06 2026 +0800
[core] Define scalar primary-key global indexes (#8599)
Define the configuration and schema contract for source-backed scalar
primary-key indexes, preparing BTree and Bitmap indexes for the later
build, write, and scan changes.
---
docs/generated/core_configuration.html | 12 ++
.../main/java/org/apache/paimon/CoreOptions.java | 78 ++++++++-
.../paimon/index/pk/PrimaryKeyIndexDefinition.java | 67 ++++++++
.../index/pk/PrimaryKeyIndexDefinitions.java | 118 +++++++++++++
.../org/apache/paimon/schema/SchemaManager.java | 17 +-
.../org/apache/paimon/schema/SchemaValidation.java | 100 +++++++++++
.../index/pk/PrimaryKeyIndexDefinitionsTest.java | 104 +++++++++++
.../pksorted/PrimaryKeySortedIndexOptionsTest.java | 78 +++++++++
.../PrimaryKeySortedIndexValidationTest.java | 191 +++++++++++++++++++++
.../apache/paimon/schema/SchemaManagerTest.java | 79 ++++++++-
10 files changed, 837 insertions(+), 7 deletions(-)
diff --git a/docs/generated/core_configuration.html
b/docs/generated/core_configuration.html
index 5d46fda800..cc8ceaaec3 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1199,6 +1199,18 @@ This config option does not affect the default
filesystem metastore.</td>
<td>String</td>
<td>You can specify a pattern to get a timestamp from partitions.
The formatter pattern is defined by 'partition.timestamp-formatter'.<ul><li>By
default, read from the first field.</li><li>If the timestamp in the partition
is a single field called 'dt', you can use '$dt'.</li><li>If it is spread
across multiple fields for year, month, day, and hour, you can use
'$year-$month-$day $hour:00:00'.</li><li>If the timestamp is in fields dt and
hour, you can use '$dt $hour:00:00'.</ [...]
</tr>
+ <tr>
+ <td><h5>pk-bitmap.index.columns</h5></td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Comma-separated columns indexed by primary-key Bitmap
indexes.</td>
+ </tr>
+ <tr>
+ <td><h5>pk-btree.index.columns</h5></td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Comma-separated columns indexed by primary-key BTree
indexes.</td>
+ </tr>
<tr>
<td><h5>pk-clustering-override</h5></td>
<td style="word-wrap: break-word;">false</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index e4f43582a1..9130e4bc03 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2741,6 +2741,20 @@ public class CoreOptions implements Serializable {
+ "metric are also field-scoped. The first
release supports exactly "
+ "one column.");
+ public static final ConfigOption<String> PK_BTREE_INDEX_COLUMNS =
+ key("pk-btree.index.columns")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Comma-separated columns indexed by primary-key
BTree indexes.");
+
+ public static final ConfigOption<String> PK_BITMAP_INDEX_COLUMNS =
+ key("pk-bitmap.index.columns")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Comma-separated columns indexed by primary-key
Bitmap indexes.");
+
public static final ConfigOption<Integer>
PK_VECTOR_INDEX_COMPACTION_LEVEL_FANOUT =
key("pk-vector.index.compaction.level-fanout")
.intType()
@@ -4296,13 +4310,75 @@ public class CoreOptions implements Serializable {
}
public List<String> primaryKeyVectorIndexColumns() {
- String columns = options.get(PK_VECTOR_INDEX_COLUMNS);
+ return primaryKeyIndexColumns(PK_VECTOR_INDEX_COLUMNS);
+ }
+
+ public List<String> primaryKeyBTreeIndexColumns() {
+ return primaryKeyIndexColumns(PK_BTREE_INDEX_COLUMNS);
+ }
+
+ public List<String> primaryKeyBitmapIndexColumns() {
+ return primaryKeyIndexColumns(PK_BITMAP_INDEX_COLUMNS);
+ }
+
+ private List<String> primaryKeyIndexColumns(ConfigOption<String> option) {
+ String columns = options.get(option);
if (columns == null) {
return Collections.emptyList();
}
return Arrays.stream(columns.split(",",
-1)).map(String::trim).collect(Collectors.toList());
}
+ public Options primaryKeyBTreeIndexOptions(String column) {
+ return primaryKeySortedIndexOptions(column, "pk-btree",
"btree-index.");
+ }
+
+ public Options primaryKeyBitmapIndexOptions(String column) {
+ return primaryKeySortedIndexOptions(column, "pk-bitmap",
"bitmap-index.");
+ }
+
+ private Options primaryKeySortedIndexOptions(
+ String column, String optionFamily, String algorithmPrefix) {
+ Options resolved = new Options(toConfiguration().toMap());
+ String optionKey = "fields." + column + "." + optionFamily +
".index.options";
+ String serialized = options.get(optionKey);
+ if (serialized == null || serialized.trim().isEmpty()) {
+ return resolved;
+ }
+
+ LinkedHashMap<String, String> parsed;
+ try {
+ parsed = JsonSerdeUtil.parseJsonMap(serialized, String.class);
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException(
+ optionKey + " must be a JSON object of option key-value
pairs.", e);
+ }
+
+ for (Map.Entry<String, String> entry : parsed.entrySet()) {
+ String key = entry.getKey();
+ String value = entry.getValue();
+ checkArgument(
+ key != null && !key.trim().isEmpty(),
+ "%s contains an empty option key.",
+ optionKey);
+ checkArgument(value != null, "%s value for key %s must not be
null.", optionKey, key);
+ String qualifiedKey =
+ key.startsWith(algorithmPrefix)
+ || key.startsWith("sorted-index.")
+ || key.startsWith("fields.")
+ ? key
+ : algorithmPrefix + key;
+ String previous = resolved.get(qualifiedKey);
+ checkArgument(
+ previous == null || previous.equals(value),
+ "%s defines conflicting values for %s.",
+ optionKey,
+ qualifiedKey);
+ resolved.setString(qualifiedKey, value);
+ }
+ return resolved;
+ }
+
public String primaryKeyVectorIndexColumn() {
List<String> columns = primaryKeyVectorIndexColumns();
checkArgument(
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
new file mode 100644
index 0000000000..76f7b3a2ef
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
@@ -0,0 +1,67 @@
+/*
+ * 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.paimon.index.pk;
+
+import org.apache.paimon.options.Options;
+
+/** Resolved definition of one source-backed primary-key index. */
+public class PrimaryKeyIndexDefinition {
+
+ /** Built-in primary-key index families. */
+ public enum Family {
+ VECTOR,
+ BTREE,
+ BITMAP
+ }
+
+ private final String column;
+ private final int fieldId;
+ private final String indexType;
+ private final Options options;
+ private final Family family;
+
+ public PrimaryKeyIndexDefinition(
+ String column, int fieldId, String indexType, Options options,
Family family) {
+ this.column = column;
+ this.fieldId = fieldId;
+ this.indexType = indexType;
+ this.options = options;
+ this.family = family;
+ }
+
+ public String column() {
+ return column;
+ }
+
+ public int fieldId() {
+ return fieldId;
+ }
+
+ public String indexType() {
+ return indexType;
+ }
+
+ public Options options() {
+ return options;
+ }
+
+ public Family family() {
+ return family;
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
new file mode 100644
index 0000000000..16f2fdaa3a
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
@@ -0,0 +1,118 @@
+/*
+ * 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.paimon.index.pk;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.globalindex.bitmap.BitmapGlobalIndexerFactory;
+import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Resolves all configured source-backed primary-key indexes. */
+public class PrimaryKeyIndexDefinitions {
+
+ private final List<PrimaryKeyIndexDefinition> definitions;
+
+ private PrimaryKeyIndexDefinitions(List<PrimaryKeyIndexDefinition>
definitions) {
+ this.definitions = Collections.unmodifiableList(definitions);
+ }
+
+ public static PrimaryKeyIndexDefinitions create(TableSchema schema) {
+ CoreOptions options = new CoreOptions(schema.options());
+ List<String> vectorColumns = options.primaryKeyVectorIndexColumns();
+ List<String> btreeColumns = options.primaryKeyBTreeIndexColumns();
+ List<String> bitmapColumns = options.primaryKeyBitmapIndexColumns();
+ validateNoDuplicates(vectorColumns,
CoreOptions.PK_VECTOR_INDEX_COLUMNS.key());
+ validateNoDuplicates(btreeColumns,
CoreOptions.PK_BTREE_INDEX_COLUMNS.key());
+ validateNoDuplicates(bitmapColumns,
CoreOptions.PK_BITMAP_INDEX_COLUMNS.key());
+ validateOneIndexPerColumn(vectorColumns, btreeColumns, bitmapColumns);
+ List<PrimaryKeyIndexDefinition> definitions = new ArrayList<>();
+
+ for (DataField field : schema.fields()) {
+ String column = field.name();
+ if (btreeColumns.contains(column)) {
+ definitions.add(
+ new PrimaryKeyIndexDefinition(
+ column,
+ field.id(),
+ BTreeGlobalIndexerFactory.IDENTIFIER,
+ options.primaryKeyBTreeIndexOptions(column),
+ PrimaryKeyIndexDefinition.Family.BTREE));
+ } else if (bitmapColumns.contains(column)) {
+ definitions.add(
+ new PrimaryKeyIndexDefinition(
+ column,
+ field.id(),
+ BitmapGlobalIndexerFactory.IDENTIFIER,
+ options.primaryKeyBitmapIndexOptions(column),
+ PrimaryKeyIndexDefinition.Family.BITMAP));
+ } else if (vectorColumns.contains(column)) {
+ definitions.add(
+ new PrimaryKeyIndexDefinition(
+ column,
+ field.id(),
+ options.primaryKeyVectorIndexType(column),
+ options.primaryKeyVectorIndexOptions(column),
+ PrimaryKeyIndexDefinition.Family.VECTOR));
+ }
+ }
+
+ return new PrimaryKeyIndexDefinitions(definitions);
+ }
+
+ private static void validateNoDuplicates(List<String> columns, String
optionKey) {
+ Set<String> uniqueColumns = new HashSet<>();
+ for (String column : columns) {
+ checkArgument(
+ uniqueColumns.add(column),
+ "%s contains duplicate column '%s'.",
+ optionKey,
+ column);
+ }
+ }
+
+ private static void validateOneIndexPerColumn(
+ List<String> vectorColumns, List<String> btreeColumns,
List<String> bitmapColumns) {
+ Set<String> indexedColumns = new HashSet<>();
+ validateUniqueColumns(indexedColumns, vectorColumns);
+ validateUniqueColumns(indexedColumns, btreeColumns);
+ validateUniqueColumns(indexedColumns, bitmapColumns);
+ }
+
+ private static void validateUniqueColumns(Set<String> indexedColumns,
List<String> columns) {
+ for (String column : columns) {
+ checkArgument(
+ indexedColumns.add(column),
+ "Column '%s' can own at most one primary-key index.",
+ column);
+ }
+ }
+
+ public List<PrimaryKeyIndexDefinition> definitions() {
+ return definitions;
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
index 6db4136f1b..10d73eeb9d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
@@ -439,7 +439,8 @@ public class SchemaManager implements Serializable {
} else if (change instanceof RenameColumn) {
RenameColumn rename = (RenameColumn) change;
assertNotUpdatingPartitionKeys(oldTableSchema,
rename.fieldNames(), "rename");
- assertNotRenamingPrimaryKeyVectorIndexColumn(oldTableSchema,
rename.fieldNames());
+ assertNotUpdatingPrimaryKeyIndexColumn(
+ oldTableSchema, rename.fieldNames(), "rename");
assertNotRenamingBlobColumn(newFields, rename.fieldNames());
new NestedColumnModifier(rename.fieldNames(), lazyIdentifier) {
@Override
@@ -496,6 +497,8 @@ public class SchemaManager implements Serializable {
UpdateColumnType update = (UpdateColumnType) change;
assertNotUpdatingPartitionKeys(oldTableSchema,
update.fieldNames(), "update");
assertNotUpdatingPrimaryKeys(oldTableSchema,
update.fieldNames(), "update");
+ assertNotUpdatingPrimaryKeyIndexColumn(
+ oldTableSchema, update.fieldNames(), "update type of");
assertNotChangingBlobColumnType(
newFields, update.fieldNames(), update.newDataType());
updateNestedColumn(
@@ -943,6 +946,7 @@ public class SchemaManager implements Serializable {
throw new UnsupportedOperationException(
String.format("Cannot drop partition key or primary key:
[%s]", columnToDrop));
}
+ assertNotUpdatingPrimaryKeyIndexColumn(schema, change.fieldNames(),
"drop");
}
private static void assertNotUpdatingPartitionKeys(
@@ -984,16 +988,19 @@ public class SchemaManager implements Serializable {
}
}
- private static void assertNotRenamingPrimaryKeyVectorIndexColumn(
- TableSchema schema, String[] fieldNames) {
+ private static void assertNotUpdatingPrimaryKeyIndexColumn(
+ TableSchema schema, String[] fieldNames, String operation) {
if (fieldNames.length > 1) {
return;
}
String fieldName = fieldNames[0];
- if (new
CoreOptions(schema.options()).primaryKeyVectorIndexColumns().contains(fieldName))
{
+ CoreOptions options = new CoreOptions(schema.options());
+ if (options.primaryKeyVectorIndexColumns().contains(fieldName)
+ || options.primaryKeyBTreeIndexColumns().contains(fieldName)
+ || options.primaryKeyBitmapIndexColumns().contains(fieldName))
{
throw new UnsupportedOperationException(
String.format(
- "Cannot rename primary-key vector index column:
[%s]", fieldName));
+ "Cannot %s primary-key index column: [%s]",
operation, fieldName));
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index 746338314d..49dfcb9b06 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -29,6 +29,9 @@ import org.apache.paimon.fileindex.FileIndexOptions;
import org.apache.paimon.fileindex.FileIndexerFactory;
import org.apache.paimon.fileindex.FileIndexerFactoryUtils;
import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.bitmap.BitmapGlobalIndexerFactory;
+import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
import org.apache.paimon.mergetree.compact.aggregate.FieldAggregator;
import
org.apache.paimon.mergetree.compact.aggregate.factory.FieldAggregatorFactory;
import org.apache.paimon.options.ConfigOption;
@@ -350,6 +353,8 @@ public class SchemaValidation {
fieldNamesSpecifiedAsVector.isEmpty(),
"Some of the columns specified as vector-field are unknown.");
+ validatePrimaryKeyIndexColumns(options);
+ validatePrimaryKeySortedIndexes(schema, options);
validatePrimaryKeyVectorIndex(schema, options);
validateMergeFunctionFactory(schema);
@@ -960,6 +965,101 @@ public class SchemaValidation {
options.primaryKeyVectorDistanceMetric(indexColumn));
}
+ private static void validatePrimaryKeyIndexColumns(CoreOptions options) {
+ List<String> vectorColumns = options.primaryKeyVectorIndexColumns();
+ List<String> btreeColumns = options.primaryKeyBTreeIndexColumns();
+ List<String> bitmapColumns = options.primaryKeyBitmapIndexColumns();
+ validateNoDuplicatePrimaryKeyIndexColumns(
+ vectorColumns, CoreOptions.PK_VECTOR_INDEX_COLUMNS.key());
+ validateNoDuplicatePrimaryKeyIndexColumns(
+ btreeColumns, CoreOptions.PK_BTREE_INDEX_COLUMNS.key());
+ validateNoDuplicatePrimaryKeyIndexColumns(
+ bitmapColumns, CoreOptions.PK_BITMAP_INDEX_COLUMNS.key());
+
+ Set<String> indexedColumns = new HashSet<>();
+ validateUniquePrimaryKeyIndexColumns(indexedColumns, vectorColumns);
+ validateUniquePrimaryKeyIndexColumns(indexedColumns, btreeColumns);
+ validateUniquePrimaryKeyIndexColumns(indexedColumns, bitmapColumns);
+ }
+
+ private static void validateNoDuplicatePrimaryKeyIndexColumns(
+ List<String> columns, String optionKey) {
+ checkArgument(
+ new HashSet<>(columns).size() == columns.size(),
+ "%s must not contain duplicate columns, but is %s.",
+ optionKey,
+ columns);
+ }
+
+ private static void validateUniquePrimaryKeyIndexColumns(
+ Set<String> indexedColumns, List<String> columns) {
+ for (String column : columns) {
+ checkArgument(
+ indexedColumns.add(column),
+ "Column '%s' can own at most one primary-key index.",
+ column);
+ }
+ }
+
+ private static void validatePrimaryKeySortedIndexes(TableSchema schema,
CoreOptions options) {
+ if (options.primaryKeyBTreeIndexColumns().isEmpty()
+ && options.primaryKeyBitmapIndexColumns().isEmpty()) {
+ return;
+ }
+
+ checkArgument(
+ options.deletionVectorsEnabled(),
+ "Primary-key BTree and Bitmap indexes require
deletion-vectors.enabled = true.");
+ checkArgument(
+ !schema.primaryKeys().isEmpty(),
+ "Primary-key BTree and Bitmap indexes require a primary-key
table.");
+ checkArgument(
+ options.bucket() > 0 || options.bucket() ==
BucketMode.POSTPONE_BUCKET,
+ "Primary-key BTree and Bitmap indexes require fixed or
postpone bucket mode "
+ + "(bucket > 0 or bucket = -2), but bucket is %s.",
+ options.bucket());
+ checkArgument(
+ !options.deletionVectorsMergeOnRead(),
+ "Primary-key BTree and Bitmap indexes require
deletion-vectors.merge-on-read = false.");
+ checkArgument(
+ !options.pkClusteringOverride(),
+ "Primary-key BTree and Bitmap indexes do not support
pk-clustering-override.");
+
+ validatePrimaryKeySortedIndexColumns(
+ schema,
+ options.primaryKeyBTreeIndexColumns(),
+ CoreOptions.PK_BTREE_INDEX_COLUMNS.key());
+ validatePrimaryKeySortedIndexColumns(
+ schema,
+ options.primaryKeyBitmapIndexColumns(),
+ CoreOptions.PK_BITMAP_INDEX_COLUMNS.key());
+
+ Map<String, DataField> fields = schema.nameToFieldMap();
+ for (String column : options.primaryKeyBTreeIndexColumns()) {
+ GlobalIndexer.create(
+ BTreeGlobalIndexerFactory.IDENTIFIER,
+ fields.get(column),
+ options.primaryKeyBTreeIndexOptions(column));
+ }
+ for (String column : options.primaryKeyBitmapIndexColumns()) {
+ GlobalIndexer.create(
+ BitmapGlobalIndexerFactory.IDENTIFIER,
+ fields.get(column),
+ options.primaryKeyBitmapIndexOptions(column));
+ }
+ }
+
+ private static void validatePrimaryKeySortedIndexColumns(
+ TableSchema schema, List<String> columns, String optionKey) {
+ for (String column : columns) {
+ checkArgument(
+ schema.fieldNames().contains(column),
+ "%s entry '%s' must reference an existing column.",
+ optionKey,
+ column);
+ }
+ }
+
private static void validateSequenceField(TableSchema schema, CoreOptions
options) {
List<String> sequenceField = options.sequenceField();
if (!sequenceField.isEmpty()) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
new file mode 100644
index 0000000000..b5d7875adc
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.paimon.index.pk;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link PrimaryKeyIndexDefinitions}. */
+class PrimaryKeyIndexDefinitionsTest {
+
+ @Test
+ void testCreatesMixedDefinitionsInSchemaFieldOrder() {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.PK_VECTOR_INDEX_COLUMNS.key(), "embedding");
+ options.put("fields.embedding.pk-vector.index.type", "ivf-pq");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "name");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "status");
+
+ List<PrimaryKeyIndexDefinition> definitions =
+
PrimaryKeyIndexDefinitions.create(schema(options)).definitions();
+
+ assertThat(definitions)
+ .extracting(PrimaryKeyIndexDefinition::column)
+ .containsExactly("name", "status", "embedding");
+ assertThat(definitions)
+ .extracting(PrimaryKeyIndexDefinition::family)
+ .containsExactly(
+ PrimaryKeyIndexDefinition.Family.BTREE,
+ PrimaryKeyIndexDefinition.Family.BITMAP,
+ PrimaryKeyIndexDefinition.Family.VECTOR);
+ assertThat(definitions)
+ .extracting(PrimaryKeyIndexDefinition::fieldId)
+ .containsExactly(1, 2, 3);
+ assertThat(definitions)
+ .extracting(PrimaryKeyIndexDefinition::indexType)
+ .containsExactly("btree", "bitmap", "ivf-pq");
+ }
+
+ @Test
+ void testRejectsDuplicateColumnWithinFamily() {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "name,name");
+
+ assertThatThrownBy(() ->
PrimaryKeyIndexDefinitions.create(schema(options)))
+ .hasMessageContaining("pk-btree.index.columns")
+ .hasMessageContaining("duplicate")
+ .hasMessageContaining("name");
+ }
+
+ @Test
+ void testRejectsColumnConfiguredByMultipleFamilies() {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "name");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "name");
+
+ assertThatThrownBy(() ->
PrimaryKeyIndexDefinitions.create(schema(options)))
+ .hasMessageContaining("name")
+ .hasMessageContaining("at most one primary-key index");
+ }
+
+ private static TableSchema schema(Map<String, String> options) {
+ return new TableSchema(
+ 0,
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT().notNull()),
+ new DataField(1, "name", DataTypes.STRING()),
+ new DataField(2, "status", DataTypes.INT()),
+ new DataField(3, "embedding", DataTypes.VECTOR(3,
DataTypes.FLOAT()))),
+ 3,
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ "");
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PrimaryKeySortedIndexOptionsTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PrimaryKeySortedIndexOptionsTest.java
new file mode 100644
index 0000000000..1822598773
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PrimaryKeySortedIndexOptionsTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.paimon.index.pksorted;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for primary-key sorted index options in {@link CoreOptions}. */
+class PrimaryKeySortedIndexOptionsTest {
+
+ @Test
+ void testResolvesBTreeIndexColumns() {
+ CoreOptions options =
+ new
CoreOptions(Collections.singletonMap("pk-btree.index.columns", " name, age "));
+
+
assertThat(options.primaryKeyBTreeIndexColumns()).containsExactly("name",
"age");
+ }
+
+ @Test
+ void testResolvesBitmapIndexColumns() {
+ CoreOptions options =
+ new CoreOptions(
+ Collections.singletonMap("pk-bitmap.index.columns", "
status, region "));
+
+
assertThat(options.primaryKeyBitmapIndexColumns()).containsExactly("status",
"region");
+ }
+
+ @Test
+ void testResolvesBTreeIndexOptions() {
+ Map<String, String> values = new HashMap<>();
+ values.put(
+ "fields.name.pk-btree.index.options",
+ "{\"block-size\":\"4
kb\",\"sorted-index.records-per-range\":\"10\"}");
+
+ Options options = new
CoreOptions(values).primaryKeyBTreeIndexOptions("name");
+
+ assertThat(options.get("btree-index.block-size")).isEqualTo("4 kb");
+
assertThat(options.get("sorted-index.records-per-range")).isEqualTo("10");
+ }
+
+ @Test
+ void testResolvesBitmapIndexOptions() {
+ Map<String, String> values = new HashMap<>();
+ values.put(
+ "fields.status.pk-bitmap.index.options",
+ "{\"dictionary-block-size\":\"8 kb\","
+ + "\"sorted-index.records-per-range\":\"20\"}");
+
+ Options options = new
CoreOptions(values).primaryKeyBitmapIndexOptions("status");
+
+
assertThat(options.get("bitmap-index.dictionary-block-size")).isEqualTo("8 kb");
+
assertThat(options.get("sorted-index.records-per-range")).isEqualTo("20");
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeySortedIndexValidationTest.java
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeySortedIndexValidationTest.java
new file mode 100644
index 0000000000..d55dd836ef
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeySortedIndexValidationTest.java
@@ -0,0 +1,191 @@
+/*
+ * 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.paimon.schema;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.paimon.schema.SchemaValidation.validateTableSchema;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for primary-key BTree and Bitmap index validation. */
+class PrimaryKeySortedIndexValidationTest {
+
+ @Test
+ void testRejectsColumnConfiguredForMultipleIndexFamilies() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining("payload")
+ .hasMessageContaining("at most one primary-key index");
+ }
+
+ @Test
+ void testRejectsDuplicateBTreeIndexColumns() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(),
"payload,payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining(CoreOptions.PK_BTREE_INDEX_COLUMNS.key())
+ .hasMessageContaining("duplicate");
+ }
+
+ @Test
+ void testRejectsDuplicateBitmapIndexColumns() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(),
"payload,payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+
.hasMessageContaining(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key())
+ .hasMessageContaining("duplicate");
+ }
+
+ @Test
+ void testRequiresDeletionVectors() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining(
+ "Primary-key BTree and Bitmap indexes require
deletion-vectors.enabled = true");
+ }
+
+ @Test
+ void testRequiresPrimaryKeyTable() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.BUCKET_KEY.key(), "id");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options,
Collections.emptyList())))
+ .hasMessageContaining(
+ "Primary-key BTree and Bitmap indexes require a
primary-key table");
+ }
+
+ @Test
+ void testRequiresFixedBucketMode() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.BUCKET.key(), "-1");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining(
+ "Primary-key BTree and Bitmap indexes require fixed or
postpone bucket mode");
+ }
+
+ @Test
+ void testSupportsPostponeBucket() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.BUCKET.key(), "-2");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+
+ assertThatCode(() ->
validateTableSchema(schema(options))).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testRejectsDeletionVectorMergeOnRead() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining(
+ "Primary-key BTree and Bitmap indexes require
deletion-vectors.merge-on-read = false");
+ }
+
+ @Test
+ void testRejectsPkClusteringOverride() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_CLUSTERING_OVERRIDE.key(), "true");
+ options.put(CoreOptions.CLUSTERING_COLUMNS.key(), "payload");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining(
+ "Primary-key BTree and Bitmap indexes do not support
pk-clustering-override");
+ }
+
+ @Test
+ void testRejectsUnknownIndexColumn() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "unknown");
+
+ assertThatThrownBy(() -> validateTableSchema(schema(options)))
+ .hasMessageContaining("pk-bitmap.index.columns entry
'unknown'")
+ .hasMessageContaining("must reference an existing column");
+ }
+
+ @Test
+ void testRejectsUnsupportedIndexColumnType() {
+ Map<String, String> options = enabledOptions();
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "payload");
+
+ TableSchema schema =
+ new TableSchema(
+ 0,
+ Arrays.asList(
+ new DataField(0, "id",
DataTypes.INT().notNull()),
+ new DataField(1, "payload",
DataTypes.ARRAY(DataTypes.INT()))),
+ 0,
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ "");
+
+ assertThatThrownBy(() -> validateTableSchema(schema))
+ .hasMessageContaining("ARRAY<INT>")
+ .hasMessageContaining("not supported by global index");
+ }
+
+ private static Map<String, String> enabledOptions() {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "1");
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ return options;
+ }
+
+ private static TableSchema schema(Map<String, String> options) {
+ return schema(options, Collections.singletonList("id"));
+ }
+
+ private static TableSchema schema(
+ Map<String, String> options, java.util.List<String> primaryKeys) {
+ return new TableSchema(
+ 0,
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT().notNull()),
+ new DataField(1, "payload", DataTypes.STRING())),
+ 0,
+ Collections.emptyList(),
+ primaryKeys,
+ options,
+ "");
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
index 2c065176d1..9cb401c98e 100644
--- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
@@ -196,7 +196,84 @@ public class SchemaManagerTest {
SchemaChange.renameColumn(
new String[] {"embedding"},
"renamed_embedding")))
.isInstanceOf(UnsupportedOperationException.class)
- .hasMessage("Cannot rename primary-key vector index column:
[embedding]");
+ .hasMessage("Cannot rename primary-key index column:
[embedding]");
+ }
+
+ @Test
+ public void testRejectRenamePrimaryKeyBTreeIndexColumn() throws Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "1");
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "name");
+ Schema schema =
+ new Schema(
+ Arrays.asList(
+ new DataField(0, "id",
DataTypes.INT().notNull()),
+ new DataField(1, "name", DataTypes.STRING())),
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ "");
+ SchemaManager manager = new SchemaManager(LocalFileIO.create(), path);
+ manager.createTable(schema);
+
+ assertThatThrownBy(
+ () ->
+ manager.commitChanges(
+ SchemaChange.renameColumn(
+ new String[] {"name"},
"renamed_name")))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessage("Cannot rename primary-key index column: [name]");
+ }
+
+ @Test
+ public void testRejectDropPrimaryKeyBitmapIndexColumn() throws Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "1");
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "status");
+ Schema schema =
+ new Schema(
+ Arrays.asList(
+ new DataField(0, "id",
DataTypes.INT().notNull()),
+ new DataField(1, "status", DataTypes.INT())),
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ "");
+ SchemaManager manager = new SchemaManager(LocalFileIO.create(), path);
+ manager.createTable(schema);
+
+ assertThatThrownBy(() ->
manager.commitChanges(SchemaChange.dropColumn("status")))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessage("Cannot drop primary-key index column: [status]");
+ }
+
+ @Test
+ public void testRejectTypeChangeOfPrimaryKeyBitmapIndexColumn() throws
Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.BUCKET.key(), "1");
+ options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+ options.put(CoreOptions.PK_BITMAP_INDEX_COLUMNS.key(), "status");
+ Schema schema =
+ new Schema(
+ Arrays.asList(
+ new DataField(0, "id",
DataTypes.INT().notNull()),
+ new DataField(1, "status", DataTypes.INT())),
+ Collections.emptyList(),
+ Collections.singletonList("id"),
+ options,
+ "");
+ SchemaManager manager = new SchemaManager(LocalFileIO.create(), path);
+ manager.createTable(schema);
+
+ assertThatThrownBy(
+ () ->
+ manager.commitChanges(
+ SchemaChange.updateColumnType(
+ "status", DataTypes.BIGINT())))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessage("Cannot update type of primary-key index column:
[status]");
}
@Test