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 e4b3343ac4 [spark] Fix bucket metadata reads for chain tables (#10652)
e4b3343ac4 is described below

commit e4b3343ac4d968c06e219785b71e08feccf35eaa
Author: Ran Tao <[email protected]>
AuthorDate: Tue Oct 20 15:51:34 2026 +0800

    [spark] Fix bucket metadata reads for chain tables (#10652)
---
 .../org/apache/paimon/table/source/ChainSplit.java |  16 ++
 .../apache/paimon/table/source/ChainSplitTest.java |  21 +-
 .../apache/paimon/utils/ChainTableUtilsTest.java   |   3 +-
 .../paimon/spark/PaimonRecordReaderIterator.scala  |  32 ++-
 .../spark/util/SplitUtilsQueryAuthTest.scala       |  76 +++++-
 .../paimon/spark/SparkChainTableMetadataTest.java  | 272 +++++++++++++++++++++
 6 files changed, 401 insertions(+), 19 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
index c74a8ac903..57003859d8 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.table.source;
 
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.io.DataFileMetaSerializer;
 import org.apache.paimon.io.DataFileMetaWriteColsLegacySerializer;
@@ -42,6 +43,9 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.OptionalLong;
 
+import static org.apache.paimon.utils.FileStorePathFactory.BUCKET_PATH_PREFIX;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
 /**
  * A split describes chain table read scope. It follows DataSplit's custom 
serialization pattern and
  * extends it with branch + partition per data file.
@@ -78,6 +82,18 @@ public class ChainSplit implements Split {
         return logicalPartition;
     }
 
+    /** Returns the physical bucket shared by all files in this split. */
+    public int bucket() {
+        // Chain splits are built per bucket. Use the stored paths so older 
serialized splits
+        // can also provide bucket metadata without changing the serialization 
format.
+        checkState(!fileBucketPathMapping.isEmpty(), "No bucket path in chain 
split.");
+        String bucketPath = fileBucketPathMapping.values().iterator().next();
+        String bucketName = new Path(bucketPath).getName();
+        checkState(
+                bucketName.startsWith(BUCKET_PATH_PREFIX), "Invalid bucket 
path: %s", bucketPath);
+        return 
Integer.parseInt(bucketName.substring(BUCKET_PATH_PREFIX.length()));
+    }
+
     public List<DataFileMeta> dataFiles() {
         return dataFiles;
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/ChainSplitTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/source/ChainSplitTest.java
index 402ac581bb..76901d9f9c 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/ChainSplitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/ChainSplitTest.java
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -52,7 +53,7 @@ public class ChainSplitTest {
         Map<String, String> fileBucketPathMapping = new HashMap<>();
         Map<String, String> fileBranchMapping = new HashMap<>();
         for (DataFileMeta dataFile : dataFiles) {
-            fileBucketPathMapping.put(dataFile.fileName(), 
"dt=20251202/bucket_0");
+            fileBucketPathMapping.put(dataFile.fileName(), 
"dt=20251202/bucket-7");
             fileBranchMapping.put(dataFile.fileName(), "branch_0");
         }
         ChainSplit split =
@@ -68,6 +69,7 @@ public class ChainSplitTest {
         
assertThat(fileBucketPathMapping).isEqualTo(newSplit.fileBucketPathMapping());
         assertThat(fileBranchMapping).isEqualTo(newSplit.fileBranchMapping());
         assertThat(newSplit.deletionFiles()).isEmpty();
+        assertThat(newSplit.bucket()).isEqualTo(7);
         assertThat(newSplit).isEqualTo(split);
     }
 
@@ -89,7 +91,7 @@ public class ChainSplitTest {
                         null,
                         new DeletionFile("dv3", 0, 50, 1L));
         for (DataFileMeta dataFile : dataFiles) {
-            fileBucketPathMapping.put(dataFile.fileName(), 
"dt=20251202/bucket_0");
+            fileBucketPathMapping.put(dataFile.fileName(), 
"dt=20251202/bucket-7");
             fileBranchMapping.put(dataFile.fileName(), "branch_0");
         }
         ChainSplit split =
@@ -106,6 +108,21 @@ public class ChainSplitTest {
         assertThat(fileBranchMapping).isEqualTo(newSplit.fileBranchMapping());
         assertThat(newSplit.deletionFiles().isPresent());
         assertThat(newSplit.deletionFiles().get()).isEqualTo(deletionFiles);
+        assertThat(newSplit.bucket()).isEqualTo(7);
         assertThat(newSplit).isEqualTo(split);
     }
+
+    @Test
+    public void testBucketFromDataSplit() {
+        DataFileMeta file = 
DataFileTestDataGenerator.builder().build().next().meta;
+        DataSplit dataSplit =
+                DataSplit.builder()
+                        .withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(3)
+                        .withBucketPath(
+                                
"file:/warehouse/branch/branch-snapshot/dt=20251202/bucket-3")
+                        .withDataFiles(Collections.singletonList(file))
+                        .build();
+        assertThat(ChainSplit.from(dataSplit, 
"snapshot").bucket()).isEqualTo(3);
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/ChainTableUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/ChainTableUtilsTest.java
index 9797af2a91..4f510680e0 100644
--- a/paimon-core/src/test/java/org/apache/paimon/utils/ChainTableUtilsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/ChainTableUtilsTest.java
@@ -774,6 +774,7 @@ public class ChainTableUtilsTest {
                         1L);
 
         assertThat(splits).hasSize(2);
+        assertThat(splits).allSatisfy(split -> 
assertThat(split.bucket()).isZero());
 
         List<Set<String>> groups =
                 
splits.stream().map(ChainTableUtilsTest::fileNames).collect(Collectors.toList());
@@ -875,7 +876,7 @@ public class ChainTableUtilsTest {
         return DataSplit.builder()
                 .withPartition(partition)
                 .withBucket(bucket)
-                .withBucketPath("bucket_" + bucket)
+                .withBucketPath("bucket-" + bucket)
                 .withTotalBuckets(1)
                 .withDataFiles(files)
                 .build();
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
index d2c758e93e..1c6dc81315 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonRecordReaderIterator.scala
@@ -22,9 +22,9 @@ import org.apache.paimon.data.{BinaryString, GenericRow, 
InternalRow => PaimonIn
 import org.apache.paimon.fs.Path
 import org.apache.paimon.reader.{FileRecordIterator, RecordReader, 
ScoreRecordIterator, ScoreRecordReader}
 import org.apache.paimon.spark.schema.PaimonMetadataColumn
-import 
org.apache.paimon.spark.schema.PaimonMetadataColumn.{PARTITION_AND_BUCKET_META_COLUMNS,
 PATH_AND_INDEX_META_COLUMNS, VECTOR_SEARCH_META_COLUMN_NAMES}
-import org.apache.paimon.table.source.{DataSplit, Split}
-import org.apache.paimon.utils.{CloseableIterator, Preconditions}
+import 
org.apache.paimon.spark.schema.PaimonMetadataColumn.{PATH_AND_INDEX_META_COLUMNS,
 VECTOR_SEARCH_META_COLUMN_NAMES}
+import org.apache.paimon.table.source.{ChainSplit, DataSplit, Split, Splits}
+import org.apache.paimon.utils.{CloseableIterator, IOUtils, Preconditions}
 
 import org.apache.spark.sql.PaimonUtils
 
@@ -37,14 +37,32 @@ case class PaimonRecordReaderIterator(
     split: Split)
   extends CloseableIterator[PaimonInternalRow] {
 
+  // Keep partition metadata restricted to the original DataSplit. Unwrapping 
authorization
+  // here would expose raw partition values after the reader has applied 
column masks.
   if (
-    metadataColumns.exists(c => 
PARTITION_AND_BUCKET_META_COLUMNS.contains(c.name)) && !split
+    metadataColumns.exists(_.name == PaimonMetadataColumn.PARTITION_COLUMN) && 
!split
       .isInstanceOf[DataSplit]
   ) {
-    throw new RuntimeException(
-      "There need be DataSplit when path and index metadata columns are 
required")
+    IOUtils.closeQuietly(reader)
+    throw new UnsupportedOperationException(
+      "Partition metadata is only supported for DataSplit without query 
authorization.")
   }
 
+  private lazy val bucketMetadata =
+    if (metadataColumns.exists(_.name == PaimonMetadataColumn.BUCKET_COLUMN)) {
+      // Inspect the underlying split only for metadata. The reader still 
receives the original
+      // split so fallback routing and query authorization are preserved.
+      Some(Splits.underlying(split) match {
+        case dataSplit: DataSplit => dataSplit.bucket()
+        case chainSplit: ChainSplit => chainSplit.bucket()
+        case _ =>
+          throw new RuntimeException(
+            "There need be DataSplit or ChainSplit when bucket metadata is 
required")
+      })
+    } else {
+      None
+    }
+
   private val needMetadata = metadataColumns.nonEmpty
   private val needPathAndIndexMetadata =
     metadataColumns.exists(c => PATH_AND_INDEX_META_COLUMNS.contains(c.name))
@@ -173,7 +191,7 @@ case class PaimonRecordReaderIterator(
           case PaimonMetadataColumn.PARTITION_COLUMN =>
             metadataRow.setField(index, 
split.asInstanceOf[DataSplit].partition())
           case PaimonMetadataColumn.BUCKET_COLUMN =>
-            metadataRow.setField(index, split.asInstanceOf[DataSplit].bucket())
+            metadataRow.setField(index, bucketMetadata.get)
         }
     }
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/util/SplitUtilsQueryAuthTest.scala
 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/util/SplitUtilsQueryAuthTest.scala
index cada2b24a2..b02f43695e 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/util/SplitUtilsQueryAuthTest.scala
+++ 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/util/SplitUtilsQueryAuthTest.scala
@@ -19,14 +19,17 @@
 package org.apache.paimon.spark.util
 
 import org.apache.paimon.catalog.TableQueryAuthResult
-import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.data.{BinaryRow, GenericRow, InternalRow}
 import org.apache.paimon.io.DataFileMeta
+import org.apache.paimon.reader.RecordReader
 import org.apache.paimon.spark.PaimonRecordReaderIterator
 import org.apache.paimon.spark.schema.PaimonMetadataColumn
 import org.apache.paimon.stats.SimpleStats
-import org.apache.paimon.table.source.{DataSplit, QueryAuthSplit, Split}
+import org.apache.paimon.table.FallbackReadFileStoreTable.FallbackSplitImpl
+import org.apache.paimon.table.source.{ChainSplit, DataSplit, QueryAuthSplit, 
Split}
+import org.apache.paimon.utils.IteratorRecordReader
 
-import org.apache.spark.sql.types.BinaryType
+import org.apache.spark.sql.types.{StringType, StructField, StructType}
 import org.scalatest.funsuite.AnyFunSuite
 
 import java.util.Collections
@@ -72,12 +75,67 @@ class SplitUtilsQueryAuthTest extends AnyFunSuite {
     assert(SplitUtils.dataFileCount(wrapped) == 1L, "file count collapsed 
under query auth")
   }
 
-  test("partition metadata columns are refused on a query-authorized split") {
-    val partitionColumn =
-      PaimonMetadataColumn(-1, PaimonMetadataColumn.PARTITION_COLUMN, 
BinaryType)
-    val error = intercept[RuntimeException] {
-      PaimonRecordReaderIterator(null, Seq(partitionColumn), 
authorized(dataSplit()))
+  private val partitionColumn =
+    PaimonMetadataColumn.PARTITION(StructType(Seq(StructField("dt", 
StringType))))
+
+  private class EmptyReader extends RecordReader[InternalRow] {
+    var closed = false
+
+    override def readBatch(): RecordReader.RecordIterator[InternalRow] = null
+
+    override def close(): Unit = {
+      closed = true
+    }
+  }
+
+  test("partition metadata remains supported for an unwrapped DataSplit") {
+    val iterator =
+      PaimonRecordReaderIterator(new EmptyReader, Seq(partitionColumn), 
dataSplit())
+    try {
+      assert(!iterator.hasNext)
+    } finally {
+      iterator.close()
+    }
+  }
+
+  private def chainAndAuthorizedSplits(): Seq[Split] = {
+    val chainSplit = ChainSplit.from(dataSplit(), "snapshot")
+    Seq(
+      authorized(dataSplit()),
+      chainSplit,
+      authorized(chainSplit),
+      new FallbackSplitImpl(chainSplit, true),
+      new FallbackSplitImpl(authorized(chainSplit), true))
+  }
+
+  test("partition metadata remains unsupported for authorized and chain 
splits") {
+    chainAndAuthorizedSplits().foreach {
+      split =>
+        val reader = new EmptyReader
+        val error = intercept[UnsupportedOperationException] {
+          PaimonRecordReaderIterator(reader, Seq(partitionColumn), split)
+        }
+        assert(error.getMessage.contains("Partition metadata is only supported 
for DataSplit"))
+        assert(reader.closed)
+    }
+  }
+
+  test("bucket metadata supports authorized and chain splits") {
+    chainAndAuthorizedSplits().foreach {
+      split =>
+        val reader = new IteratorRecordReader[InternalRow](
+          
Collections.singletonList[InternalRow](GenericRow.of(Int.box(42))).iterator())
+        val iterator =
+          PaimonRecordReaderIterator(reader, Seq(PaimonMetadataColumn.BUCKET), 
split)
+        try {
+          assert(iterator.hasNext)
+          val row = iterator.next()
+          assert(row.getInt(0) == 42)
+          assert(row.getInt(1) == 0)
+          assert(!iterator.hasNext)
+        } finally {
+          iterator.close()
+        }
     }
-    assert(error.getMessage.contains("DataSplit"))
   }
 }
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkChainTableMetadataTest.java
 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkChainTableMetadataTest.java
new file mode 100644
index 0000000000..fa4da2b923
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkChainTableMetadataTest.java
@@ -0,0 +1,272 @@
+/*
+ * 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;
+
+import org.apache.paimon.catalog.CatalogLoader;
+import org.apache.paimon.catalog.DelegateCatalog;
+import org.apache.paimon.catalog.TableQueryAuthResult;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.FallbackReadFileStoreTable.FallbackSplit;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.source.ChainSplit;
+import org.apache.paimon.table.source.QueryAuthSplit;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.utils.JsonSerdeUtil;
+
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests bucket metadata when Spark reads chain splits, including query 
authorization. */
+public class SparkChainTableMetadataTest {
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void testBucketMetadata(boolean queryAuth, @TempDir Path warehouse) 
throws Exception {
+        try (SparkSession spark = createSparkSession(warehouse, queryAuth)) {
+            spark.sql("CREATE DATABASE spark_test.chain_metadata");
+            spark.sql("USE spark_test.chain_metadata");
+            spark.sql(
+                    "CREATE TABLE T (id INT, payload STRING, seq BIGINT, dt 
STRING) "
+                            + "USING paimon PARTITIONED BY (dt) TBLPROPERTIES 
("
+                            + "'primary-key'='dt,id', 'bucket-key'='id', 
'bucket'='2', "
+                            + "'sequence.field'='seq', 
'chain-table.enabled'='true', "
+                            + "'partition.timestamp-pattern'='$dt', "
+                            + "'partition.timestamp-formatter'='yyyyMMdd')");
+            spark.sql("CALL spark_test.sys.create_branch('chain_metadata.T', 
'snapshot')");
+            spark.sql("CALL spark_test.sys.create_branch('chain_metadata.T', 
'delta')");
+            for (String table : Arrays.asList("T", "T$branch_snapshot", 
"T$branch_delta")) {
+                spark.sql(
+                        "ALTER TABLE `"
+                                + table
+                                + "` SET TBLPROPERTIES ("
+                                + "'scan.fallback-snapshot-branch'='snapshot', 
"
+                                + "'scan.fallback-delta-branch'='delta')");
+            }
+            spark.sql(
+                    "INSERT INTO `T$branch_snapshot` "
+                            + "SELECT CAST(id AS INT), 'old', 1, '20250810' 
FROM range(16)");
+            spark.sql(
+                    "INSERT INTO `T$branch_delta` "
+                            + "SELECT CAST(id AS INT), 'new', 2, '20250811' 
FROM range(4)");
+
+            List<Row> bucketRows =
+                    spark.sql("SELECT id, __paimon_bucket FROM 
`T$branch_snapshot`")
+                            .collectAsList();
+            assertThat(bucketRows).hasSize(16);
+            assertThat(bucketRows.stream().map(row -> 
row.getInt(1)).collect(Collectors.toSet()))
+                    .containsExactlyInAnyOrder(0, 1);
+
+            if (queryAuth) {
+                // Filter on a non-key field after merging: updated rows must 
not reappear with
+                // their old snapshot values, even when payload is absent from 
the projection.
+                for (String name : Arrays.asList("T", "T$branch_snapshot", 
"T$branch_delta")) {
+                    spark.sql(
+                            "ALTER TABLE `"
+                                    + name
+                                    + "` SET TBLPROPERTIES 
('query-auth.enabled'='true')");
+                }
+            }
+
+            // Cover both snapshot-only ChainSplit.from and snapshot + delta 
chain merging.
+            for (String dt : Arrays.asList("20250810", "20250811")) {
+                if (queryAuth) {
+                    assertQueryAuthChainSplits(spark, dt);
+                }
+                List<Row> visibleBucketRows =
+                        bucketRows.stream()
+                                .filter(
+                                        row ->
+                                                !queryAuth
+                                                        || 
dt.equals("20250810")
+                                                        || row.getInt(0) >= 4)
+                                .collect(Collectors.toList());
+                String predicate = " WHERE dt = '" + dt + "'";
+                assertThat(
+                                spark.sql("SELECT id, __paimon_bucket FROM T" 
+ predicate)
+                                        .collectAsList())
+                        
.containsExactlyInAnyOrderElementsOf(visibleBucketRows);
+                // Partition metadata on chain and authorized splits is a 
separate concern.
+                for (String query :
+                        Arrays.asList(
+                                "SELECT id, __paimon_partition, 
__paimon_bucket FROM T" + predicate,
+                                "SELECT id FROM T"
+                                        + predicate
+                                        + " AND __paimon_partition.dt = '"
+                                        + dt
+                                        + "'")) {
+                    assertThatThrownBy(() -> spark.sql(query).collectAsList())
+                            .rootCause()
+                            .isInstanceOf(UnsupportedOperationException.class)
+                            .hasMessageContaining(
+                                    "Partition metadata is only supported for 
DataSplit");
+                }
+
+                for (int bucket = 0; bucket < 2; bucket++) {
+                    final int selectedBucket = bucket;
+                    List<Row> expected =
+                            visibleBucketRows.stream()
+                                    .filter(row -> row.getInt(1) == 
selectedBucket)
+                                    .map(
+                                            row ->
+                                                    RowFactory.create(
+                                                            row.getInt(0),
+                                                            
dt.equals("20250811")
+                                                                            && 
row.getInt(0) < 4
+                                                                    ? "new"
+                                                                    : "old"))
+                                    .collect(Collectors.toList());
+                    assertThat(
+                                    spark.sql(
+                                                    "SELECT id, payload FROM T"
+                                                            + predicate
+                                                            + " AND 
__paimon_bucket = "
+                                                            + bucket)
+                                            .collectAsList())
+                            .containsExactlyInAnyOrderElementsOf(expected);
+                }
+            }
+            spark.sql("DROP TABLE T");
+            spark.sql("USE spark_test.default");
+            spark.sql("DROP DATABASE spark_test.chain_metadata");
+        }
+    }
+
+    private SparkSession createSparkSession(Path warehouse, boolean queryAuth) 
{
+        return SparkSession.builder()
+                .master("local[2]")
+                .config(
+                        "spark.sql.catalog.spark_test",
+                        (queryAuth ? RowFilterSparkCatalog.class : 
SparkCatalog.class).getName())
+                .config("spark.sql.catalog.spark_test.warehouse", 
warehouse.toUri().toString())
+                .config("spark.sql.defaultCatalog", "spark_test")
+                .config(
+                        "spark.sql.extensions",
+                        
"org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions")
+                .getOrCreate();
+    }
+
+    private FileStoreTable loadTable(SparkSession spark) throws Exception {
+        SparkCatalog catalog =
+                (SparkCatalog) 
spark.sessionState().catalogManager().currentCatalog();
+        SparkTable table =
+                (SparkTable) catalog.loadTable(Identifier.of(new String[] 
{"chain_metadata"}, "T"));
+        return (FileStoreTable) table.table();
+    }
+
+    private void assertQueryAuthChainSplits(SparkSession spark, String dt) 
throws Exception {
+        List<Split> splits =
+                loadTable(spark)
+                        .newReadBuilder()
+                        .withPartitionFilter(Collections.singletonMap("dt", 
dt))
+                        .newScan()
+                        .plan()
+                        .splits();
+        assertThat(splits)
+                .isNotEmpty()
+                .allSatisfy(
+                        split -> {
+                            
assertThat(split).isInstanceOf(FallbackSplit.class);
+                            Split wrapped = ((FallbackSplit) split).wrapped();
+                            
assertThat(wrapped).isInstanceOf(QueryAuthSplit.class);
+                            QueryAuthSplit authSplit = (QueryAuthSplit) 
wrapped;
+                            assertThat(authSplit.authResult()).isNotNull();
+                            
assertThat(authSplit.authResult().hasRules()).isTrue();
+                            
assertThat(authSplit.split()).isInstanceOf(ChainSplit.class);
+                        });
+    }
+
+    /** Supplies a row-filter policy while keeping the real filesystem catalog 
and table reads. */
+    public static class RowFilterSparkCatalog extends SparkCatalog {
+
+        @Override
+        protected Table loadSparkTable(Identifier identifier, Map<String, 
String> extraOptions)
+                throws NoSuchTableException {
+            SparkTable sparkTable = (SparkTable) 
super.loadSparkTable(identifier, extraOptions);
+            FileStoreTable table = (FileStoreTable) sparkTable.table();
+            return table.coreOptions().queryAuthEnabled()
+                    ? SparkTable$.MODULE$.of(withRowFilter(table))
+                    : sparkTable;
+        }
+    }
+
+    private static FileStoreTable withRowFilter(FileStoreTable table) {
+        Predicate rowFilter =
+                new PredicateBuilder(table.rowType()).equal(1, 
BinaryString.fromString("old"));
+        TableQueryAuthResult authResult =
+                new TableQueryAuthResult(
+                        
Collections.singletonList(JsonSerdeUtil.toFlatJson(rowFilter)), null);
+        CatalogEnvironment environment = table.catalogEnvironment();
+        CatalogLoader loader = environment.catalogLoader();
+        CatalogEnvironment authorizedEnvironment =
+                new CatalogEnvironment(
+                        environment.identifier(),
+                        environment.uuid(),
+                        authCatalogLoader(loader, authResult),
+                        environment.lockFactory(),
+                        environment.lockContext(),
+                        environment.catalogContext(),
+                        false,
+                        false);
+        return FileStoreTableFactory.create(
+                table.fileIO(),
+                table.location(),
+                table.schema(),
+                new Options(table.options()),
+                authorizedEnvironment);
+    }
+
+    private static CatalogLoader authCatalogLoader(
+            CatalogLoader loader, TableQueryAuthResult authResult) {
+        return () ->
+                new DelegateCatalog(loader.load()) {
+                    @Override
+                    public TableQueryAuthResult authTableQuery(
+                            org.apache.paimon.catalog.Identifier identifier, 
List<String> select) {
+                        return authResult;
+                    }
+
+                    @Override
+                    public CatalogLoader catalogLoader() {
+                        return authCatalogLoader(loader, authResult);
+                    }
+                };
+    }
+}

Reply via email to