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 f8cbb66c45 [python] Use native partition selectors and preserve row 
filters (#10576)
f8cbb66c45 is described below

commit f8cbb66c45c97ac056171798088b3e1a04d5937f
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 13:49:41 2026 +0800

    [python] Use native partition selectors and preserve row filters (#10576)
---
 .github/workflows/ci-python.yml                    |   2 +
 paimon-python/pypaimon/read/native_plan.py         |   5 +-
 paimon-python/pypaimon/read/read_builder.py        |  11 +-
 .../pypaimon/read/scanner/file_scanner.py          |   6 +-
 paimon-python/pypaimon/read/split_read.py          |   3 +
 paimon-python/pypaimon/read/table_scan.py          |  22 +--
 .../pypaimon/tests/native_partition_filter_test.py | 197 +++++++++++++++++++++
 .../pypaimon/tests/partition_predicate_test.py     |  22 +++
 .../pypaimon/tests/reader_split_generator_test.py  |  17 +-
 9 files changed, 268 insertions(+), 17 deletions(-)

diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 797bed8bf6..c34c60d745 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -220,6 +220,8 @@ jobs:
               normalize_array_for_write,
               read_arrow_schema,
           )
+          from pypaimon_rust.datafusion import ReadBuilder
+          assert callable(getattr(ReadBuilder, 'with_partition_filter', None))
           PY
 
       - name: Run Python tests with native planning, reading, writing, 
updating and committing
diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index a024b44c41..315096fe35 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -451,7 +451,8 @@ def native_plan(
         row_position_slice: Optional[Tuple[int, int]] = None,
         row_position_shard: Optional[Tuple[int, int]] = None,
         chunk_shuffle: Optional[Tuple[int, int]] = None,
-        shard: Optional[Tuple[int, int]] = None) -> Plan:
+        shard: Optional[Tuple[int, int]] = None,
+        partition_predicate: Optional[Predicate] = None) -> Plan:
     """Plan with pypaimon_rust, preserving snapshot metadata.
 
     Native conversion or planning failures are handled by TableScan, which
@@ -464,6 +465,8 @@ def native_plan(
             "scan.native-plan.enabled needs pypaimon-rust>=0.3.0 (split 
planning API)")
     builder = _configure_native_read_builder(
         _native_read_builder(table), predicate, limit, projection)
+    if partition_predicate is not None:
+        builder = 
builder.with_partition_filter(_predicate_to_native(partition_predicate))
     if row_ranges is not None:
         builder = builder.with_row_ranges(row_ranges)
     if incremental_range is None:
diff --git a/paimon-python/pypaimon/read/read_builder.py 
b/paimon-python/pypaimon/read/read_builder.py
index 6bf52e773c..f990d7ded6 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -115,7 +115,16 @@ class ReadBuilder:
         self._predicate = predicate
         return self
 
-    def with_partition_filter(self, partition_filter: Predicate) -> 
'ReadBuilder':
+    def with_partition_filter(self, partition_filter: Optional[Predicate]) -> 
'ReadBuilder':
+        """Replace scan-only partition selection; None restores derived 
pruning.
+
+        As with Java's PartitionPredicate, only partition fields are allowed.
+        Ordinary row predicates remain independent and are checked by the 
reader.
+        """
+        if partition_filter is not None and (
+                not self.table.partition_keys
+                or not 
predicate_field_names(partition_filter).issubset(self.table.partition_keys)):
+            raise ValueError('Partition filter must reference only partition 
keys of a partitioned table')
         self._partition_filter = partition_filter
         return self
 
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index 7a60208a41..b938a7f7d2 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -231,6 +231,7 @@ class FileScanner:
         self.table: FileStoreTable = table
         self.manifest_scanner = manifest_scanner
         self.predicate = predicate
+        self._explicit_partition_filter = partition_predicate is not None
         row_ranges = (
             _row_ranges_from_predicate(predicate) if predicate else None
         )
@@ -749,7 +750,10 @@ class FileScanner:
             return splits
         if self.data_evolution and self.deletion_vectors_enabled:
             return splits
-        if self._has_non_partition_filter() or 
self.auth_has_non_partition_filter:
+        if (self._has_non_partition_filter() or 
self.auth_has_non_partition_filter
+                # An explicit selector replaces ordinary partition pruning.
+                # Count rows only after the reader has checked the full filter.
+                or (self._explicit_partition_filter and self.predicate is not 
None)):
             return splits
 
         scanned_row_count = 0
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index e6faeba3f1..e23db8092d 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -1092,6 +1092,9 @@ class RawFileSplitRead(SplitRead):
         if (self.predicate_for_reader
                 and (self.table.is_primary_key_table
                      or not self._arrow_filter_pushdown_enabled
+                     # Partition values are injected after the physical read;
+                     # their predicates cannot have been evaluated by Parquet.
+                     or 
predicate_field_names(self.predicate).intersection(self.table.partition_keys)
                      or self.deletion_file_readers
                      or self._physical_row_ranges
                      or any(file.schema_id != self.table.table_schema.id
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 771b287295..40f0383d30 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -142,12 +142,11 @@ class TableScan:
             database_name = self.table.identifier.get_database_name()
             if not database_name or database_name == UNKNOWN_DATABASE:
                 return False
-        if self.table.options.query_auth_enabled:
-            # The binding currently carries explicit partition selectors as
-            # ordinary filters. Keep them on Python, where masked scan-only
-            # selectors can be rejected as Java requires.
-            if self.partition_predicate is not None:
+        if self.partition_predicate is not None:
+            from pypaimon.read.native_plan import native_method_available
+            if not native_method_available('ReadBuilder', 
'with_partition_filter'):
                 return False
+        if self.table.options.query_auth_enabled:
             # The filesystem path has no catalog-issued authorization session.
             loader = getattr(getattr(self.table, 'catalog_environment', None), 
'catalog_loader', None)
             from pypaimon.read.native_plan import _catalog_metastore
@@ -219,20 +218,17 @@ class TableScan:
                     extra_options['row_position_slice'] = (
                         fs.start_pos_of_this_subtask, 
fs.end_pos_of_this_subtask)
             row_ranges = self._native_row_ranges()
-            native_predicate = self.predicate
             if self.partition_predicate is not None:
-                native_predicate = PredicateBuilder.and_predicates([
-                    predicate for predicate in (
-                        native_predicate,
-                        self.file_scanner.partition_key_predicate,
-                    ) if predicate is not None
-                ])
+                # Explicit partition selectors are scan-only. Keep the ordinary
+                # row predicate separate so core can apply Java's override and
+                # masked-partition validation semantics.
+                extra_options['partition_predicate'] = 
fs.partition_key_predicate
             # Append positions still precede LIMIT. Rust owns PK shards and
             # source-backed sorted-index refinement before applying LIMIT.
             defer_limit = has_distribution and not 
self.table.is_primary_key_table
             plan = native_plan(
                 self.table,
-                predicate=native_predicate,
+                predicate=self.predicate,
                 limit=None if defer_limit else self.limit,
                 projection=(
                     [field.name for field in self._read_type]
diff --git a/paimon-python/pypaimon/tests/native_partition_filter_test.py 
b/paimon-python/pypaimon/tests/native_partition_filter_test.py
new file mode 100644
index 0000000000..ad6ac844a8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_partition_filter_test.py
@@ -0,0 +1,197 @@
+# 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.
+
+"""Explicit partition selectors through REST Python and Native scan/read 
paths."""
+
+import json
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import AuthTableQueryResponse
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.read.native_plan import _native_table, native_plan, 
native_runtime_available
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests import native_plan_rest_test
+
+rest_catalog = native_plan_rest_test.rest_catalog
+
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_runtime_available(), reason='Rust planner required')]
+
+
[email protected](params=[(mode, auth) for mode in ('append', 'pk', 'de') for 
auth in (False, True)],
+                ids=lambda case: '%s-%s' % (case[0], 'auth' if case[1] else 
'plain'))
+def source(rest_catalog, request):
+    catalog, server = rest_catalog
+    mode, auth = request.param
+    schema = pa.schema([('id', pa.int32()), ('p', pa.string()),
+                        ('value', pa.int32()), ('region', pa.string())])
+    options = {'read.batch-size': '1'}
+    if mode == 'pk':
+        options['bucket'] = '1'
+    elif mode == 'de':
+        options.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true'})
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        schema, partition_keys=['region', 'p'],
+        primary_keys=['id', 'p', 'region'] if mode == 'pk' else [], 
options=options), False)
+    table = catalog.get_table('default.t')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist([
+            {'id': 1, 'p': 'a', 'value': 10, 'region': 'east'},
+            {'id': 2, 'p': 'b', 'value': 20, 'region': 'east'},
+            {'id': 3, 'p': 'a', 'value': 30, 'region': 'west'},
+            {'id': 4, 'p': 'b', 'value': 40, 'region': 'west'},
+        ], schema=schema))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    if auth:
+        catalog.alter_table(table.identifier, 
[SchemaChange.set_option('query-auth.enabled', 'true')])
+        table = catalog.get_table('default.t')
+    return table, server, auth
+
+
+def _read(table, native, selector, predicate=None, limit=None, 
selector_first=False):
+    table = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                        'read.native.enabled': str(native).lower()})
+    builder = table.new_read_builder().with_projection(['id'])
+    if selector_first:
+        builder.with_partition_filter(selector)
+    if predicate is not None:
+        builder.with_filter(predicate)
+    if not selector_first:
+        builder.with_partition_filter(selector)
+    if limit is not None:
+        builder.with_limit(limit)
+    scan = builder.new_scan()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(scan.file_scanner, 'scan',
+                                             
side_effect=AssertionError('Native plan fell back')))
+            
stack.enter_context(patch('pypaimon.read.table_read.TableRead._create_split_read',
+                                      side_effect=AssertionError('Native read 
fell back')))
+        plan = scan.plan()
+        rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+    if native:
+        assert all(getattr(split, '_native_split', None) is not None for split 
in plan.splits())
+    return plan, sorted(row['id'] for row in rows)
+
+
[email protected]('selector_first', [False, True])
+def 
test_partition_selector_keeps_the_ordinary_predicate_on_hidden_columns(source, 
selector_first):
+    table, _, _ = source
+    predicates = PredicateBuilder(table.fields)
+    for native in (False, True):
+        plan, result = _read(table, native, predicates.equal('region', 'east'),
+                             predicates.equal('p', 'a'), 
selector_first=selector_first)
+        assert plan.splits()
+        assert result == [1]
+
+
[email protected]('limit', [None, 0, 1])
+def test_conflicting_selectors_never_return_rows(source, limit):
+    table, _, _ = source
+    predicates = PredicateBuilder(table.fields)
+    for native in (False, True):
+        _, result = _read(table, native, predicates.equal('region', 'east'),
+                          predicates.equal('region', 'west'), limit=limit)
+        assert result == []
+
+
+def test_limit_counts_only_rows_matching_the_ordinary_predicate(source):
+    table, _, _ = source
+    predicates = PredicateBuilder(table.fields)
+    selector = predicates.is_in('region', ['east', 'west'])
+    for native in (False, True):
+        _, result = _read(table, native, selector, predicates.equal('region', 
'west'), limit=1)
+        assert len(result) == 1 and result[0] in (3, 4)
+
+
+def test_replacing_and_clearing_partition_selector(source):
+    table, _, _ = source
+    predicates = PredicateBuilder(table.fields)
+    for native in (False, True):
+        builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
+                              'read.native.enabled': 
str(native).lower()}).new_read_builder()
+        builder.with_projection(['id']).with_filter(predicates.equal('p', 'a'))
+        builder.with_partition_filter(predicates.equal('region', 'east'))
+        builder.with_partition_filter(predicates.equal('region', 'west'))
+        plan = builder.new_scan().plan()
+        assert builder.new_read().to_arrow(plan.splits()).to_pylist() == 
[{'id': 3}]
+        builder.with_partition_filter(None)
+        plan = builder.new_scan().plan()
+        assert sorted(row['id'] for row in 
builder.new_read().to_arrow(plan.splits()).to_pylist()) == [1, 3]
+
+
+def test_unmasked_partition_selector_with_a_masked_row_predicate(source):
+    table, server, auth = source
+    if not auth:
+        pytest.skip('requires query authorization')
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        column_masking={'p': json.dumps({'name': 'UPPER', 'inputs': [{'index': 
1, 'name': 'p', 'type': 'STRING'}]})})
+    predicates = PredicateBuilder(table.fields)
+    for native in (False, True):
+        _, result = _read(table, native, predicates.equal('region', 'east'),
+                          predicates.equal('p', 'A'), limit=1)
+        assert result == [1]
+        # Masking p must not forbid a selector that references only region.
+        _, result = _read(table, native, predicates.equal('region', 'west'))
+        assert result == [3, 4]
+
+
+def test_native_plan_rejects_masked_selector_before_any_data_is_read(source):
+    table, server, auth = source
+    if not auth:
+        pytest.skip('requires query authorization')
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        column_masking={'p': json.dumps({'name': 'CONCAT', 'inputs': 
['masked']})})
+    for limit in (0, 1):
+        with pytest.raises(NotImplementedError, match="partition key 'p' is 
masked"):
+            native_plan(table, projection=['id'], limit=limit,
+                        
partition_predicate=PredicateBuilder(table.fields).equal('p', 'missing'))
+
+
+def test_native_authorization_includes_scan_only_partition_operands(source):
+    table, server, auth = source
+    if not auth:
+        pytest.skip('requires query authorization')
+    selections = []
+
+    def authorize(select):
+        selections.append(select)
+        return AuthTableQueryResponse()
+
+    server.table_query_auth_store[table.identifier.get_full_name()] = authorize
+    _read(table, True, PredicateBuilder(table.fields).equal('region', 'east'))
+    assert selections and all(set(selection) == {'id', 'region'} for selection 
in selections)
+
+
+def 
test_native_reader_rechecks_partition_filter_on_independently_planned_splits(source):
+    table, _, _ = source
+    native = _native_table(table)
+    plan = native.new_read_builder().new_scan().plan()
+    builder = native.new_read_builder().with_projection(['id']).with_filter(
+        {'method': 'equal', 'field': 'region', 'literals': ['west']})
+    rows = [row for batch in builder.new_read().read_arrow(plan.splits()) for 
row in batch.to_pylist()]
+    assert sorted(row['id'] for row in rows) == [3, 4]
diff --git a/paimon-python/pypaimon/tests/partition_predicate_test.py 
b/paimon-python/pypaimon/tests/partition_predicate_test.py
index c362def4aa..f1d0e96fb2 100644
--- a/paimon-python/pypaimon/tests/partition_predicate_test.py
+++ b/paimon-python/pypaimon/tests/partition_predicate_test.py
@@ -474,3 +474,25 @@ class 
TestCommitScannerPartitionPredicate(unittest.TestCase):
 
         self.assertEqual(len(result), 1)
         self.assertEqual(tuple(result[0].partition.values), ('p1', 'us'))
+
+
+class ReadBuilderPartitionFilterTest(unittest.TestCase):
+    def 
test_public_selector_rejects_non_partition_fields_without_changing_state(self):
+        from types import SimpleNamespace
+        from pypaimon.read.read_builder import ReadBuilder
+
+        builder = ReadBuilder(SimpleNamespace(partition_keys=['dt', 'region']))
+        predicates = PredicateBuilder(TABLE_FIELDS)
+        selector = predicates.equal('region', 'us-east-1')
+        builder.with_partition_filter(selector)
+        for invalid in [predicates.equal('name', 'x'),
+                        predicates.and_predicates([selector, 
predicates.equal('name', 'x')]),
+                        predicates.or_predicates([selector, 
predicates.equal('name', 'x')])]:
+            with self.assertRaisesRegex(ValueError, 'only partition keys'):
+                builder.with_partition_filter(invalid)
+            self.assertIs(builder._partition_filter, selector)
+        self.assertIs(builder.with_partition_filter(None), builder)
+        self.assertIsNone(builder._partition_filter)
+        unpartitioned = ReadBuilder(SimpleNamespace(partition_keys=[]))
+        with self.assertRaisesRegex(ValueError, 'partitioned table'):
+            unpartitioned.with_partition_filter(selector)
diff --git a/paimon-python/pypaimon/tests/reader_split_generator_test.py 
b/paimon-python/pypaimon/tests/reader_split_generator_test.py
index 6f8d1b1962..c2610112b5 100644
--- a/paimon-python/pypaimon/tests/reader_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/reader_split_generator_test.py
@@ -338,7 +338,9 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
             limit,
             has_non_partition_filter=False,
             data_evolution=False,
-            deletion_vectors_enabled=False):
+            deletion_vectors_enabled=False,
+            explicit_partition_filter=False,
+            has_row_filter=False):
         from pypaimon.read.scanner.file_scanner import FileScanner
 
         class _FakeScanner:
@@ -349,6 +351,8 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
         scanner.data_evolution = data_evolution
         scanner.deletion_vectors_enabled = deletion_vectors_enabled
         scanner.auth_has_non_partition_filter = False
+        scanner._explicit_partition_filter = explicit_partition_filter
+        scanner.predicate = object() if has_row_filter else None
         scanner._has_non_partition_filter = lambda: has_non_partition_filter
         return FileScanner._apply_push_down_limit(scanner, splits)
 
@@ -407,6 +411,17 @@ class ApplyPushDownLimitUnitTest(unittest.TestCase):
             [s_raw, s_raw, s_raw], limit=5, has_non_partition_filter=True)
         self.assertEqual(len(result), 3)
 
+    def 
test_explicit_partition_selector_defers_limit_until_ordinary_filter(self):
+        first = self._split(raw_convertible=True, row_count=10, 
merged_row_count=10)
+        later = self._split(raw_convertible=True, row_count=10, 
merged_row_count=10)
+        splits = [first, later]
+        self.assertEqual(self._apply(splits, limit=1, 
explicit_partition_filter=True,
+                                     has_row_filter=True), splits)
+        # Without a residual row filter, the explicit selection has already
+        # constrained every split, so merged-row-count LIMIT remains safe.
+        self.assertEqual(self._apply(splits, limit=1, 
explicit_partition_filter=True), [first])
+        self.assertEqual(self._apply(splits, limit=1, has_row_filter=True), 
[first])
+
     def test_data_evolution_deletion_vectors_disable_limit_pushdown(self):
         s_raw = self._split(raw_convertible=True, row_count=10, 
merged_row_count=10)
         result = self._apply(

Reply via email to