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 113b31188b [fix] Apply row filter auth during scan planning (#8447)
113b31188b is described below

commit 113b31188b3adec8fac22e7d5cb98122006eb696
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Jul 4 09:32:12 2026 +0800

    [fix] Apply row filter auth during scan planning (#8447)
---
 .../paimon/catalog/TableQueryAuthResult.java       |  12 +-
 .../apache/paimon/operation/ManifestsReader.java   |  30 ++-
 .../paimon/table/source/AbstractDataTableScan.java |  38 ++++
 .../paimon/table/source/DataTableBatchScan.java    |  15 +-
 .../paimon/table/source/ReadBuilderImpl.java       | 112 ++++++++++-
 .../org/apache/paimon/rest/RESTCatalogTest.java    | 208 ++++++++++++++++++-
 .../org/apache/paimon/flink/RESTCatalogITCase.java |  43 ++++
 .../paimon/spark/SparkCatalogWithRestTest.java     | 220 +++++++++++++++++++++
 8 files changed, 666 insertions(+), 12 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/catalog/TableQueryAuthResult.java 
b/paimon-core/src/main/java/org/apache/paimon/catalog/TableQueryAuthResult.java
index b0bb9b1d52..0524eea1b0 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/catalog/TableQueryAuthResult.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/catalog/TableQueryAuthResult.java
@@ -106,6 +106,16 @@ public class TableQueryAuthResult implements Serializable {
         return rowFilter;
     }
 
+    /**
+     * Remap the auth predicate's field references by name to positional 
indices of {@code rowType}.
+     * The auth server sends field-id-based {@link 
org.apache.paimon.predicate.FieldRef}s, which
+     * must be resolved by name before positional evaluation.
+     */
+    @Nullable
+    public static Predicate remapPredicate(Predicate predicate, RowType 
rowType) {
+        return predicate.visit(new PredicateRemapper(rowType));
+    }
+
     public Map<String, Transform> extractColumnMasking() {
         Map<String, Transform> result = new TreeMap<>();
         if (columnMasking != null && !columnMasking.isEmpty()) {
@@ -129,7 +139,7 @@ public class TableQueryAuthResult implements Serializable {
             RecordReader<InternalRow> reader, RowType outputRowType) {
         Predicate rowFilter = extractPredicate();
         if (rowFilter != null) {
-            Predicate remappedFilter = rowFilter.visit(new 
PredicateRemapper(outputRowType));
+            Predicate remappedFilter = remapPredicate(rowFilter, 
outputRowType);
             if (remappedFilter != null) {
                 reader = reader.filter(remappedFilter::test);
             }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
index e5727cc0c8..46bbbd17f6 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestsReader.java
@@ -34,6 +34,7 @@ import org.apache.paimon.utils.SnapshotManager;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -54,6 +55,9 @@ public class ManifestsReader {
     @Nullable private Integer specifiedBucket = null;
     @Nullable private Integer specifiedLevel = null;
     @Nullable private PartitionPredicate partitionFilter = null;
+    // Auth partition filter (ANDed with partitionFilter); kept separate so it 
can be reset each
+    // plan without touching the base partitionFilter.
+    @Nullable private PartitionPredicate authPartitionFilter = null;
     @Nullable private BiFilter<Integer, Integer> levelMinMaxFilter = null;
     @Nullable protected RowRangeIndex rowRangeIndex = null;
 
@@ -108,6 +112,12 @@ public class ManifestsReader {
         return this;
     }
 
+    /** Overwrites the auth-derived partition filter; {@code null} clears it. 
*/
+    public ManifestsReader withAuthPartitionFilter(@Nullable 
PartitionPredicate predicate) {
+        this.authPartitionFilter = predicate;
+        return this;
+    }
+
     public ManifestsReader withRowRangeIndex(RowRangeIndex rowRangeIndex) {
         this.rowRangeIndex = rowRangeIndex;
         return this;
@@ -115,7 +125,13 @@ public class ManifestsReader {
 
     @Nullable
     public PartitionPredicate partitionFilter() {
-        return partitionFilter;
+        if (partitionFilter == null) {
+            return authPartitionFilter;
+        }
+        if (authPartitionFilter == null) {
+            return partitionFilter;
+        }
+        return PartitionPredicate.and(Arrays.asList(partitionFilter, 
authPartitionFilter));
     }
 
     public Result read(@Nullable Snapshot specifiedSnapshot, ScanMode 
scanMode) {
@@ -128,9 +144,12 @@ public class ManifestsReader {
             manifests = readManifests(snapshot, scanMode);
         }
 
+        // Compute the effective partition filter once (it ANDs the base and 
auth slots) instead of
+        // rebuilding it per manifest.
+        PartitionPredicate effectivePartitionFilter = partitionFilter();
         List<ManifestFileMeta> filtered =
                 manifests.stream()
-                        .filter(this::filterManifestFileMeta)
+                        .filter(m -> filterManifestFileMeta(m, 
effectivePartitionFilter))
                         .collect(Collectors.toList());
         return new Result(snapshot, manifests, filtered);
     }
@@ -163,7 +182,8 @@ public class ManifestsReader {
     }
 
     /** Note: Keep this thread-safe. */
-    private boolean filterManifestFileMeta(ManifestFileMeta manifest) {
+    private boolean filterManifestFileMeta(
+            ManifestFileMeta manifest, @Nullable PartitionPredicate 
effectivePartitionFilter) {
         Integer minBucket = manifest.minBucket();
         Integer maxBucket = manifest.maxBucket();
         if (minBucket != null && maxBucket != null) {
@@ -188,9 +208,9 @@ public class ManifestsReader {
             }
         }
 
-        if (partitionFilter != null) {
+        if (effectivePartitionFilter != null) {
             SimpleStats stats = manifest.partitionStats();
-            if (!partitionFilter.test(
+            if (!effectivePartitionFilter.test(
                     manifest.numAddedFiles() + manifest.numDeletedFiles(),
                     stats.minValues(),
                     stats.maxValues(),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
index adec8a9175..7bf5e68603 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/AbstractDataTableScan.java
@@ -69,6 +69,7 @@ import javax.annotation.Nullable;
 
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
 
@@ -88,6 +89,11 @@ abstract class AbstractDataTableScan implements 
DataTableScan {
     private final TableQueryAuth queryAuth;
 
     @Nullable private RowType readType;
+    // Last applied auth predicate; guards redundant re-application across 
plan()s.
+    @Nullable private Predicate appliedAuthPredicate;
+    // Whether the auth predicate has a non-partition part (enforced only at 
read time). Used by
+    // DataTableBatchScan to disable limit push down; not pushed through 
withFilter.
+    protected boolean authHasNonPartitionFilter;
 
     protected AbstractDataTableScan(
             TableSchema schema,
@@ -103,6 +109,8 @@ abstract class AbstractDataTableScan implements 
DataTableScan {
     @Override
     public final TableScan.Plan plan() {
         TableQueryAuthResult queryAuthResult = authQuery();
+        // Always apply/clear the auth filter so removing auth leaves no stale 
partition pruning.
+        applyAuthFilter(queryAuthResult == null ? null : 
queryAuthResult.extractPredicate());
         Plan plan = planWithoutAuth();
         if (queryAuthResult != null) {
             plan = queryAuthResult.convertPlan(plan);
@@ -112,6 +120,36 @@ abstract class AbstractDataTableScan implements 
DataTableScan {
 
     protected abstract TableScan.Plan planWithoutAuth();
 
+    private void applyAuthFilter(@Nullable Predicate authPredicate) {
+        if (Objects.equals(authPredicate, appliedAuthPredicate)) {
+            return;
+        }
+        appliedAuthPredicate = authPredicate;
+
+        PartitionPredicate authPartitionFilter = null;
+        boolean hasNonPartitionPart = false;
+        if (authPredicate != null) {
+            // Remap field-id FieldRefs to positional indices by name (as 
doAuth does on read), so
+            // pruning stays correct across schema evolution.
+            Predicate remappedAuth =
+                    TableQueryAuthResult.remapPredicate(authPredicate, 
schema.logicalRowType());
+            if (remappedAuth != null) {
+                Pair<Optional<PartitionPredicate>, List<Predicate>> split =
+                        
PartitionPredicate.splitPartitionPredicatesAndDataPredicates(
+                                remappedAuth, schema.logicalRowType(), 
schema.partitionKeys());
+                authPartitionFilter = split.getLeft().orElse(null);
+                hasNonPartitionPart = !split.getRight().isEmpty();
+            }
+        }
+
+        // Push only the partition part, to a dedicated slot 
overwritten/cleared each plan() so a
+        // changed/removed auth leaves no stale pruning. The full filter is 
enforced at read time.
+        
snapshotReader.manifestsReader().withAuthPartitionFilter(authPartitionFilter);
+        // A non-partition auth part is enforced only at read time, so limit 
push down is unsafe
+        // (DataTableBatchScan reads this). Kept off SnapshotReader since it 
is not a pushed filter.
+        this.authHasNonPartitionFilter = hasNonPartitionPart;
+    }
+
     @Override
     public InnerTableScan withFilter(Predicate predicate) {
         snapshotReader.withFilter(predicate);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableBatchScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableBatchScan.java
index 5293fad4ff..7d528db6c3 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableBatchScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataTableBatchScan.java
@@ -97,8 +97,8 @@ public class DataTableBatchScan extends AbstractDataTableScan 
{
 
     @Override
     public InnerTableScan withLimit(int limit) {
+        // Record it; applyPushDownLimit pushes the file-store limit only when 
safe.
         this.pushDownLimit = limit;
-        snapshotReader.withLimit(limit);
         return this;
     }
 
@@ -144,9 +144,14 @@ public class DataTableBatchScan extends 
AbstractDataTableScan {
     }
 
     private Optional<StartingScanner.Result> applyPushDownLimit() {
-        if (pushDownLimit == null || snapshotReader.hasNonPartitionFilter()) {
+        // A read-time filter (WHERE or auth) drops rows after scanning, so 
only push the limit down
+        // when neither is present.
+        if (pushDownLimit == null
+                || snapshotReader.hasNonPartitionFilter()
+                || authHasNonPartitionFilter) {
             return Optional.empty();
         }
+        snapshotReader.withLimit(pushDownLimit);
 
         StartingScanner.Result result = startingScanner.scan(snapshotReader);
         if (!(result instanceof ScannedResult)) {
@@ -183,7 +188,11 @@ public class DataTableBatchScan extends 
AbstractDataTableScan {
     }
 
     private Optional<StartingScanner.Result> applyPushDownTopN() {
-        if (topN == null || pushDownLimit != null || 
!schema.primaryKeys().isEmpty()) {
+        // Auth drops rows at read time, so split-level TopN pruning could 
drop authorized rows.
+        if (topN == null
+                || pushDownLimit != null
+                || authHasNonPartitionFilter
+                || !schema.primaryKeys().isEmpty()) {
             return Optional.empty();
         }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
index 553a848749..6dbc364002 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/ReadBuilderImpl.java
@@ -19,10 +19,15 @@
 package org.apache.paimon.table.source;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.metrics.MetricRegistry;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.predicate.TopN;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.RecordReader.RecordIterator;
 import org.apache.paimon.table.InnerTable;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Filter;
@@ -31,6 +36,7 @@ import org.apache.paimon.utils.RowRangeIndex;
 
 import javax.annotation.Nullable;
 
+import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -47,6 +53,7 @@ public class ReadBuilderImpl implements ReadBuilder {
     private final InnerTable table;
     private final RowType partitionType;
     private final String defaultPartitionName;
+    private final boolean queryAuthEnabled;
 
     private Predicate filter;
 
@@ -69,7 +76,9 @@ public class ReadBuilderImpl implements ReadBuilder {
     public ReadBuilderImpl(InnerTable table) {
         this.table = table;
         this.partitionType = table.rowType().project(table.partitionKeys());
-        this.defaultPartitionName = new 
CoreOptions(table.options()).partitionDefaultName();
+        CoreOptions options = new CoreOptions(table.options());
+        this.defaultPartitionName = options.partitionDefaultName();
+        this.queryAuthEnabled = options.queryAuthEnabled();
     }
 
     @Override
@@ -234,6 +243,14 @@ public class ReadBuilderImpl implements ReadBuilder {
         if (readType != null) {
             read.withReadType(readType);
         }
+        if (queryAuthEnabled) {
+            // Skip TopN (engine re-applies it); apply the limit after auth 
only without a TopN,
+            // else an unordered limit could drop sorted rows.
+            if (topN == null && limit != null) {
+                return new LimitTableRead(read, limit);
+            }
+            return read;
+        }
         if (topN != null) {
             read.withTopN(topN);
         }
@@ -264,4 +281,97 @@ public class ReadBuilderImpl implements ReadBuilder {
         result = 31 * result + Objects.hash(readType);
         return result;
     }
+
+    /**
+     * Limits the delegate's output to {@code limit} rows. Used under query 
auth to apply the limit
+     * after read-time auth filtering, not before (which could drop authorized 
rows).
+     */
+    private static class LimitTableRead implements TableRead {
+
+        private final TableRead delegate;
+        private final int limit;
+
+        private LimitTableRead(TableRead delegate, int limit) {
+            this.delegate = delegate;
+            this.limit = limit;
+        }
+
+        @Override
+        public TableRead withMetricRegistry(MetricRegistry registry) {
+            delegate.withMetricRegistry(registry);
+            return this;
+        }
+
+        @Override
+        public TableRead executeFilter() {
+            delegate.executeFilter();
+            return this;
+        }
+
+        @Override
+        public TableRead withIOManager(IOManager ioManager) {
+            delegate.withIOManager(ioManager);
+            return this;
+        }
+
+        @Override
+        public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
+            return limit(delegate.createReader(split));
+        }
+
+        @Override
+        public RecordReader<InternalRow> createReader(List<Split> splits) 
throws IOException {
+            // Limit the merged reader so the cap is global, not per split.
+            return limit(delegate.createReader(splits));
+        }
+
+        @Override
+        public RecordReader<InternalRow> createReader(TableScan.Plan plan) 
throws IOException {
+            return limit(delegate.createReader(plan));
+        }
+
+        private RecordReader<InternalRow> limit(RecordReader<InternalRow> 
reader) {
+            // Stop reading once the limit is reached (return EOF), rather 
than filtering and
+            // draining the rest of the data.
+            return new RecordReader<InternalRow>() {
+                private int count;
+
+                @Nullable
+                @Override
+                public RecordIterator<InternalRow> readBatch() throws 
IOException {
+                    if (count >= limit) {
+                        return null;
+                    }
+                    RecordIterator<InternalRow> batch = reader.readBatch();
+                    if (batch == null) {
+                        return null;
+                    }
+                    return new RecordIterator<InternalRow>() {
+                        @Nullable
+                        @Override
+                        public InternalRow next() throws IOException {
+                            if (count >= limit) {
+                                return null;
+                            }
+                            InternalRow row = batch.next();
+                            if (row != null) {
+                                count++;
+                            }
+                            return row;
+                        }
+
+                        @Override
+                        public void releaseBatch() {
+                            batch.releaseBatch();
+                        }
+                    };
+                }
+
+                @Override
+                public void close() throws IOException {
+                    reader.close();
+                }
+            };
+        }
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
index 818b6e301c..4cac0d13c9 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
@@ -59,6 +59,7 @@ import org.apache.paimon.predicate.GreaterThan;
 import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.TopN;
 import org.apache.paimon.predicate.Transform;
 import org.apache.paimon.predicate.UpperTransform;
 import org.apache.paimon.reader.RecordReader;
@@ -83,6 +84,7 @@ import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.sink.StreamTableCommit;
 import org.apache.paimon.table.sink.StreamTableWrite;
 import org.apache.paimon.table.sink.TableWriteImpl;
+import org.apache.paimon.table.source.InnerTableScan;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.table.source.TableRead;
@@ -138,6 +140,8 @@ import static org.apache.paimon.CoreOptions.TYPE;
 import static org.apache.paimon.TableType.OBJECT_TABLE;
 import static org.apache.paimon.catalog.Catalog.SYSTEM_DATABASE_NAME;
 import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
+import static org.apache.paimon.predicate.SortValue.NullOrdering.NULLS_LAST;
+import static org.apache.paimon.predicate.SortValue.SortDirection.DESCENDING;
 import static org.apache.paimon.rest.RESTApi.PAGE_TOKEN;
 import static org.apache.paimon.rest.RESTCatalogOptions.DLF_OSS_ENDPOINT;
 import static org.apache.paimon.rest.RESTCatalogOptions.IO_CACHE_ENABLED;
@@ -3898,6 +3902,169 @@ public abstract class RESTCatalogTest extends 
CatalogTestBase {
         assertThat(rows.get(0).getString(1).toString()).isIn("Alice", "Bob", 
"Charlie", "David");
     }
 
+    @Test
+    void testRowFilterWithLimitReadsPastUnauthorizedFiles() throws Exception {
+        Identifier identifier = Identifier.create("test_table_db", 
"auth_table_limit");
+        catalog.createDatabase(identifier.getDatabaseName(), true);
+
+        List<DataField> fields = new ArrayList<>();
+        fields.add(new DataField(0, "id", DataTypes.INT()));
+        fields.add(new DataField(1, "secret", DataTypes.INT()));
+        catalog.createTable(
+                identifier,
+                new Schema(
+                        fields,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonMap(QUERY_AUTH_ENABLED.key(), 
"true"),
+                        ""),
+                true);
+
+        Table table = catalog.getTable(identifier);
+
+        // Two files, each with an authorized (secret=1) and an unauthorized 
(secret=0) row.
+        commitRows(table, GenericRow.of(1, 1), GenericRow.of(2, 0));
+        commitRows(table, GenericRow.of(3, 1), GenericRow.of(4, 0));
+
+        // secret is a data column, so the filter is enforced at read time, 
not pushed to the store.
+        LeafPredicate secretFilter =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(1, "secret", 
DataTypes.INT())),
+                        Equal.INSTANCE,
+                        Collections.singletonList(1));
+        setRowFilter(identifier, Collections.singletonList(secretFilter));
+
+        // The file-store limit must be disabled under the auth filter, else 
the scan stops at the
+        // first file and misses the authorized row in the second.
+        List<String> result = batchReadWithLimit(table, 1);
+        assertThat(result).contains("+I[1, 1]", "+I[3, 1]");
+    }
+
+    @Test
+    void testRowFilterReadBuilderLimitAfterAuth() throws Exception {
+        Identifier identifier = Identifier.create("test_table_db", 
"auth_table_read_limit");
+        catalog.createDatabase(identifier.getDatabaseName(), true);
+
+        List<DataField> fields = new ArrayList<>();
+        fields.add(new DataField(0, "id", DataTypes.INT()));
+        fields.add(new DataField(1, "v", DataTypes.INT()));
+        catalog.createTable(
+                identifier,
+                new Schema(
+                        fields,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonMap(QUERY_AUTH_ENABLED.key(), 
"true"),
+                        ""),
+                true);
+
+        Table table = catalog.getTable(identifier);
+
+        // Rows 1,2 unauthorized and come first; rows 3,4 authorized.
+        commitRows(
+                table,
+                GenericRow.of(1, 10),
+                GenericRow.of(2, 20),
+                GenericRow.of(3, 30),
+                GenericRow.of(4, 40));
+
+        LeafPredicate idGt2 =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(0, "id", 
DataTypes.INT())),
+                        GreaterThan.INSTANCE,
+                        Collections.singletonList(2));
+        setRowFilter(identifier, Collections.singletonList(idGt2));
+
+        // withLimit(1) must return exactly one authorized row: skipping the 
limit returns 2,
+        // applying it before auth returns 0.
+        List<String> result = batchReadWithReadLimit(table, 1);
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0)).isIn("+I[3, 30]", "+I[4, 40]");
+    }
+
+    @Test
+    void testRowFilterReadLimitSkippedWithTopN() throws Exception {
+        Identifier identifier = Identifier.create("test_table_db", 
"auth_table_read_limit_topn");
+        catalog.createDatabase(identifier.getDatabaseName(), true);
+
+        List<DataField> fields = new ArrayList<>();
+        fields.add(new DataField(0, "id", DataTypes.INT()));
+        fields.add(new DataField(1, "score", DataTypes.INT()));
+        catalog.createTable(
+                identifier,
+                new Schema(
+                        fields,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonMap(QUERY_AUTH_ENABLED.key(), 
"true"),
+                        ""),
+                true);
+
+        Table table = catalog.getTable(identifier);
+
+        // Rows 1,2 unauthorized; 3,4 authorized. The highest-sorted 
authorized row (id=4) is last.
+        commitRows(
+                table,
+                GenericRow.of(1, 10),
+                GenericRow.of(2, 20),
+                GenericRow.of(3, 30),
+                GenericRow.of(4, 40));
+
+        LeafPredicate idGt2 =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(0, "id", 
DataTypes.INT())),
+                        GreaterThan.INSTANCE,
+                        Collections.singletonList(2));
+        setRowFilter(identifier, Collections.singletonList(idGt2));
+
+        // With a TopN pushed, the read-side limit must be skipped so all 
authorized rows reach the
+        // engine's ORDER BY; else id=4 (last in scan order) gets truncated.
+        TopN topN = new TopN(new FieldRef(1, "score", DataTypes.INT()), 
DESCENDING, NULLS_LAST, 1);
+        ReadBuilder readBuilder = 
table.newReadBuilder().withTopN(topN).withLimit(1);
+        List<String> result = batchRead(table, 
readBuilder.newScan().plan().splits(), readBuilder);
+        assertThat(result).containsExactlyInAnyOrder("+I[3, 30]", "+I[4, 40]");
+    }
+
+    @Test
+    void testRowFilterWithTopNKeepsAuthorizedSplits() throws Exception {
+        Identifier identifier = Identifier.create("test_table_db", 
"auth_table_topn");
+        catalog.createDatabase(identifier.getDatabaseName(), true);
+
+        // Partitioned so each row is its own split; TopN pruning works at 
split granularity.
+        List<DataField> fields = new ArrayList<>();
+        fields.add(new DataField(0, "pt", DataTypes.INT()));
+        fields.add(new DataField(1, "score", DataTypes.INT()));
+        fields.add(new DataField(2, "secret", DataTypes.INT()));
+        catalog.createTable(
+                identifier,
+                new Schema(
+                        fields,
+                        Collections.singletonList("pt"),
+                        Collections.emptyList(),
+                        Collections.singletonMap(QUERY_AUTH_ENABLED.key(), 
"true"),
+                        ""),
+                true);
+
+        Table table = catalog.getTable(identifier);
+
+        // The split with the highest score is unauthorized; the authorized 
row has a lower score.
+        commitRows(table, GenericRow.of(1, 100, 0));
+        commitRows(table, GenericRow.of(2, 50, 1));
+
+        LeafPredicate secretFilter =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "secret", 
DataTypes.INT())),
+                        Equal.INSTANCE,
+                        Collections.singletonList(1));
+        setRowFilter(identifier, Collections.singletonList(secretFilter));
+
+        // ORDER BY score DESC LIMIT 1: TopN split pruning must be disabled 
under the auth filter,
+        // else it keeps the top unauthorized split and drops the authorized 
row.
+        TopN topN = new TopN(new FieldRef(1, "score", DataTypes.INT()), 
DESCENDING, NULLS_LAST, 1);
+        List<String> result = batchReadWithTopN(table, topN);
+        assertThat(result).contains("+I[2, 50, 1]");
+    }
+
     @Test
     public void testConflictRollback() throws Exception {
         doTestConflictRollback(false);
@@ -4067,9 +4234,46 @@ public abstract class RESTCatalogTest extends 
CatalogTestBase {
         commit.close();
     }
 
+    protected void commitRows(Table table, GenericRow... rows) throws 
Exception {
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        BatchTableWrite write = writeBuilder.newWrite();
+        for (GenericRow row : rows) {
+            write.write(row);
+        }
+        BatchTableCommit commit = writeBuilder.newCommit();
+        commit.commit(write.prepareCommit());
+        write.close();
+        commit.close();
+    }
+
     protected List<String> batchRead(Table table) throws IOException {
-        ReadBuilder readBuilder = table.newReadBuilder();
-        List<Split> splits = readBuilder.newScan().plan().splits();
+        return batchRead(table, 
table.newReadBuilder().newScan().plan().splits());
+    }
+
+    protected List<String> batchReadWithLimit(Table table, int limit) throws 
IOException {
+        // Limit only the scan (file pruning), not the read, so the result 
reflects the kept files.
+        InnerTableScan scan = (InnerTableScan) 
table.newReadBuilder().newScan();
+        return batchRead(table, scan.withLimit(limit).plan().splits());
+    }
+
+    protected List<String> batchReadWithTopN(Table table, TopN topN) throws 
IOException {
+        // Apply TopN only on the scan (split pruning), so the result reflects 
the kept splits.
+        InnerTableScan scan = (InnerTableScan) 
table.newReadBuilder().newScan();
+        return batchRead(table, scan.withTopN(topN).plan().splits());
+    }
+
+    protected List<String> batchReadWithReadLimit(Table table, int limit) 
throws IOException {
+        // Exercises the ReadBuilder.withLimit path, which limits both the 
scan and the read side.
+        ReadBuilder readBuilder = table.newReadBuilder().withLimit(limit);
+        return batchRead(table, readBuilder.newScan().plan().splits(), 
readBuilder);
+    }
+
+    private List<String> batchRead(Table table, List<Split> splits) throws 
IOException {
+        return batchRead(table, splits, table.newReadBuilder());
+    }
+
+    private List<String> batchRead(Table table, List<Split> splits, 
ReadBuilder readBuilder)
+            throws IOException {
         TableRead read = readBuilder.newRead();
         RecordReader<InternalRow> reader = read.createReader(splits);
         List<String> result = new ArrayList<>();
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/RESTCatalogITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/RESTCatalogITCase.java
index de93679e1a..5be454a9d0 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/RESTCatalogITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/RESTCatalogITCase.java
@@ -472,6 +472,49 @@ class RESTCatalogITCase extends RESTCatalogITCaseBase {
                                         DATABASE_NAME, filterTable)))
                 .containsExactlyInAnyOrder(Row.of(3, "Charlie", 35, "IT"));
 
+        String partitionFilterTable = "partition_row_filter_table";
+        batchSql(
+                String.format(
+                        "CREATE TABLE %s.%s (id INT, name STRING, dtpart 
STRING) PARTITIONED BY (dtpart) WITH ('query-auth.enabled' = 'true')",
+                        DATABASE_NAME, partitionFilterTable));
+        batchSql(
+                String.format(
+                        "INSERT INTO %s.%s VALUES (1, 'blocked', 
'2026-07-03'), (2, 'allowed', '2026-07-02')",
+                        DATABASE_NAME, partitionFilterTable));
+        Predicate partitionPredicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "dtpart", 
DataTypes.STRING())),
+                        Equal.INSTANCE,
+                        
Collections.singletonList(BinaryString.fromString("2026-07-02")));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create(DATABASE_NAME, partitionFilterTable),
+                Collections.singletonList(partitionPredicate));
+
+        assertThat(
+                        batchSql(
+                                String.format(
+                                        "SELECT * FROM %s.%s WHERE dtpart = 
'2026-07-02'",
+                                        DATABASE_NAME, partitionFilterTable)))
+                .containsExactly(Row.of(2, "allowed", "2026-07-02"));
+        assertThat(
+                        batchSql(
+                                String.format(
+                                        "SELECT * FROM %s.%s",
+                                        DATABASE_NAME, partitionFilterTable)))
+                .containsExactly(Row.of(2, "allowed", "2026-07-02"));
+        assertThat(
+                        batchSql(
+                                String.format(
+                                        "SELECT id, dtpart FROM %s.%s LIMIT 1",
+                                        DATABASE_NAME, partitionFilterTable)))
+                .containsExactly(Row.of(2, "2026-07-02"));
+        assertThat(
+                        batchSql(
+                                String.format(
+                                        "SELECT id FROM %s.%s WHERE dtpart = 
'2026-07-03'",
+                                        DATABASE_NAME, partitionFilterTable)))
+                .isEmpty();
+
         // Test JOIN with row filter
         String joinTable = "join_table";
         batchSql(
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkCatalogWithRestTest.java
 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkCatalogWithRestTest.java
index 4a8f43e61b..fcde06202e 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkCatalogWithRestTest.java
+++ 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/SparkCatalogWithRestTest.java
@@ -33,6 +33,7 @@ import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.predicate.FieldTransform;
 import org.apache.paimon.predicate.GreaterOrEqual;
 import org.apache.paimon.predicate.GreaterThan;
+import org.apache.paimon.predicate.IsNull;
 import org.apache.paimon.predicate.LeafPredicate;
 import org.apache.paimon.predicate.LessThan;
 import org.apache.paimon.predicate.Predicate;
@@ -443,6 +444,70 @@ public class SparkCatalogWithRestTest {
                                 .toString())
                 .isEqualTo("[[3,Charlie,35,IT]]");
 
+        spark.sql(
+                "CREATE TABLE t_partition_row_filter (id INT, name STRING, 
dtpart STRING) PARTITIONED BY (dtpart)"
+                        + " TBLPROPERTIES ('query-auth.enabled'='true')");
+        spark.sql(
+                "INSERT INTO t_partition_row_filter VALUES (1, 'blocked', 
'2026-07-03'), (2, 'allowed', '2026-07-02')");
+        Predicate partitionPredicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "dtpart", 
DataTypes.STRING())),
+                        Equal.INSTANCE,
+                        
Collections.singletonList(BinaryString.fromString("2026-07-02")));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_partition_row_filter"),
+                Collections.singletonList(partitionPredicate));
+
+        assertThat(
+                        spark.sql(
+                                        "SELECT * FROM t_partition_row_filter 
WHERE dtpart = '2026-07-02'")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2,allowed,2026-07-02]]");
+        assertThat(spark.sql("SELECT * FROM 
t_partition_row_filter").collectAsList().toString())
+                .isEqualTo("[[2,allowed,2026-07-02]]");
+        assertThat(
+                        spark.sql("SELECT id, dtpart FROM 
t_partition_row_filter LIMIT 1")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2,2026-07-02]]");
+        assertThat(
+                        spark.sql(
+                                        "SELECT id FROM t_partition_row_filter 
WHERE dtpart = '2026-07-03'")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[]");
+
+        // After DROP COLUMN, a partition key's field id no longer equals its 
position; pruning must
+        // remap by name, else it mis-projects onto p2 and drops visible rows.
+        spark.sql(
+                "CREATE TABLE t_evolved_row_filter (a INT, b STRING, p1 
STRING, p2 STRING)"
+                        + " PARTITIONED BY (p1, p2) TBLPROPERTIES 
('query-auth.enabled'='true')");
+        spark.sql("ALTER TABLE t_evolved_row_filter DROP COLUMN a");
+        spark.sql(
+                "INSERT INTO t_evolved_row_filter VALUES ('x', 'keep', 'A'), 
('y', 'keep', 'B'),"
+                        + " ('z', 'drop', 'A')");
+        // p1 keeps field id 2 though it now sits at position 1 after the drop.
+        Predicate evolvedPredicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "p1", 
DataTypes.STRING())),
+                        Equal.INSTANCE,
+                        
Collections.singletonList(BinaryString.fromString("keep")));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_evolved_row_filter"),
+                Collections.singletonList(evolvedPredicate));
+
+        assertThat(
+                        spark.sql("SELECT b FROM t_evolved_row_filter ORDER BY 
b")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[x], [y]]");
+        assertThat(
+                        spark.sql("SELECT b FROM t_evolved_row_filter WHERE p2 
= 'B'")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[y]]");
+
         // Test JOIN with row filter
         spark.sql("CREATE TABLE t_join2 (id INT, salary DOUBLE)");
         spark.sql(
@@ -541,6 +606,161 @@ public class SparkCatalogWithRestTest {
                 .isEqualTo("[[4]]");
     }
 
+    @Test
+    public void testRowFilterPrimaryKeyTable() {
+        // Row filter on a PK table must respect merge-on-read: id=2 survives 
on its merged value,
+        // not dropped on the stale file value.
+        spark.sql(
+                "CREATE TABLE t_pk_row_filter (id INT, name STRING, age INT) 
TBLPROPERTIES"
+                        + " ('primary-key'='id', 'bucket'='2', 
'query-auth.enabled'='true')");
+        spark.sql(
+                "INSERT INTO t_pk_row_filter VALUES (1, 'Alice', 25), (2, 
'Bob', 30), (3, 'Charlie', 35)");
+        // Later snapshot updates id=2 from age 30 to 40.
+        spark.sql("INSERT INTO t_pk_row_filter VALUES (2, 'Bob', 40)");
+
+        // Filter on a value (non-key) column across the update.
+        Predicate ageGt30Predicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "age", 
DataTypes.INT())),
+                        GreaterThan.INSTANCE,
+                        Collections.singletonList(30));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_pk_row_filter"),
+                Collections.singletonList(ageGt30Predicate));
+        assertThat(
+                        spark.sql("SELECT * FROM t_pk_row_filter ORDER BY id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2,Bob,40], [3,Charlie,35]]");
+
+        // Filter on the primary-key column.
+        Predicate idGe2Predicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(0, "id", 
DataTypes.INT())),
+                        GreaterOrEqual.INSTANCE,
+                        Collections.singletonList(2));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_pk_row_filter"),
+                Collections.singletonList(idGe2Predicate));
+        assertThat(
+                        spark.sql("SELECT * FROM t_pk_row_filter ORDER BY id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2,Bob,40], [3,Charlie,35]]");
+    }
+
+    @Test
+    public void testRowFilterNullAndDefaultPartition() {
+        // IS NULL row filter on a regular column returns only rows whose 
value is null.
+        spark.sql(
+                "CREATE TABLE t_null_row_filter (id INT, grade STRING) 
TBLPROPERTIES"
+                        + " ('query-auth.enabled'='true')");
+        spark.sql(
+                "INSERT INTO t_null_row_filter VALUES (1, 'A'), (2, CAST(NULL 
AS STRING)), (3, 'B'),"
+                        + " (4, CAST(NULL AS STRING))");
+        Predicate gradeIsNull =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(1, "grade", 
DataTypes.STRING())),
+                        IsNull.INSTANCE,
+                        Collections.emptyList());
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_null_row_filter"),
+                Collections.singletonList(gradeIsNull));
+        assertThat(
+                        spark.sql("SELECT id FROM t_null_row_filter ORDER BY 
id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2], [4]]");
+
+        // Partition column with NULL values goes to the default partition.
+        spark.sql(
+                "CREATE TABLE t_null_partition_filter (id INT, dtpart STRING) 
PARTITIONED BY (dtpart)"
+                        + " TBLPROPERTIES ('query-auth.enabled'='true')");
+        spark.sql(
+                "INSERT INTO t_null_partition_filter VALUES (1, '2026-07-02'),"
+                        + " (2, CAST(NULL AS STRING)), (3, '2026-07-03'), (4, 
CAST(NULL AS STRING))");
+
+        // Equality on the partition column must exclude the NULL (default) 
partition.
+        Predicate dtpartEq =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(1, "dtpart", 
DataTypes.STRING())),
+                        Equal.INSTANCE,
+                        
Collections.singletonList(BinaryString.fromString("2026-07-02")));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_null_partition_filter"),
+                Collections.singletonList(dtpartEq));
+        assertThat(
+                        spark.sql("SELECT id FROM t_null_partition_filter 
ORDER BY id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[1]]");
+
+        // IS NULL on the partition column must keep only the default (NULL) 
partition.
+        Predicate dtpartIsNull =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(1, "dtpart", 
DataTypes.STRING())),
+                        IsNull.INSTANCE,
+                        Collections.emptyList());
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_null_partition_filter"),
+                Collections.singletonList(dtpartIsNull));
+        assertThat(
+                        spark.sql("SELECT id FROM t_null_partition_filter 
ORDER BY id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[2], [4]]");
+    }
+
+    @Test
+    public void testRowFilterBucketKeyTable() {
+        // Row filter on the bucket-key column flows through bucket selection; 
must stay correct.
+        spark.sql(
+                "CREATE TABLE t_bucket_row_filter (id INT, name STRING) 
TBLPROPERTIES"
+                        + " ('bucket'='4', 'bucket-key'='id', 
'query-auth.enabled'='true')");
+        spark.sql("INSERT INTO t_bucket_row_filter VALUES (1, 'a'), (2, 'b'), 
(3, 'c'), (4, 'd')");
+        Predicate idEq3Predicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(0, "id", 
DataTypes.INT())),
+                        Equal.INSTANCE,
+                        Collections.singletonList(3));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_bucket_row_filter"),
+                Collections.singletonList(idEq3Predicate));
+        assertThat(
+                        spark.sql("SELECT * FROM t_bucket_row_filter ORDER BY 
id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[3,c]]");
+    }
+
+    @Test
+    public void testRowFilterDeletionVectorsTable() {
+        // Deletion-vectors table: deleted rows excluded via the deletion 
vector, then filtered.
+        spark.sql(
+                "CREATE TABLE t_dv_row_filter (id INT, name STRING, age INT) 
TBLPROPERTIES"
+                        + " ('primary-key'='id', 'bucket'='2', 
'deletion-vectors.enabled'='true',"
+                        + " 'query-auth.enabled'='true')");
+        spark.sql(
+                "INSERT INTO t_dv_row_filter VALUES (1, 'Alice', 25), (2, 
'Bob', 30),"
+                        + " (3, 'Charlie', 35), (4, 'David', 45)");
+        // Delete id=4 (writes a deletion vector). id=4 passes the age>=35 
filter, so if the
+        // deletion vector were ignored it would resurface in the result and 
fail the test.
+        spark.sql("DELETE FROM t_dv_row_filter WHERE id = 4");
+        Predicate ageGe35Predicate =
+                LeafPredicate.of(
+                        new FieldTransform(new FieldRef(2, "age", 
DataTypes.INT())),
+                        GreaterOrEqual.INSTANCE,
+                        Collections.singletonList(35));
+        restCatalogServer.setRowFilterAuth(
+                Identifier.create("db2", "t_dv_row_filter"),
+                Collections.singletonList(ageGe35Predicate));
+        assertThat(
+                        spark.sql("SELECT * FROM t_dv_row_filter ORDER BY id")
+                                .collectAsList()
+                                .toString())
+                .isEqualTo("[[3,Charlie,35]]");
+    }
+
     @Test
     public void testColumnMaskingAndRowFilter() {
         spark.sql(


Reply via email to