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 8063701a5c [flink][spark] Support composite BTree global index 
creation (#10345)
8063701a5c is described below

commit 8063701a5ca31420bef353a9f0f401959dd4cc31
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 3 14:05:24 2026 +0800

    [flink][spark] Support composite BTree global index creation (#10345)
---
 docs/docs/multimodal-table/global-index/btree.mdx  |  41 +++++
 .../flink/globalindex/SortedIndexTopoBuilder.java  | 110 ++++++++----
 .../procedure/CreateGlobalIndexProcedure.java      |   2 +-
 .../paimon/flink/SortedGlobalIndexITCase.java      | 200 +++++++++++++++++++++
 .../globalindex/sorted/SortedIndexTopoBuilder.java |  58 ++++--
 .../CompositeBTreeIndexProcedureTest.scala         | 152 ++++++++++++++++
 6 files changed, 513 insertions(+), 50 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/btree.mdx 
b/docs/docs/multimodal-table/global-index/btree.mdx
index d73fdbfc9b..5a31c6b005 100644
--- a/docs/docs/multimodal-table/global-index/btree.mdx
+++ b/docs/docs/multimodal-table/global-index/btree.mdx
@@ -145,6 +145,47 @@ print(pa_table)
 
 </Tabs>
 
+## Composite BTree Indexes
+
+Spark and Flink can create a composite index by listing columns in key order:
+
+```sql
+CALL sys.create_global_index(
+    `table` => 'db.my_table',
+    index_column => 'category,item_number',
+    index_type => 'btree'
+);
+
+SELECT * FROM my_table
+WHERE category = 'category-a'
+  AND item_number = 107;
+```
+
+The index stores typed tuples and one row-ID posting list for each distinct 
tuple.
+Equality conditions on every key column use a composite point lookup, 
regardless
+of their order in the SQL predicate. This avoids expanding each column's 
posting
+list before intersecting them. Additional predicates remain row filters, and OR
+branches can each select a composite lookup. When several definitions match,
+selection prefers the one with more key columns.
+
+Eligible composite point queries honor `global-index.query-in-reader.enabled`.
+With reader evaluation enabled, supported index conditions narrow the candidate
+rows and remaining conditions are evaluated against the data.
+
+Composite indexes can coexist with single-column indexes on the same columns.
+Composite lookup currently requires equality on all key columns; conditions on
+only some key columns, ranges and IN predicates use available single-column
+indexes or ordinary scans. Vector and full-text search pre-filters use
+single-column indexes or their data fallback.
+
+In `full` and `detail` modes, rows outside the selected composite index's 
coverage
+remain eligible for data filtering. Calling the procedure again with the same
+ordered column list fills missing index ranges. Under the `IGNORE` 
column-update
+policy, updates to any key column invalidate the affected composite index 
ranges;
+calling the procedure again rebuilds those ranges.
+To drop the composite definition, pass the same ordered column list to
+`sys.drop_global_index`; independent single-column definitions remain in place.
+
 ## BTree Options
 
 | Option | Default | Description |
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
index 54c010c228..f40a224265 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
@@ -64,6 +64,7 @@ import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.InternalRowUtils;
 import org.apache.paimon.utils.Range;
 
 import org.apache.flink.api.common.functions.Partitioner;
@@ -150,12 +151,34 @@ public class SortedIndexTopoBuilder {
             PartitionPredicate partitionPredicate,
             Options userOptions)
             throws Exception {
+        List<List<String>> definitions = new ArrayList<>();
+        for (String name : indexColumns) {
+            definitions.add(Collections.singletonList(name));
+        }
+        return buildIndexDefinitions(
+                env,
+                indexScannerSupplier,
+                table,
+                definitions,
+                indexType,
+                partitionPredicate,
+                userOptions);
+    }
+
+    private static Optional<DataStream<Committable>> buildIndexDefinitions(
+            StreamExecutionEnvironment env,
+            Supplier<SortedGlobalIndexScanner> indexScannerSupplier,
+            FileStoreTable table,
+            List<List<String>> definitions,
+            String indexType,
+            PartitionPredicate partitionPredicate,
+            Options userOptions)
+            throws Exception {
         List<DataStream<Committable>> allStreams = new ArrayList<>();
-        for (String indexColumn : indexColumns) {
+        for (List<String> indexColumns : definitions) {
+            String indexColumn = indexColumns.get(0);
             SortedGlobalIndexScanner indexScanner =
-                    indexScannerSupplier
-                            .get()
-                            
.withIndexFields(Collections.singletonList(indexColumn));
+                    indexScannerSupplier.get().withIndexFields(indexColumns);
             if (partitionPredicate != null) {
                 indexScanner = 
indexScanner.withPartitionPredicate(partitionPredicate);
             }
@@ -176,18 +199,12 @@ public class SortedIndexTopoBuilder {
                 continue;
             }
 
-            // 2. Select necessary columns (index field + ROW_ID)
-            List<String> selectedColumns = new ArrayList<>();
-            selectedColumns.add(indexColumn);
-
-            RowType dataReadType =
-                    
SpecialFields.rowTypeWithRowId(table.rowType().project(selectedColumns));
+            // 2. Select the ordered index fields and ROW_ID.
+            RowType sourceReadType = table.rowType().project(indexColumns);
+            RowType dataReadType = 
SpecialFields.rowTypeWithRowId(sourceReadType);
             String buildTaskIdField = buildTaskIdFieldName(dataReadType);
             GlobalIndexer indexer =
-                    GlobalIndexer.create(
-                            indexType,
-                            
Collections.singletonList(table.rowType().getField(indexColumn)),
-                            userOptions);
+                    GlobalIndexer.create(indexType, 
sourceReadType.getFields(), userOptions);
             if (!(indexer instanceof SortedGlobalIndexer)) {
                 throw new IllegalArgumentException(
                         "Index algorithm " + indexType + " does not expose 
sorted index keys.");
@@ -200,7 +217,6 @@ public class SortedIndexTopoBuilder {
             int indexFieldPos = sortReadType.getFieldIndex(indexColumn);
             int rowIdPos = 
sortReadType.getFieldIndex(SpecialFields.ROW_ID.name());
             DataType indexFieldType = sortReadType.getTypeAt(indexFieldPos);
-            DataType sourceFieldType = 
table.rowType().getField(indexColumn).type();
 
             // 3. Calculate maximum parallelism bound
             long recordsPerRange =
@@ -220,7 +236,7 @@ public class SortedIndexTopoBuilder {
                 BinaryRow partition = partitionEntry.getKey();
                 byte[] partitionBytes = 
binaryRowSerializer.serializeToBytes(partition);
                 Map<Range, List<Split>> ranges =
-                        keyExtractor.isIdentity()
+                        keyExtractor.isIdentity() && 
sourceReadType.getFieldCount() == 1
                                 ? partitionEntry.getValue()
                                 : 
shardSplitsByRowRange(partitionEntry.getValue(), recordsPerRange);
                 for (Map.Entry<Range, List<Split>> entry : ranges.entrySet()) {
@@ -249,14 +265,14 @@ public class SortedIndexTopoBuilder {
                             splitTasks,
                             readBuilder,
                             new SortedGlobalIndexWriter(table, indexType, 
userOptions)
-                                    
.withIndexFields(Collections.singletonList(indexColumn)),
+                                    .withIndexFields(indexColumns),
                             scanResult.scanSnapshotId(),
                             partitionFieldSize,
                             taskIdPos,
                             indexFieldPos,
                             rowIdPos,
                             indexFieldType,
-                            sourceFieldType,
+                            sourceReadType,
                             keyExtractor,
                             coreOptions,
                             sortReadType,
@@ -306,19 +322,22 @@ public class SortedIndexTopoBuilder {
     public static void buildIndexAndExecute(
             StreamExecutionEnvironment env,
             FileStoreTable table,
-            String indexColumn,
+            List<String> indexColumns,
             String indexType,
             PartitionPredicate partitionPredicate,
             Options userOptions)
             throws Exception {
-        if (buildIndex(
-                env,
-                () -> new SortedGlobalIndexScanner(table, indexType, 
userOptions),
-                table,
-                Collections.singletonList(indexColumn),
-                indexType,
-                partitionPredicate,
-                userOptions)) {
+        Optional<DataStream<Committable>> written =
+                buildIndexDefinitions(
+                        env,
+                        () -> new SortedGlobalIndexScanner(table, indexType, 
userOptions),
+                        table,
+                        Collections.singletonList(indexColumns),
+                        indexType,
+                        partitionPredicate,
+                        userOptions);
+        if (written.isPresent()) {
+            commit(table, written.get(), 
CoreOptions.createCommitUser(userOptions));
             env.execute("Create " + indexType + " global index for table: " + 
table.name());
         }
     }
@@ -335,7 +354,7 @@ public class SortedIndexTopoBuilder {
             int indexFieldPos,
             int rowIdPos,
             DataType indexFieldType,
-            DataType sourceFieldType,
+            RowType sourceReadType,
             GlobalIndexKeyExtractor keyExtractor,
             CoreOptions coreOptions,
             RowType readType,
@@ -354,14 +373,14 @@ public class SortedIndexTopoBuilder {
                         .transform(
                                 "Read Data",
                                 InternalTypeInfo.fromRowType(readType),
-                                new ReadDataOperator(readBuilder, 
keyExtractor, sourceFieldType))
+                                new ReadDataOperator(readBuilder, 
keyExtractor, sourceReadType))
                         .setParallelism(parallelism);
 
         DataStream<InternalRow> sortedStream =
                 sortRows(
                         env,
                         rowDataStream,
-                        keyExtractor.isIdentity(),
+                        keyExtractor.isIdentity() && 
sourceReadType.getFieldCount() == 1,
                         taskIdPos,
                         indexFieldPos,
                         coreOptions,
@@ -516,25 +535,34 @@ public class SortedIndexTopoBuilder {
 
         private final ReadBuilder readBuilder;
         private final GlobalIndexKeyExtractor keyExtractor;
-        private final DataType sourceFieldType;
+        private final RowType sourceReadType;
 
         private transient TableRead tableRead;
         private transient InternalRow.FieldGetter sourceFieldGetter;
+        private transient InternalRow.FieldGetter[] compositeGetters;
 
         public ReadDataOperator(
                 ReadBuilder readBuilder,
                 GlobalIndexKeyExtractor keyExtractor,
-                DataType sourceFieldType) {
+                RowType sourceReadType) {
             this.readBuilder = readBuilder;
             this.keyExtractor = keyExtractor;
-            this.sourceFieldType = sourceFieldType;
+            this.sourceReadType = sourceReadType;
         }
 
         @Override
         public void open() throws Exception {
             super.open();
             this.tableRead = readBuilder.newRead();
-            this.sourceFieldGetter = 
InternalRow.createFieldGetter(sourceFieldType, 0);
+            if (sourceReadType.getFieldCount() > 1) {
+                compositeGetters = new 
InternalRow.FieldGetter[sourceReadType.getFieldCount()];
+                for (int i = 0; i < compositeGetters.length; i++) {
+                    compositeGetters[i] =
+                            
InternalRow.createFieldGetter(sourceReadType.getTypeAt(i), i);
+                }
+            } else {
+                sourceFieldGetter = 
InternalRow.createFieldGetter(sourceReadType.getTypeAt(0), 0);
+            }
         }
 
         @Override
@@ -546,10 +574,20 @@ public class SortedIndexTopoBuilder {
                     try {
                         InternalRow row;
                         while ((row = batch.next()) != null) {
-                            long rowId = row.getLong(1);
+                            long rowId = 
row.getLong(sourceReadType.getFieldCount());
+                            Object sourceValue;
+                            if (compositeGetters == null) {
+                                sourceValue = 
sourceFieldGetter.getFieldOrNull(row);
+                            } else {
+                                GenericRow tuple = new 
GenericRow(compositeGetters.length);
+                                for (int i = 0; i < compositeGetters.length; 
i++) {
+                                    tuple.setField(i, 
compositeGetters[i].getFieldOrNull(row));
+                                }
+                                sourceValue = InternalRowUtils.copy(tuple, 
sourceReadType);
+                            }
                             boolean[] emitted = new boolean[1];
                             keyExtractor.extract(
-                                    sourceFieldGetter.getFieldOrNull(row),
+                                    sourceValue,
                                     key -> {
                                         emitted[0] = true;
                                         output.collect(
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/CreateGlobalIndexProcedure.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/CreateGlobalIndexProcedure.java
index c0a0ea90d5..bb38de474f 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/CreateGlobalIndexProcedure.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/procedure/CreateGlobalIndexProcedure.java
@@ -135,7 +135,7 @@ public class CreateGlobalIndexProcedure extends 
ProcedureBase {
                 SortedIndexTopoBuilder.buildIndexAndExecute(
                         procedureContext.getExecutionEnvironment(),
                         table,
-                        indexColumns.get(0),
+                        indexColumns,
                         indexType,
                         partitionPredicate,
                         userOptions);
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
index 70be19ff6d..0ae2afbe7a 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/SortedGlobalIndexITCase.java
@@ -20,15 +20,28 @@ package org.apache.paimon.flink;
 
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.globalindex.IndexedSplit;
 import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
 import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.InternalRowUtils;
+import org.apache.paimon.utils.Range;
 
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
@@ -36,6 +49,10 @@ import org.apache.flink.table.api.config.TableConfigOptions;
 import org.apache.flink.types.Row;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -80,6 +97,189 @@ public class SortedGlobalIndexITCase extends 
CatalogITCaseBase {
         assertThat(sql("SELECT * FROM T WHERE id = 
100")).containsOnly(Row.of(100, "name_100"));
     }
 
+    @Test
+    public void testCompositeBTreeIndex() throws Exception {
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql(
+                "CREATE TABLE T_COMPOSITE (id INT, category STRING, 
item_number INT) WITH ("
+                        + "'row-tracking.enabled' = 'true', 
'data-evolution.enabled' = 'true', "
+                        + "'global-index.column-update-action' = 'IGNORE', "
+                        + "'sorted-index.records-per-file' = '13', 
'sorted-index.build.max-parallelism' = '4', "
+                        + "'btree-index.bloom-filter.enabled' = 'true')");
+        insertCompositeRows(0, 40);
+        sql(
+                "INSERT INTO T_COMPOSITE VALUES "
+                        + "(100, CAST(NULL AS STRING), 7), (101, 'category-a', 
CAST(NULL AS INT))");
+        buildBTreeIndexForTable("T_COMPOSITE", "category");
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number");
+        // Reverse the schema order to verify the index preserves the 
requested key order.
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number, category");
+        List<IndexManifestEntry> compositeEntries =
+                
paimonTable("T_COMPOSITE").store().newIndexFileHandler().scanEntries().stream()
+                        .filter(
+                                entry ->
+                                        entry.indexFile()
+                                                        .globalIndexMeta()
+                                                        .getIndexedFieldIds()
+                                                        .size()
+                                                == 2)
+                        .collect(Collectors.toList());
+        assertThat(compositeEntries).isNotEmpty();
+        assertThat(compositeEntries)
+                .allSatisfy(
+                        entry -> {
+                            
assertThat(entry.indexFile().globalIndexMeta().getIndexedFieldIds())
+                                    .containsExactly(2, 1);
+                            
assertThat(entry.indexFile().globalIndexMeta().rowRange().count())
+                                    .isLessThanOrEqualTo(13);
+                        });
+        assertThat(compositeEntries.stream().mapToLong(entry -> 
entry.indexFile().rowCount()).sum())
+                .isEqualTo(42);
+        for (boolean inReader : Arrays.asList(false, true)) {
+            sql(
+                    "ALTER TABLE T_COMPOSITE SET 
('global-index.query-in-reader.enabled' = '"
+                            + inReader
+                            + "', 'scalar-index.search-mode' = 'fast')");
+            assertThat(
+                            sql(
+                                    "SELECT id FROM T_COMPOSITE WHERE 
item_number = 7 AND category = 'category-a'"))
+                    .containsExactlyInAnyOrder(Row.of(7), Row.of(27));
+            assertThat(
+                            sql(
+                                    "SELECT id FROM T_COMPOSITE WHERE category 
= 'category-a' AND item_number = 7"))
+                    .containsExactlyInAnyOrder(Row.of(7), Row.of(27));
+            assertThat(
+                            sql(
+                                    "SELECT id FROM T_COMPOSITE WHERE category 
= 'absent' AND item_number = 7"))
+                    .isEmpty();
+        }
+        insertCompositeRows(40, 60);
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number,category");
+        assertThat(
+                        sql(
+                                "SELECT id FROM T_COMPOSITE WHERE category = 
'category-a' AND item_number = 7"))
+                .containsExactlyInAnyOrder(Row.of(7), Row.of(27), Row.of(47));
+        updateCompositeColumn(7, "item_number", 107);
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number,category");
+        assertThat(
+                        sql(
+                                "SELECT id FROM T_COMPOSITE WHERE category = 
'category-a' AND item_number = 107"))
+                .containsExactly(Row.of(7));
+        updateCompositeColumn(27, "category", 
BinaryString.fromString("category-c"));
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number,category");
+        assertThat(
+                        sql(
+                                "SELECT id FROM T_COMPOSITE WHERE category = 
'category-a' AND item_number = 7"))
+                .containsExactly(Row.of(47));
+        assertThat(
+                        sql(
+                                "SELECT id FROM T_COMPOSITE WHERE category = 
'category-c' AND item_number = 7"))
+                .containsExactly(Row.of(27));
+        long snapshot = 
paimonTable("T_COMPOSITE").snapshotManager().latestSnapshot().id();
+        buildBTreeIndexForTable("T_COMPOSITE", "item_number,category");
+        
assertThat(paimonTable("T_COMPOSITE").snapshotManager().latestSnapshot().id())
+                .isEqualTo(snapshot);
+        sql(
+                "CALL sys.drop_global_index(`table` => 'default.T_COMPOSITE', 
index_column => 'item_number,category', index_type => 'btree')");
+        List<IndexManifestEntry> remaining =
+                
paimonTable("T_COMPOSITE").store().newIndexFileHandler().scanEntries();
+        assertThat(remaining)
+                .isNotEmpty()
+                .allSatisfy(
+                        entry ->
+                                
assertThat(entry.indexFile().globalIndexMeta().getIndexedFieldIds())
+                                        .hasSize(1));
+        assertThat(
+                        remaining.stream()
+                                .map(entry -> 
entry.indexFile().globalIndexMeta().indexFieldId())
+                                .distinct()
+                                .collect(Collectors.toList()))
+                .containsExactlyInAnyOrder(1, 2);
+    }
+
+    private void insertCompositeRows(int from, int to) {
+        String values =
+                IntStream.range(from, to)
+                        .mapToObj(
+                                i ->
+                                        String.format(
+                                                "(%d, '%s', %d)",
+                                                i,
+                                                (i / 10) % 2 == 0 ? 
"category-a" : "category-b",
+                                                i % 10))
+                        .collect(Collectors.joining(","));
+        sql("INSERT INTO T_COMPOSITE VALUES " + values);
+    }
+
+    private void updateCompositeColumn(int id, String column, Object value) 
throws Exception {
+        FileStoreTable table = paimonTable("T_COMPOSITE");
+        RowType readType =
+                RowType.of(
+                        table.rowType().getField("id"),
+                        table.rowType().getField(column),
+                        SpecialFields.ROW_ID);
+        ReadBuilder readBuilder = 
table.newReadBuilder().withReadType(readType);
+        List<InternalRow> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(
+                    row -> rows.add((InternalRow) InternalRowUtils.copy(row, 
readType)));
+        }
+        List<Long> rowIds =
+                rows.stream()
+                        .filter(row -> row.getInt(0) == id)
+                        .map(row -> row.getLong(2))
+                        .collect(Collectors.toList());
+        assertThat(rowIds).hasSize(1);
+        long rowId = rowIds.get(0);
+        List<Range> ranges =
+                table.store().newScan().plan().files().stream()
+                        .map(entry -> entry.file().nonNullRowIdRange())
+                        .filter(range -> range.from <= rowId && rowId <= 
range.to)
+                        .distinct()
+                        .collect(Collectors.toList());
+        assertThat(ranges).hasSize(1);
+        Range range = ranges.get(0);
+        List<InternalRow> updatedRows =
+                rows.stream()
+                        .filter(row -> range.from <= row.getLong(2) && 
row.getLong(2) <= range.to)
+                        .sorted(Comparator.comparingLong(row -> 
row.getLong(2)))
+                        .collect(Collectors.toList());
+        assertThat(updatedRows).hasSize((int) range.count());
+        InternalRow.FieldGetter getter = 
InternalRow.createFieldGetter(readType.getTypeAt(1), 1);
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write =
+                        builder.newWrite()
+                                .withWriteType(
+                                        table.rowType()
+                                                
.project(Collections.singletonList(column)));
+                BatchTableCommit commit = builder.newCommit()) {
+            // Data Evolution partial-column writes preserve the original data 
file's row range.
+            for (InternalRow row : updatedRows) {
+                write.write(
+                        GenericRow.of(row.getInt(0) == id ? value : 
getter.getFieldOrNull(row)));
+            }
+            List<CommitMessage> messages = write.prepareCommit();
+            List<DataFileMeta> files =
+                    messages.stream()
+                            .flatMap(
+                                    message ->
+                                            ((CommitMessageImpl) message)
+                                                    
.newFilesIncrement().newFiles().stream())
+                            .collect(Collectors.toList());
+            assertThat(files).hasSize(1);
+            assertThat(files.get(0).rowCount()).isEqualTo(range.count());
+            for (CommitMessage message : messages) {
+                List<DataFileMeta> newFiles =
+                        ((CommitMessageImpl) 
message).newFilesIncrement().newFiles();
+                if (!newFiles.isEmpty()) {
+                    newFiles.set(0, 
newFiles.get(0).assignFirstRowId(range.from));
+                }
+            }
+            commit.commit(messages);
+        }
+    }
+
     @Test
     public void testBitmapIndex() throws Catalog.TableNotExistException {
         sql(
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
index e1f23c9fa1..54a361f002 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
@@ -45,6 +45,7 @@ import org.apache.paimon.table.sink.CommitMessageSerializer;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.InstantiationUtil;
@@ -72,6 +73,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Optional;
+import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.groupSplitsByRange;
 import static 
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.shardSplitsByRowRange;
@@ -101,9 +103,38 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
             DataField indexField,
             Options options)
             throws IOException {
+        return buildIndex(
+                spark,
+                relation,
+                partitionPredicate,
+                table,
+                indexType,
+                readType,
+                indexField,
+                Collections.emptyList(),
+                options);
+    }
+
+    @Override
+    public List<CommitMessage> buildIndex(
+            SparkSession spark,
+            DataSourceV2Relation relation,
+            PartitionPredicate partitionPredicate,
+            FileStoreTable table,
+            String indexType,
+            RowType readType,
+            DataField indexField,
+            List<DataField> extraFields,
+            Options options)
+            throws IOException {
+        extraFields = extraFields == null ? Collections.emptyList() : 
extraFields;
+        List<DataField> indexFields = new ArrayList<>();
+        indexFields.add(indexField);
+        indexFields.addAll(extraFields);
+        List<String> indexNames =
+                
indexFields.stream().map(DataField::name).collect(Collectors.toList());
         SortedGlobalIndexScanner indexScanner =
-                new SortedGlobalIndexScanner(table, indexType, options)
-                        
.withIndexFields(Collections.singletonList(indexField.name()));
+                new SortedGlobalIndexScanner(table, indexType, 
options).withIndexFields(indexNames);
         if (partitionPredicate != null) {
             indexScanner = 
indexScanner.withPartitionPredicate(partitionPredicate);
         }
@@ -131,8 +162,7 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
         int maxParallelism = 
options.get(SortedIndexOptions.SORTED_INDEX_BUILD_MAX_PARALLELISM);
 
         List<CommitMessage> allMessages = new ArrayList<>();
-        GlobalIndexer indexer =
-                GlobalIndexer.create(indexType, 
Collections.singletonList(indexField), options);
+        GlobalIndexer indexer = GlobalIndexer.create(indexType, indexFields, 
options);
         if (!(indexer instanceof SortedGlobalIndexer)) {
             throw new IllegalArgumentException(
                     "Index algorithm " + indexType + " does not expose sorted 
index keys.");
@@ -144,8 +174,7 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
         final int partitionKeyNum = table.partitionKeys().size();
         BinaryRowSerializer binaryRowSerializer = new 
BinaryRowSerializer(partitionKeyNum);
         SortedGlobalIndexWriter indexWriter =
-                new SortedGlobalIndexWriter(table, indexType, options)
-                        
.withIndexFields(Collections.singletonList(indexField.name()));
+                new SortedGlobalIndexWriter(table, indexType, 
options).withIndexFields(indexNames);
         final byte[] serializedWriter = 
InstantiationUtil.serializeObject(indexWriter);
         if (keyExtractor.isIdentity()) {
             List<SortedBuildTask> buildTasks = new ArrayList<>();
@@ -177,7 +206,14 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
                     taskInputs.add(
                             selected.select(
                                     functions.col(taskIdField),
-                                    functions.col(indexField.name()),
+                                    extraFields.isEmpty()
+                                            ? functions.col(indexField.name())
+                                            : functions
+                                                    .struct(
+                                                            indexNames.stream()
+                                                                    
.map(functions::col)
+                                                                    
.toArray(Column[]::new))
+                                                    .alias(indexField.name()),
                                     
functions.col(SpecialFields.ROW_ID.name())));
                 }
             }
@@ -420,10 +456,7 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
     }
 
     private static RowType normalizedReadType(
-            RowType readType,
-            String taskIdField,
-            DataField sourceField,
-            org.apache.paimon.types.DataType keyType) {
+            RowType readType, String taskIdField, DataField sourceField, 
DataType keyType) {
         return RowType.of(
                 new DataField(BUILD_TASK_ID_FIELD_ID, taskIdField, 
DataTypes.BIGINT().notNull()),
                 new DataField(sourceField.id(), sourceField.name(), keyType),
@@ -454,8 +487,7 @@ public class SortedIndexTopoBuilder implements 
GlobalIndexTopologyBuilder {
 
         private InternalRow next;
 
-        private SortedTaskInput(
-                Iterator<InternalRow> input, org.apache.paimon.types.DataType 
keyType) {
+        private SortedTaskInput(Iterator<InternalRow> input, DataType keyType) 
{
             this.input = input;
             this.keyGetter = InternalRow.createFieldGetter(keyType, 1);
             advance();
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
new file mode 100644
index 0000000000..334b68471c
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompositeBTreeIndexProcedureTest.scala
@@ -0,0 +1,152 @@
+/*
+ * 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.spark.procedure
+
+import org.apache.paimon.options.Options
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.spark.globalindex.sorted.SortedIndexTopoBuilder
+import org.apache.paimon.types.DataField
+
+import org.apache.spark.sql.Row
+
+import java.util.Collections
+
+import scala.collection.JavaConverters._
+
+class CompositeBTreeIndexProcedureTest extends PaimonSparkTestBase {
+
+  test("nullable extra fields preserve single column empty builds") {
+    withTable("T") {
+      sql("""CREATE TABLE T (id INT)
+            |TBLPROPERTIES ('bucket' = '-1', 'row-tracking.enabled' = 'true',
+            |'data-evolution.enabled' = 'true')
+            |""".stripMargin)
+      val table = loadTable("T")
+      for (extras <- Seq(null, Collections.emptyList[DataField]())) {
+        assert(
+          new SortedIndexTopoBuilder()
+            .buildIndex(
+              null,
+              null,
+              null,
+              table,
+              "btree",
+              table.rowType(),
+              table.rowType().getField("id"),
+              extras,
+              new Options())
+            .isEmpty)
+      }
+    }
+  }
+
+  test("composite btree creation, incremental build and component refresh") {
+    withTable("T", "S", "C") {
+      sql("""CREATE TABLE T (id INT, category STRING, item_number INT)
+            |TBLPROPERTIES ('bucket' = '-1', 'row-tracking.enabled' = 'true',
+            |'data-evolution.enabled' = 'true', 
'global-index.column-update-action' = 'IGNORE',
+            |'sorted-index.records-per-file' = '13', 
'btree-index.bloom-filter.enabled' = 'true')
+            |""".stripMargin)
+      def insert(from: Int, to: Int): Unit = {
+        val values = (from until to)
+          .map {
+            i =>
+              val categoryValue = if ((i / 10) % 2 == 0) "category-a" else 
"category-b"
+              s"($i, '$categoryValue', ${i % 10})"
+          }
+          .mkString(",")
+        sql(s"INSERT INTO T VALUES $values")
+      }
+      def build(columns: String): Unit = {
+        sql(
+          s"CALL sys.create_global_index(table => 'test.T', index_column => 
'$columns', index_type => 'btree')")
+      }
+      insert(0, 40)
+      sql("INSERT INTO T VALUES (100, NULL, 7), (101, 'category-a', NULL)")
+      build("category")
+      build("item_number")
+      build("category,item_number")
+      val indexes = 
loadTable("T").store().newIndexFileHandler().scanEntries().asScala
+      val composite =
+        
indexes.filter(_.indexFile().globalIndexMeta().getIndexedFieldIds().size() == 2)
+      assert(composite.nonEmpty)
+      assert(
+        composite.forall(
+          _.indexFile().globalIndexMeta().getIndexedFieldIds().asScala.toSeq 
== Seq(1, 2)))
+      assert(composite.map(_.indexFile().rowCount()).sum == 42L)
+      for (inReader <- Seq(false, true)) {
+        sql(
+          s"ALTER TABLE T SET TBLPROPERTIES 
('global-index.query-in-reader.enabled' = '$inReader', 
'scalar-index.search-mode' = 'fast')")
+        checkAnswer(
+          sql("SELECT id FROM T WHERE item_number = 7 AND category = 
'category-a'"),
+          Seq(Row(7), Row(27)))
+        checkAnswer(
+          sql("SELECT id FROM T WHERE category = 'absent' AND item_number = 
7"),
+          Seq.empty)
+      }
+      insert(40, 60)
+      build("category,item_number")
+      checkAnswer(
+        sql("SELECT id FROM T WHERE item_number = 7 AND category = 
'category-a'"),
+        Seq(Row(7), Row(27), Row(47)))
+      sql("CREATE TABLE S (id INT, item_number INT)")
+      sql("INSERT INTO S VALUES (7, 107)")
+      sql(
+        "MERGE INTO T USING S ON T.id = S.id WHEN MATCHED THEN UPDATE SET 
T.item_number = S.item_number")
+      build("category,item_number")
+      checkAnswer(
+        sql("SELECT id FROM T WHERE category = 'category-a' AND item_number = 
107"),
+        Seq(Row(7)))
+      checkAnswer(
+        sql("SELECT id FROM T WHERE category = 'category-a' AND item_number = 
7"),
+        Seq(Row(27), Row(47)))
+      sql("CREATE TABLE C (id INT, category STRING)")
+      sql("INSERT INTO C VALUES (27, 'category-c')")
+      sql(
+        "MERGE INTO T USING C ON T.id = C.id WHEN MATCHED THEN UPDATE SET 
T.category = C.category")
+      build("category,item_number")
+      checkAnswer(
+        sql("SELECT id FROM T WHERE category = 'category-a' AND item_number = 
7"),
+        Seq(Row(47)))
+      checkAnswer(
+        sql("SELECT id FROM T WHERE category = 'category-c' AND item_number = 
7"),
+        Seq(Row(27)))
+      val snapshot = loadTable("T").snapshotManager().latestSnapshot().id()
+      build("category,item_number")
+      assert(loadTable("T").snapshotManager().latestSnapshot().id() == 
snapshot)
+      sql(
+        "CALL sys.drop_global_index(table => 'test.T', index_column => 
'category,item_number', index_type => 'btree')")
+      assert(
+        loadTable("T")
+          .store()
+          .newIndexFileHandler()
+          .scanEntries()
+          .asScala
+          .forall(_.indexFile().globalIndexMeta().getIndexedFieldIds().size() 
== 1))
+      assert(
+        loadTable("T")
+          .store()
+          .newIndexFileHandler()
+          .scanEntries()
+          .asScala
+          .map(_.indexFile().globalIndexMeta().indexFieldId())
+          .toSet == Set(1, 2))
+    }
+  }
+}

Reply via email to