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 4eec1297a3 [python] Keep projected nested ROW predicates on native
reads (#10637)
4eec1297a3 is described below
commit 4eec1297a31f3baf968dcc3b7fa318f30d58d96c
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 12:55:08 2026 +0800
[python] Keep projected nested ROW predicates on native reads (#10637)
---
paimon-python/pypaimon/common/predicate.py | 11 +-
paimon-python/pypaimon/read/native_plan.py | 7 +-
paimon-python/pypaimon/read/push_down_utils.py | 42 +++
paimon-python/pypaimon/read/read_builder.py | 19 +-
.../pypaimon/read/scanner/file_scanner.py | 37 +-
paimon-python/pypaimon/read/split_read.py | 8 +-
paimon-python/pypaimon/read/table_read.py | 72 +++-
paimon-python/pypaimon/tests/global_index_test.py | 2 +
.../pypaimon/tests/native_nested_predicate_test.py | 385 +++++++++++++++++++++
9 files changed, 554 insertions(+), 29 deletions(-)
diff --git a/paimon-python/pypaimon/common/predicate.py
b/paimon-python/pypaimon/common/predicate.py
index 8967e2cecb..bc7f564bfd 100644
--- a/paimon-python/pypaimon/common/predicate.py
+++ b/paimon-python/pypaimon/common/predicate.py
@@ -53,6 +53,8 @@ class Predicate:
index: Optional[int]
field: Optional[str]
literals: Optional[List[Any]] = None
+ # Physical source path for predicates built against a flat projection view.
+ field_path: Optional[List[str]] = None
testers: ClassVar[Dict[str, Any]] = {}
@@ -61,14 +63,16 @@ class Predicate:
method=self.method,
index=index,
field=self.field,
- literals=self.literals)
+ literals=self.literals,
+ field_path=self.field_path)
def new_literals(self, literals: List[Any]):
return Predicate(
method=self.method,
index=self.index,
field=self.field,
- literals=literals)
+ literals=literals,
+ field_path=self.field_path)
def test(self, record: InternalRow) -> bool:
if self.method == 'and':
@@ -88,6 +92,9 @@ class Predicate:
return all(p.test_by_simple_stats(stat, row_count) for p in
self.literals)
if self.method == 'or':
return any(p.test_by_simple_stats(stat, row_count) for p in
self.literals)
+ if self.field_path and len(self.field_path) > 1:
+ # Parent ROW stats do not describe a nested field transform.
+ return True
index = self.index
if index is None or index < 0:
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index f968f85154..4c30c5e525 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -243,11 +243,16 @@ def _predicate_to_native(predicate: Predicate) -> dict:
'method': predicate.method,
'children': [_predicate_to_native(child) for child in children],
}
- return {
+ result = {
'method': predicate.method,
'field': predicate.field,
'literals': list(predicate.literals or []),
}
+ if predicate.field_path:
+ result['field'] = predicate.field_path[0]
+ if len(predicate.field_path) > 1:
+ result['nested_path'] = list(predicate.field_path[1:])
+ return result
def _resolved_rest_table_response(table):
diff --git a/paimon-python/pypaimon/read/push_down_utils.py
b/paimon-python/pypaimon/read/push_down_utils.py
index 1b096053e1..c9ae91d722 100644
--- a/paimon-python/pypaimon/read/push_down_utils.py
+++ b/paimon-python/pypaimon/read/push_down_utils.py
@@ -139,6 +139,48 @@ def predicate_field_names(predicate: Predicate) ->
Set[str]:
return involved_fields
+def predicate_for_physical_fields(predicate: Optional[Predicate], fields:
List[DataField]) -> Optional[Predicate]:
+ """Bind projected top-level leaves; omit nested leaves from file pruning.
+
+ Like Java's NestedFieldTransform, a nested ROW leaf cannot use the parent
+ column's stats, bucket key or scalar index. Its flat output name can also
+ coincide with an unrelated physical column. Keep exact filtering on the
+ original predicate after projection.
+ """
+ if predicate is None:
+ return None
+ if predicate.method in ('and', 'or'):
+ children = [predicate_for_physical_fields(child, fields) for child in
predicate.literals or []]
+ if predicate.method == 'or' and any(child is None for child in
children):
+ return None
+ kept = [child for child in children if child is not None]
+ combine = (PredicateBuilder.and_predicates if predicate.method == 'and'
+ else PredicateBuilder.or_predicates)
+ return combine(kept)
+ if not predicate.field_path:
+ return predicate
+ if len(predicate.field_path) != 1:
+ return None
+ name = predicate.field_path[0]
+ index = next((index for index, field in enumerate(fields) if field.name ==
name), None)
+ if index is None:
+ # Synthesized row-tracking columns are absent from table.fields but
+ # already carry the physical name used by the row-range selector.
+ if name == predicate.field:
+ return predicate
+ return None
+ return Predicate(predicate.method, index, name, predicate.literals,
predicate.field_path)
+
+
+def predicate_matches_physical_names(predicate: Optional[Predicate]) -> bool:
+ """Whether a predicate's output names denote the same physical columns."""
+ if predicate is None:
+ return True
+ if predicate.method in ('and', 'or'):
+ return all(predicate_matches_physical_names(child) for child in
predicate.literals or [])
+ return not predicate.field_path or predicate.field_path ==
[predicate.field]
+
+
def _get_all_fields(predicate: Predicate) -> Set[str]:
return predicate_field_names(predicate)
diff --git a/paimon-python/pypaimon/read/read_builder.py
b/paimon-python/pypaimon/read/read_builder.py
index cb5373cdc8..e4d3297d71 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -24,7 +24,7 @@ from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.read.explain import ExplainResult, ExplainSplitInfo, PruningStat
from pypaimon.read.explain_render import render_predicate
-from pypaimon.read.push_down_utils import predicate_field_names
+from pypaimon.read.push_down_utils import predicate_field_names,
predicate_matches_physical_names
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.split import Split
@@ -86,9 +86,17 @@ def _normalize_sql_literals(expression: str) -> str:
class _ReadPredicateBuilder(PredicateBuilder):
- def __init__(self, fields, unsupported_fields):
+ def __init__(self, fields, unsupported_fields, paths=None):
super().__init__(fields)
self._unsupported_fields = unsupported_fields
+ self._paths = paths or {}
+
+ def _build_predicate(self, method, field, literals=None):
+ predicate = super()._build_predicate(method, field, literals)
+ path = self._paths.get(field)
+ if path:
+ predicate.field_path = list(path)
+ return predicate
def _get_field_index(self, field: str) -> int:
if field in self._unsupported_fields:
@@ -123,6 +131,7 @@ class ReadBuilder:
"""
if partition_filter is not None and (
not self.table.partition_keys
+ or not predicate_matches_physical_names(partition_filter)
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
@@ -208,10 +217,12 @@ class ReadBuilder:
# List projections expose flat leaf names to the Python predicate
# evaluator. Derive that view without changing the reader request.
fields = self.read_type()
+ paths = {}
if self._output_projection is not None and not
self._output_projection.named:
- fields = reader_adapter(fields, self._table_read_fields(),
self._output_projection)[0]
+ fields, name_paths = reader_adapter(fields,
self._table_read_fields(), self._output_projection)
+ paths = {field.name: path for field, path in zip(fields,
name_paths)}
return _ReadPredicateBuilder(
- fields, self._map_key_output_names())
+ fields, self._map_key_output_names(), paths)
def explain(self, verbose: bool = False) -> ExplainResult:
"""Produce a structured scan plan for this builder.
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index bcbf73e033..b24624e69f 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -36,6 +36,8 @@ from pypaimon.read.plan import Plan
from pypaimon.read.scan_distribution import validate_shard, validate_slice
from pypaimon.read.push_down_utils import (_get_all_fields,
exclude_predicate_with_fields,
+ predicate_for_physical_fields,
+ predicate_matches_physical_names,
remove_row_id_filter,
rewrite_predicate_indices,
trim_and_transform_predicate,
@@ -275,16 +277,17 @@ class FileScanner:
def _set_predicate(self, predicate):
self.predicate = predicate
- row_ranges = _row_ranges_from_predicate(predicate) if predicate else
None
+ physical = predicate_for_physical_fields(predicate, self.table.fields)
+ row_ranges = _row_ranges_from_predicate(physical) if physical else None
self.predicate_for_stats = (
- remove_row_id_filter(predicate) if row_ranges is not None else
predicate)
+ remove_row_id_filter(physical) if row_ranges is not None else
physical)
self.predicate_for_stats = exclude_predicate_with_fields(
self.predicate_for_stats, SpecialFields.SYSTEM_FIELD_NAMES |
set(self.table.partition_keys))
self.primary_key_predicate = trim_and_transform_predicate(
- predicate, self.table.field_names, self.table.trimmed_primary_keys)
+ physical, self.table.field_names, self.table.trimmed_primary_keys)
if self._partition_predicate is None:
self.partition_key_predicate = trim_and_transform_predicate(
- predicate, self.table.field_names, self.table.partition_keys)
+ physical, self.table.field_names, self.table.partition_keys)
else:
self.partition_key_predicate = rewrite_predicate_indices(
trim_predicate_by_fields(self._partition_predicate,
self.table.partition_keys),
@@ -418,8 +421,9 @@ class FileScanner:
def _apply_primary_key_sorted_indexes(self, splits, snapshot=None):
snapshot = snapshot if snapshot is not None else self._scanned_snapshot
+ predicate = predicate_for_physical_fields(self.predicate,
self.table.fields)
if (not self.table.options.global_index_enabled()
- or self.predicate is None
+ or predicate is None
or snapshot is None
or not splits):
return splits
@@ -449,7 +453,7 @@ class FileScanner:
evaluated = primary_key_sorted_index_scan.evaluate(
index_plan,
self.table.fields,
- self.predicate,
+ predicate,
definitions,
primary_key_sorted_index_scan.reader_factory(self.table),
)
@@ -482,7 +486,8 @@ class FileScanner:
if isinstance(global_index_result, ScoredGlobalIndexResult):
score_getter = global_index_result.score_getter()
if row_ranges is None and self.predicate is not None:
- row_ranges = _row_ranges_from_predicate(self.predicate)
+ row_ranges = _row_ranges_from_predicate(
+ predicate_for_physical_fields(self.predicate,
self.table.fields))
if row_ranges is not None and not row_ranges:
return [], DataEvolutionSplitGenerator(
@@ -545,7 +550,8 @@ class FileScanner:
def _eval_global_index(self, snapshot=None):
# Snapshot indexes describe current state, not historical change
events.
- if self.is_streaming or self.predicate is None:
+ predicate = predicate_for_physical_fields(self.predicate,
self.table.fields)
+ if self.is_streaming or predicate is None:
return None
# Check if global index is enabled
@@ -558,20 +564,20 @@ class FileScanner:
scanner = DataEvolutionGlobalIndexScanner.create(
self.table,
partition_filter=self.partition_key_predicate,
- predicate=self.predicate,
+ predicate=predicate,
snapshot=snapshot,
)
if scanner is None:
return None
with scanner:
- evaluation = scanner.scan_with_coverage(self.predicate)
+ evaluation = scanner.scan_with_coverage(predicate)
if evaluation is None:
return None
scalar_mode = self.table.options.scalar_index_search_mode()
return _GlobalIndexPlanningResult(
evaluation.result,
scanner.unindexed_ranges(
- self.predicate,
+ predicate,
search_mode=scalar_mode,
contributing_field_ids=(
evaluation.contributing_field_ids),
@@ -729,7 +735,7 @@ class FileScanner:
if self._user_predicate is not None:
partition_keys = set(self.table.partition_keys or [])
non_partition_fields = _get_all_fields(self._user_predicate) -
partition_keys
- if non_partition_fields:
+ if non_partition_fields or not
predicate_matches_physical_names(self._user_predicate):
raise ValueError(
"chunk_shuffle predicate must reference only partition
keys; "
"got non-partition fields: "
@@ -768,6 +774,8 @@ class FileScanner:
"""Mirror Java ``SnapshotReaderImpl.hasNonPartitionFilter``."""
if self.predicate is None:
return False
+ if not predicate_matches_physical_names(self.predicate):
+ return True
partition_keys = set(self.table.partition_keys or [])
return not _get_all_fields(self.predicate).issubset(partition_keys)
@@ -806,6 +814,9 @@ class FileScanner:
"""
if self.predicate is None:
return None
+ predicate = predicate_for_physical_fields(self.predicate,
self.table.fields)
+ if predicate is None:
+ return None
# ``bucket_mode()`` returns HASH_FIXED only when ``options.bucket()
# > 0``; other modes (DYNAMIC / POSTPONE / UNAWARE / CROSS_PARTITION)
# have no fixed hash → bucket mapping at write time and must NOT
@@ -845,7 +856,7 @@ class FileScanner:
if name in self.table.field_dict
]
return create_bucket_selector(
- self.predicate, bucket_key_fields,
+ predicate, bucket_key_fields,
partition_fields=partition_fields,
)
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index 9563abf54e..a50deffb09 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -40,6 +40,8 @@ from pypaimon.read.interval_partition import
IntervalPartition, SortedRun
from pypaimon.read.partition_info import PartitionInfo
from pypaimon.read.push_down_utils import (
predicate_field_names,
+ predicate_for_physical_fields,
+ predicate_matches_physical_names,
predicate_supports_arrow_filter,
rewrite_predicate_indices,
trim_predicate_by_fields,
@@ -195,6 +197,7 @@ class SplitRead(ABC):
if (
self.predicate is not None
and
predicate_field_names(self.predicate).issubset(read_type_names)
+ and predicate_matches_physical_names(self.predicate)
):
self.predicate_for_reader = rewrite_predicate_indices(
self.predicate, read_type
@@ -260,12 +263,13 @@ class SplitRead(ABC):
# see missing/NULL versions and renumber positional row IDs.
return None
elif self.table.is_primary_key_table:
- pk_predicate = trim_predicate_by_fields(self.predicate,
self.table.primary_keys)
+ physical = predicate_for_physical_fields(self.predicate,
self.table.fields)
+ pk_predicate = trim_predicate_by_fields(physical,
self.table.primary_keys)
if not pk_predicate:
return None
return pk_predicate
else:
- return self.predicate
+ return predicate_for_physical_fields(self.predicate,
self.table.fields)
def _has_row_tracking_predicate(self) -> bool:
return (self.table.options.row_tracking_enabled()
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index c041b49be4..05a77f3615 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -177,8 +177,11 @@ class TableRead:
self.predicate = predicate
self.read_type = read_type
self.output_projection = output_projection
- self._adapter_read_type = (self._reader_adapter()[0]
- if output_projection is not None else
read_type)
+ adapter = (self._reader_adapter() if output_projection is not None
+ else (read_type, [[field.name] for field in read_type]))
+ self._adapter_read_type = adapter[0]
+ physical_names = {field.name for field in self._table_read_fields()}
+ self.predicate = self._bind_predicate_to_adapter(self.predicate,
*adapter, physical_names)
# Split readers may need predicate-only columns that are absent from
# the requested output. Read the widened schema internally, then use
# ``_output_column_names`` to project batches back to ``read_type``.
@@ -460,13 +463,59 @@ class TableRead:
if (self.predicate is None or self.output_projection is None
or self.output_projection.named):
return adapter
- # Keep complete source columns for authorization and result extraction.
- # Only a predicate on a flat list-projection leaf needs an additional
- # local leaf view, even when its parent ROW is requested in full.
- projected = reader_adapter(self.read_type, self._table_read_fields(),
self.output_projection)
+ # Derive the flat working view for exact Python filtering, retaining
+ # requested whole columns and the additional top-level filter inputs.
+ columns = list(self.output_projection.columns)
+ paths = [path for _, path in columns]
+ physical_names = {field.name for field in self._table_read_fields()}
+ has_physical_path = False
+ # Predicates carrying physical paths were built against a flat view.
+ # Keep that layout stable when a projection change rebinds their
+ # aliases, including names which collide with top-level columns.
+ pending = [self.predicate]
+ while pending:
+ predicate = pending.pop()
+ if predicate.method in ('and', 'or'):
+ pending.extend(predicate.literals or [])
+ else:
+ path = predicate.field_path or ([predicate.field] if
predicate.field in physical_names else None)
+ if path:
+ has_physical_path = True
+ if len(path) == 1 and path[0] in physical_names and path
not in paths:
+ # Keep existing top-level filter-only columns in the
+ # flat working view. Projection assigns their aliases
+ # together with the user outputs, preventing
collisions.
+ columns.append((path[0], path))
+ paths.append(path)
+ projected = reader_adapter(self.read_type, self._table_read_fields(),
OutputProjection(columns))
extra_names = {field.name for field in projected[0]} - {field.name for
field in adapter[0]}
+ if has_physical_path:
+ return projected
return projected if predicate_field_names(self.predicate) &
extra_names else adapter
+ @staticmethod
+ def _bind_predicate_to_adapter(predicate, fields, paths, physical_names):
+ if predicate is None:
+ return None
+ if predicate.method in ('and', 'or'):
+ return predicate.new_literals([
+ TableRead._bind_predicate_to_adapter(child, fields, paths,
physical_names)
+ for child in predicate.literals or []])
+ path = predicate.field_path or ([predicate.field] if predicate.field
in physical_names else None)
+ if not path:
+ return predicate
+ matches = [(index, field) for index, (field, candidate_path) in
enumerate(zip(fields, paths))
+ if candidate_path == path]
+ if matches:
+ index, field = matches[0]
+ bound_path = predicate.field_path or (path if field.name !=
predicate.field else None)
+ return Predicate(predicate.method, index, field.name,
predicate.literals, bound_path)
+ if len(path) == 1 and path[0] not in {field.name for field in fields}:
+ # Existing top-level filter-only columns are appended separately.
+ return Predicate(predicate.method, predicate.index, path[0],
predicate.literals, predicate.field_path)
+ raise ValueError("Predicate field %r with path %r is not in the reader
projection"
+ % (predicate.field, path))
+
def _output_arrow_schema(self, splits=None):
schema = read_arrow_schema(self.read_type)
masks = set()
@@ -854,8 +903,17 @@ class TableRead:
def _flatten_native_nested_batch(self, batch, schema):
_, paths = self._reader_adapter()
+ # Rust already evaluated the complete predicate and projected away
+ # filter-only fields. Flatten just the user output dependencies; the
+ # Python split reader retains the additional adapter inputs internally.
+ outputs = adapter_output_paths(self.output_projection,
self._adapter_read_type, paths)
+ needed = {path[0] for _, path in outputs}
+ fields_and_paths = [(field, path) for field, path in
zip(self._adapter_read_type, paths)
+ if field.name in needed]
+ adapter_fields = [field for field, _ in fields_and_paths]
+ paths = [path for _, path in fields_and_paths]
arrays = [extract_array(batch, path) for path in paths]
- fields = list(read_arrow_schema(self._adapter_read_type))
+ fields = list(read_arrow_schema(adapter_fields))
# Authorization can mask a NOT NULL field. Keep the captured source
# nullability when flattening, including whole-parent/MAP projections.
projected = output_schema(batch.schema, OutputProjection([
diff --git a/paimon-python/pypaimon/tests/global_index_test.py
b/paimon-python/pypaimon/tests/global_index_test.py
index c07833a70f..434b0b6411 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -316,6 +316,7 @@ class GlobalIndexScalarFallbackTest(unittest.TestCase):
class _Table:
options = _Options()
+ fields = [DataField(0, 'id', AtomicType('INT'))]
predicate = Predicate(method="equal", index=0, field="id",
literals=[1])
scanner = FileScanner.__new__(FileScanner)
@@ -405,6 +406,7 @@ class GlobalIndexScalarFallbackTest(unittest.TestCase):
class _Table:
options = _Options()
+ fields = [DataField(0, 'id', AtomicType('INT'))]
scanner = FileScanner.__new__(FileScanner)
scanner.is_streaming = False
diff --git a/paimon-python/pypaimon/tests/native_nested_predicate_test.py
b/paimon-python/pypaimon/tests/native_nested_predicate_test.py
new file mode 100644
index 0000000000..983a338790
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_nested_predicate_test.py
@@ -0,0 +1,385 @@
+# 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.
+
+"""Existing projected ROW predicates must use native scans and reads."""
+
+from contextlib import ExitStack
+from datetime import datetime
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.read.native_plan import _predicate_to_native
+
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(path, mode):
+ catalog = CatalogFactory.create({'warehouse': str(path)})
+ catalog.create_database('db', True)
+ detail = pa.struct([('score', pa.int32()), ('label', pa.string()),
+ ('amount', pa.decimal128(12, 2)), ('instant',
pa.timestamp('ms'))])
+ schema = pa.schema([('id', pa.int32()), ('profile', pa.struct([('detail',
detail), ('unused', pa.string())])),
+ ('keep', pa.string())])
+ options = {'bucket': '1' if mode == 'pk' else '-1',
'write.native.enabled': 'false',
+ 'scan.native-plan.enabled': 'false', 'read.native.enabled':
'false', 'parquet.block.size': '1'}
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'] if mode == 'pk' else None,
options=options), False)
+ table = catalog.get_table('db.t')
+
+ def profile(score, label):
+ return {'detail': {'score': score, 'label': label, 'amount':
Decimal('1.25'),
+ 'instant': datetime(2024, 2, 29, 12)}, 'unused':
'unselected'}
+ initial = [{'id': 1, 'profile': profile(7, 'old'), 'keep': 'a'},
+ {'id': 2, 'profile': profile(1, 'first'), 'keep': 'b'},
+ {'id': 3, 'profile': None, 'keep': 'c'},
+ {'id': 4, 'profile': {'detail': None, 'unused': 'x'}, 'keep':
'd'},
+ {'id': 5, 'profile': profile(None, None), 'keep': 'e'},
+ {'id': 6, 'profile': profile(9, '中文'), 'keep': 'f'}]
+ changed = [{'id': 1, 'profile': profile(30, 'new'), 'keep': 'g'},
+ {'id': 2, 'profile': profile(7, 'winner'), 'keep': 'h'}]
+ for rows in [initial, changed]:
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ return table
+
+
+_CASES = [
+ ('score', 'equal', [7]), ('score', 'not_equal', [7]),
+ ('score', 'less_than', [7]), ('score', 'greater_or_equal', [7]),
+ ('score', 'is_null', []), ('score', 'is_not_null', []),
+ ('score', 'is_in', [[None, 7, 9]]), ('score', 'is_not_in', [[7]]),
+ ('score', 'between', [1, 9]), ('score', 'not_between', [1, 9]),
+ ('score', 'is_in', [[]]), ('score', 'is_not_in', [[]]),
+ ('score', 'is_in', [[None]]), ('score', 'is_not_in', [[None, 7]]),
+ ('score', 'between', [9, 1]), ('score', 'not_between', [9, 1]),
+ ('label', 'equal', ['中文']), ('label', 'startswith', ['w']),
+ ('label', 'contains', ['中']), ('label', 'endswith', ['文']),
+ ('label', 'like', ['_文']), ('label', 'like', ['w%']),
+ ('amount', 'equal', [Decimal('1.25')]),
+ ('instant', 'greater_or_equal', [datetime(2024, 1, 1)]),
+]
+
+
+def _read(table, native, stream, case, projection, limit, compound):
+ view = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ builder = view.new_stream_read_builder() if stream else
view.new_read_builder()
+ column, method, literals = case
+ leaf = 'profile.detail.' + column
+ builder.with_projection([leaf, 'id'])
+ predicates = builder.new_predicate_builder()
+ predicate = getattr(predicates, method)('profile_detail_' + column,
*literals)
+ wire = _predicate_to_native(predicate)
+ assert wire['field'] == 'profile'
+ assert wire['nested_path'] == ['detail', column]
+ assert _predicate_to_native(predicate.new_index(predicate.index)) == wire
+ assert _predicate_to_native(predicate.new_literals(predicate.literals)) ==
wire
+ if compound:
+ top = predicates.equal('id', 6)
+ combine = PredicateBuilder.and_predicates if compound == 'and' else
PredicateBuilder.or_predicates
+ predicate = combine([predicate, top])
+ builder.with_filter(predicate).with_projection(projection or [leaf, 'id'])
+ if limit is not None:
+ builder.with_limit(limit)
+ if stream:
+ # A snapshot plan isolates read semantics from the streaming poll loop.
+ splits = view.new_read_builder().new_scan().plan().splits()
+ else:
+ scan = builder.new_scan()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Python scan fallback')))
+ splits = scan.plan().splits()
+ read = builder.new_read()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(read, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ result = read.to_arrow_batch_reader(splits).read_all()
+ return result.sort_by('id').to_pylist()
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('case', _CASES, ids=[c[0] + '-' + c[1] for c in
_CASES])
+def test_nested_projection_filters_stay_native(tmp_path, mode, stream, case):
+ table = _table(tmp_path, mode)
+ expected = _read(table, False, stream, case, None, None, None)
+ actual = _read(table, True, stream, case, None, None, None)
+ assert actual == expected
+ if case == ('score', 'equal', [7]):
+ assert [row['id'] for row in actual] == ([2] if mode == 'pk' else [1,
2])
+ elif case == ('score', 'is_null', []):
+ assert [row['id'] for row in actual] == [3, 4, 5]
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('projection', [
+ None, ['profile', 'profile.detail.score', 'id'],
+ ['profile.detail.score', 'profile.detail.score', 'id'],
+])
[email protected]('compound', [None, 'and', 'or'])
[email protected]('limit', [None, 1])
+def test_nested_filter_composition_and_projection_changes(tmp_path, mode,
projection, compound, limit):
+ table = _table(tmp_path, mode)
+ case = ('score', 'equal', [7])
+ expected = _read(table, False, False, case, projection, limit, compound)
+ actual = _read(table, True, False, case, projection, limit, compound)
+ # LIMIT has no row-order contract. Here all matching IDs are equal or the
+ # OR includes id6; validate limited rows against the complete result.
+ if limit:
+ allowed = _read(table, False, False, case, projection, None, compound)
+ assert len(actual) == min(len(allowed), limit)
+ assert all(row in allowed for row in actual)
+ else:
+ assert actual == expected
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('nested_first', [False, True])
[email protected]('nested_filter', [False, True])
[email protected]('partitioned', [False, True])
[email protected]('compound', [None, 'and', 'or'])
[email protected]('reorder_projection', [False, True])
+def test_nested_predicate_alias_collisions(
+ tmp_path, mode, native, nested_first, nested_filter, partitioned,
compound, reorder_projection):
+ import pickle
+
+ schema = pa.schema([('id', pa.int32()), ('a', pa.struct([('b',
pa.int32())])), ('a_b', pa.int32())])
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=(['a_b', 'id'] if partitioned else ['id']) if
mode == 'pk' else None,
+ partition_keys=['a_b'] if partitioned else None,
+ options={'bucket': '8' if mode == 'pk' else '-1',
'write.native.enabled': 'false',
+ 'metadata.stats-mode': 'full'}), False)
+ table = catalog.get_table('db.t')
+ for row in [
+ {'id': 11, 'a': {'b': 7}, 'a_b': 99},
+ {'id': 12, 'a': {'b': 99}, 'a_b': 7},
+ {'id': 13, 'a': None, 'a_b': 7},
+ ]:
+ writer_builder = table.new_batch_write_builder()
+ writer, commit = writer_builder.new_write(),
writer_builder.new_commit()
+ try:
+ # Separate files expose false stats pruning when the unrelated
+ # real a_b column excludes the nested literal 7.
+ writer.write_arrow(pa.Table.from_pylist([row], schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ table = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ projection = ['a.b', 'a_b', 'id'] if nested_first else ['a_b', 'a.b', 'id']
+ builder = table.new_read_builder().with_projection(projection)
+ # The second occurrence of a_b receives a suffix, whether its source is a
+ # nested leaf or a real top-level column. Predicates retain that identity.
+ alias = 'a_b' if nested_first == nested_filter else 'a_b__0'
+ predicate = builder.new_predicate_builder().equal(alias, 7)
+ predicate =
pickle.loads(pickle.dumps(predicate.new_index(predicate.index)))
+ wire = _predicate_to_native(predicate)
+ assert wire['field'] == ('a' if nested_filter else 'a_b')
+ assert wire.get('nested_path') == (['b'] if nested_filter else None)
+ if partitioned and nested_first and nested_filter:
+ with pytest.raises(ValueError, match='Partition filter must reference
only partition keys'):
+ builder.with_partition_filter(predicate)
+ if compound == 'and':
+ predicate = PredicateBuilder.and_predicates([
+ predicate, builder.new_predicate_builder().less_or_equal('id',
12)])
+ elif compound == 'or':
+ predicate = PredicateBuilder.or_predicates([
+ predicate, builder.new_predicate_builder().equal('id', 13)])
+ builder.with_filter(predicate)
+ if reorder_projection:
+ builder.with_projection([projection[1], projection[0], 'id'])
+ if mode == 'append' and partitioned and nested_filter:
+ # A nested alias may equal a partition name. Filtering after chunk
+ # generation would invalidate its declared merged row count.
+ with pytest.raises(ValueError, match='chunk_shuffle predicate must
reference only partition keys'):
+ builder.new_scan().with_chunk_shuffle(1, 2).plan()
+ scan, read = builder.new_scan(), builder.new_read()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Python scan fallback')))
+ stack.enter_context(patch.object(read, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ rows =
read.to_arrow_batch_reader(scan.plan().splits()).read_all().to_pylist()
+ expected = {11} if nested_filter else {12, 13}
+ if compound == 'and':
+ expected &= {11, 12}
+ elif compound == 'or':
+ expected |= {13}
+ assert sorted(row['id'] for row in rows) == sorted(expected)
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def
test_removed_nested_filter_path_does_not_bind_to_colliding_column(tmp_path,
native):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([('a', pa.struct([('b', pa.int32())])), ('a_b',
pa.int32())])
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(schema), False)
+ table = catalog.get_table('db.t').copy({'read.native.enabled':
str(native).lower()})
+ builder = table.new_read_builder().with_projection(['a.b', 'a_b'])
+ predicate = builder.new_predicate_builder().equal('a_b', 7)
+ builder.with_filter(predicate).with_projection(['a_b'])
+ with pytest.raises(ValueError, match='Predicate field .* is not in the
reader projection'):
+ builder.new_read()
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('native', [False, True], ids=['python', 'native'])
[email protected]('projected_predicate', [False, True],
ids=['physical', 'projected'])
[email protected]('compound', [None, 'and', 'or'])
[email protected]('projection', [
+ ['a.b', 'id'], ['a.b', 'a.b', 'id'], ['a', 'a.b', 'id'],
+])
+def test_nested_projection_keeps_physical_filter_only_inputs(
+ tmp_path, mode, native, projected_predicate, compound, projection):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([('id', pa.int32()), ('a', pa.struct([('b',
pa.int32())])), ('a_b', pa.int32())])
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'] if mode == 'pk' else None,
+ options={'bucket': '1' if mode == 'pk' else '-1',
'write.native.enabled': 'false'}), False)
+ table = catalog.get_table('db.t')
+ for rows in [
+ [{'id': 1, 'a': {'b': 30}, 'a_b': 99}],
+ [{'id': 1, 'a': {'b': 7}, 'a_b': 99}, {'id': 2, 'a': {'b': 1}, 'a_b':
7},
+ {'id': 3, 'a': {'b': 9}, 'a_b': 99}, {'id': 4, 'a': None, 'a_b': 7}],
+ ]:
+ write_builder = table.new_batch_write_builder()
+ writer, commit = write_builder.new_write(), write_builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ table = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ builder = table.new_read_builder().with_projection(['a_b', 'id'])
+ predicates = builder.new_predicate_builder() if projected_predicate else
PredicateBuilder(table.fields)
+ physical = predicates.equal('a_b', 7)
+ assert physical.field_path == (['a_b'] if projected_predicate else None)
+ builder.with_projection(projection)
+ if compound:
+ nested_builder = builder.new_predicate_builder()
+ nested = (nested_builder.less_than('a_b', 10) if compound == 'and'
+ else nested_builder.equal('a_b', 7))
+ combine = PredicateBuilder.and_predicates if compound == 'and' else
PredicateBuilder.or_predicates
+ predicate = combine([nested, physical])
+ else:
+ predicate = physical
+ builder.with_filter(predicate)
+ scan, read = builder.new_scan(), builder.new_read()
+ schema_names = [field.name for field in builder._output_fields()]
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Python scan fallback')))
+ stack.enter_context(patch.object(read, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ splits = scan.plan().splits()
+ for result in (read.to_arrow(splits),
read.to_arrow_batch_reader(splits).read_all()):
+ assert result.schema.names == schema_names
+ assert sorted(result.column('id').to_pylist()) == (
+ [2] if compound == 'and' else [1, 2, 4] if compound == 'or'
else [2, 4])
+ # Iterator reads exercise the same exact filter on Python record readers,
+ # including merged PK records and predicate-only alias collisions.
+ ids = [row.get_field(len(projection) - 1) for row in
read.to_iterator(splits)]
+ assert sorted(ids) == (
+ [2] if compound == 'and' else [1, 2, 4] if compound == 'or' else [2,
4])
+
+
[email protected]('native', [False, True], ids=['python', 'native'])
+def test_nested_projection_keeps_synthesized_row_id_filter_input(tmp_path,
native):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([('id', pa.int32()), ('_ROW', pa.struct([('ID',
pa.int64())]))])
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(
+ schema, options={'bucket': '-1', 'row-tracking.enabled': 'true',
'write.native.enabled': 'false'}), False)
+ table = catalog.get_table('db.t')
+ write_builder = table.new_batch_write_builder()
+ writer, commit = write_builder.new_write(), write_builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': 1, '_ROW': {'ID': 100}}, {'id': 2, '_ROW': {'ID': 0}}],
schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ table = table.copy({'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower()})
+ builder = table.new_read_builder().with_projection(['_ROW_ID', 'id'])
+ predicate = builder.new_predicate_builder().equal('_ROW_ID', 0)
+ builder.with_filter(predicate).with_projection(['_ROW.ID', 'id'])
+ read, scan = builder.new_read(), builder.new_scan()
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(scan.file_scanner, 'scan',
+
side_effect=AssertionError('Python scan fallback')))
+ stack.enter_context(patch.object(read, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ splits = scan.plan().splits()
+ for result in (read.to_arrow(splits),
read.to_arrow_batch_reader(splits).read_all()):
+ assert result.to_pylist() == [{'_ROW_ID': 100, 'id': 1}]
+ assert [row.get_field(1) for row in read.to_iterator(splits)] == [1]
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('evolution', ['promote', 'rename', 'replace'])
[email protected]('projection', [None, 'full'])
+def test_nested_predicate_schema_evolution(tmp_path, mode, evolution,
projection):
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ table = _table(tmp_path, mode)
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ path = ['profile', 'detail', 'score']
+ if evolution == 'promote':
+ changes = [SchemaChange.update_column_type(path, AtomicType('BIGINT'))]
+ case = ('score', 'equal', [7])
+ elif evolution == 'rename':
+ changes = [SchemaChange.rename_column(path, 'renamed')]
+ case = ('renamed', 'equal', [7])
+ else:
+ changes = [SchemaChange.drop_column(path),
SchemaChange.add_column(path, AtomicType('INT'))]
+ case = ('score', 'is_null', [])
+ catalog.alter_table('db.t', changes, False)
+ table = catalog.get_table('db.t')
+ if projection == 'full':
+ projection = ['profile', 'profile.detail.' + case[0], 'id']
+ expected = _read(table, False, False, case, projection, None, None)
+ actual = _read(table, True, False, case, projection, None, None)
+ assert actual == expected