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 8033b47633 [python] Fix filters on non-projected columns (#8509)
8033b47633 is described below

commit 8033b476330cf04a951be30d34b602c3760e0c99
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Jul 9 12:04:41 2026 +0800

    [python] Fix filters on non-projected columns (#8509)
---
 paimon-python/pypaimon/read/push_down_utils.py     |  34 ++++-
 .../read/reader/blob_descriptor_convert_reader.py  |   3 +-
 .../pypaimon/read/reader/data_file_batch_reader.py |   4 +-
 .../pypaimon/read/reader/field_indices.py          |  48 +++++++
 .../read/reader/filter_record_batch_reader.py      |  34 ++++-
 .../read/reader/iface/record_batch_reader.py       |  23 +++-
 .../pypaimon/read/reader/limited_record_reader.py  |   4 +-
 .../read/reader/nested_leaf_batch_reader.py        |   4 +
 .../read/reader/outer_projection_record_reader.py  |  19 +--
 .../read/reader/row_range_filter_record_reader.py  |   3 +-
 .../pypaimon/read/reader/shard_batch_reader.py     |   1 +
 paimon-python/pypaimon/read/split_read.py          | 120 +++++++++--------
 paimon-python/pypaimon/read/table_read.py          |  75 ++++++++++-
 .../pypaimon/tests/data_evolution_test.py          |   9 +-
 .../tests/projection_predicate_index_test.py       | 145 +++++++++++++++++----
 .../pypaimon/tests/reader_parallel_test.py         |  15 +++
 16 files changed, 413 insertions(+), 128 deletions(-)

diff --git a/paimon-python/pypaimon/read/push_down_utils.py 
b/paimon-python/pypaimon/read/push_down_utils.py
index 9c12872d9f..ebf4da3be5 100644
--- a/paimon-python/pypaimon/read/push_down_utils.py
+++ b/paimon-python/pypaimon/read/push_down_utils.py
@@ -21,6 +21,13 @@ from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
 from pypaimon.schema.data_types import DataField
 
+_UNSAFE_ARROW_FILTER_METHODS = frozenset([
+    'startsWith',
+    'endsWith',
+    'contains',
+    'like',
+])
+
 
 def extract_partition_spec_from_predicate(
     predicate: Predicate, partition_keys: List[str]
@@ -117,16 +124,39 @@ def _change_index(input_predicate: Predicate, mapping: 
Dict[int, int]):
     return input_predicate.new_index(mapping[input_predicate.index])
 
 
-def _get_all_fields(predicate: Predicate) -> Set[str]:
+def predicate_field_names(predicate: Predicate) -> Set[str]:
+    """Return all column names referenced by predicate leaves."""
     if predicate.field is not None:
         return {predicate.field}
     involved_fields = set()
     if predicate.literals:
         for sub_predicate in predicate.literals:
-            involved_fields.update(_get_all_fields(sub_predicate))
+            involved_fields.update(predicate_field_names(sub_predicate))
     return involved_fields
 
 
+def _get_all_fields(predicate: Predicate) -> Set[str]:
+    return predicate_field_names(predicate)
+
+
+def predicate_supports_arrow_filter(predicate: Optional[Predicate]) -> bool:
+    """Whether ``predicate.to_arrow()`` is safe for batch filtering.
+
+    PyArrow 6 accepts dataset expressions for comparisons, null checks, and
+    isin, but string match compute functions do not accept dataset expressions.
+    Predicate.to_arrow() currently falls back to a truthy expression or None 
for
+    those methods, which is safe for file pruning but not for final row 
filters.
+    """
+    if predicate is None:
+        return True
+    if predicate.method == 'and' or predicate.method == 'or':
+        return all(
+            predicate_supports_arrow_filter(p)
+            for p in (predicate.literals or [])
+        )
+    return predicate.method not in _UNSAFE_ARROW_FILTER_METHODS
+
+
 def remove_row_id_filter(predicate: Predicate) -> Optional[Predicate]:
     from pypaimon.table.special_fields import SpecialFields
 
diff --git 
a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py 
b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
index f6c5f73cfe..48cee27988 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -59,8 +59,7 @@ class BlobInlineConvertReader(RecordBatchReader):
         self._table = table
         self._prescan_reader_factory = prescan_reader_factory
         self._blob_parallelism = blob_parallelism
-        self.file_io = inner.file_io
-        self.blob_field_indices = inner.blob_field_indices
+        self._adopt_metadata(inner)
         # Preserve original BlobViewStruct bytes when resolve disabled: skip 
both
         # view resolution (Stage 1) and descriptor-to-data resolution (Stage 
2).
         resolve_enabled = CoreOptions.blob_view_resolve_enabled(
diff --git a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py 
b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
index 0183396fe7..1de9f7a405 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -344,7 +344,9 @@ class DataFileBatchReader(RecordBatchReader):
         # Handle _ROW_ID field
         if SpecialFields.ROW_ID.name in self.system_fields.keys():
             idx = self.system_fields[SpecialFields.ROW_ID.name]
-            if self.row_id_offsets is not None:
+            if self.first_row_id is None:
+                arrays[idx] = pa.nulls(record_batch.num_rows, type=pa.int64())
+            elif self.row_id_offsets is not None:
                 end = self._row_id_cursor + record_batch.num_rows
                 row_ids = [self.first_row_id + o for o in 
self.row_id_offsets[self._row_id_cursor:end]]
                 arrays[idx] = pa.array(row_ids, type=pa.int64())
diff --git a/paimon-python/pypaimon/read/reader/field_indices.py 
b/paimon-python/pypaimon/read/reader/field_indices.py
new file mode 100644
index 0000000000..02060a2f51
--- /dev/null
+++ b/paimon-python/pypaimon/read/reader/field_indices.py
@@ -0,0 +1,48 @@
+# 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.
+
+"""Helpers for carrying typed field metadata through projection wrappers."""
+
+from typing import Any, Iterable, List, Optional, Sequence, Set
+
+from pypaimon.schema.data_types import DataField, VectorType
+
+
+def blob_field_indices(fields: List[DataField]) -> Set[int]:
+    return {
+        i for i, f in enumerate(fields)
+        if hasattr(f.type, 'type') and f.type.type == 'BLOB'
+    }
+
+
+def vector_field_indices(fields: List[DataField]) -> Set[int]:
+    return {i for i, f in enumerate(fields) if isinstance(f.type, VectorType)}
+
+
+def project_top_level_field_indices(
+    source_indices: Optional[Iterable[int]],
+    path_specs: Sequence[Any],
+) -> Optional[Set[int]]:
+    """Map top-level field indices through path specs with 
top_idx/sub_names."""
+    if source_indices is None:
+        return None
+    source = set(source_indices)
+    return {
+        proj_pos
+        for proj_pos, spec in enumerate(path_specs)
+        if not spec.sub_names and spec.top_idx in source
+    }
diff --git a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py 
b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
index a6039c46a8..fdebbc99cb 100644
--- a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
@@ -20,9 +20,14 @@ from typing import List, Optional
 
 import pyarrow as pa
 import pyarrow.dataset as ds
+import polars
 
 from pypaimon.common.predicate import Predicate
-from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
+from pypaimon.read.push_down_utils import predicate_supports_arrow_filter
+from pypaimon.read.reader.iface.record_batch_reader import (
+    InternalRowWrapperIterator,
+    RecordBatchReader,
+)
 from pypaimon.schema.data_types import DataField
 
 logger = logging.getLogger(__name__)
@@ -46,8 +51,8 @@ class FilterRecordBatchReader(RecordBatchReader):
         self.predicate = predicate
         self.field_names = field_names
         self.schema_fields = schema_fields
-        self.file_io = reader.file_io
-        self.blob_field_indices = reader.blob_field_indices
+        self._use_arrow_filter = predicate_supports_arrow_filter(predicate)
+        self._adopt_metadata(reader)
 
     def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
         while True:
@@ -62,7 +67,11 @@ class FilterRecordBatchReader(RecordBatchReader):
             continue
 
     def _filter_batch(self, batch: pa.RecordBatch) -> Optional[pa.RecordBatch]:
+        if not self._use_arrow_filter:
+            return self._filter_batch_by_row(batch)
         expr = self.predicate.to_arrow()
+        if expr is None:
+            return self._filter_batch_by_row(batch)
         result = ds.InMemoryDataset(pa.Table.from_batches([batch])).scanner(
             filter=expr
         ).to_table()
@@ -81,6 +90,25 @@ class FilterRecordBatchReader(RecordBatchReader):
             schema=result.schema,
         )
 
+    def _filter_batch_by_row(self, batch: pa.RecordBatch) -> 
Optional[pa.RecordBatch]:
+        df = polars.from_arrow(pa.Table.from_batches([batch]))
+        iterator = InternalRowWrapperIterator(
+            self._iter_df_rows(df),
+            df.width,
+            self.file_io,
+            self.blob_field_indices,
+            self.vector_field_indices,
+        )
+        selected = []
+        pos = 0
+        for row in iter(iterator.next, None):
+            if self.predicate.test(row):
+                selected.append(pos)
+            pos += 1
+        if not selected:
+            return None
+        return batch.take(pa.array(selected, type=pa.int64()))
+
     def return_batch_pos(self) -> int:
         pos = getattr(self.reader, 'return_batch_pos', lambda: 0)()
         return pos if pos is not None else 0
diff --git a/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py 
b/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
index 6cd45bf63d..7888f2fab5 100644
--- a/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/iface/record_batch_reader.py
@@ -19,7 +19,7 @@ from abc import abstractmethod
 from typing import Iterator, Optional, TypeVar
 
 import polars
-from pyarrow import RecordBatch
+from pyarrow import RecordBatch, Table
 
 from pypaimon.read.reader.iface.record_iterator import RecordIterator
 from pypaimon.read.reader.iface.record_reader import RecordReader
@@ -38,6 +38,11 @@ class RecordBatchReader(RecordReader):
     blob_field_indices = None
     vector_field_indices = None
 
+    def _adopt_metadata(self, reader: "RecordBatchReader") -> None:
+        self.file_io = reader.file_io
+        self.blob_field_indices = reader.blob_field_indices
+        self.vector_field_indices = reader.vector_field_indices
+
     @abstractmethod
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         """
@@ -53,22 +58,31 @@ class RecordBatchReader(RecordReader):
         arrow_batch = self.read_arrow_batch()
         if arrow_batch is None:
             return None
-        return polars.from_arrow(arrow_batch)
+        # Older Polars versions accept Arrow Table but not RecordBatch.
+        return polars.from_arrow(Table.from_batches([arrow_batch]))
 
     def tuple_iterator(self) -> Optional[Iterator[tuple]]:
         df = self.read_next_df()
         if df is None:
             return None
-        return df.iter_rows()
+        return self._iter_df_rows(df)
 
     def read_batch(self) -> Optional[RecordIterator[InternalRow]]:
         df = self.read_next_df()
         if df is None:
             return None
         return InternalRowWrapperIterator(
-            df.iter_rows(), df.width, self.file_io,
+            self._iter_df_rows(df), df.width, self.file_io,
             self.blob_field_indices, self.vector_field_indices)
 
+    @staticmethod
+    def _iter_df_rows(df) -> Iterator[tuple]:
+        iter_rows = getattr(df, "iter_rows", None)
+        if iter_rows is not None:
+            return iter_rows()
+        # Older Polars exposes eager row iteration as rows().
+        return iter(df.rows())
+
 
 class InternalRowWrapperIterator(RecordIterator[InternalRow]):
     def __init__(self, iterator: Iterator[tuple], width: int,
@@ -94,6 +108,7 @@ class RowPositionReader(RecordBatchReader):
         self._data_reader = data_reader
         self._row_iterator = RowPositionRecordIterator()
         self.batch_pos = 0
+        self._adopt_metadata(data_reader)
 
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         batch = self._data_reader.read_arrow_batch()
diff --git a/paimon-python/pypaimon/read/reader/limited_record_reader.py 
b/paimon-python/pypaimon/read/reader/limited_record_reader.py
index f78221d3f8..a4eab01986 100644
--- a/paimon-python/pypaimon/read/reader/limited_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/limited_record_reader.py
@@ -88,9 +88,7 @@ class LimitedRecordBatchReader(RecordBatchReader):
         self._inner = inner
         self._limit = limit
         self.count = 0
-        self.file_io = inner.file_io
-        self.blob_field_indices = inner.blob_field_indices
-        self.vector_field_indices = inner.vector_field_indices
+        self._adopt_metadata(inner)
 
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         if self.count >= self._limit:
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py 
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
index 8f042a9229..cd849454a7 100644
--- a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -21,6 +21,7 @@ import pyarrow as pa
 import pyarrow.compute as pc
 from pyarrow import RecordBatch
 
+from pypaimon.read.reader.field_indices import blob_field_indices, 
vector_field_indices
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser
 
@@ -44,6 +45,9 @@ class NestedLeafBatchReader(RecordBatchReader):
         self._inner = inner
         self._paths = name_paths
         self._schema = PyarrowFieldParser.from_paimon_schema(output_fields)
+        self.file_io = inner.file_io
+        self.blob_field_indices = blob_field_indices(output_fields)
+        self.vector_field_indices = vector_field_indices(output_fields)
 
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         batch = self._inner.read_arrow_batch()
diff --git 
a/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py 
b/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
index 124a957183..e8bb475097 100644
--- a/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/outer_projection_record_reader.py
@@ -27,6 +27,7 @@ wrapper extracts the user-visible flat columns afterwards.
 
 from typing import Any, List, Optional
 
+from pypaimon.read.reader.field_indices import project_top_level_field_indices
 from pypaimon.read.reader.iface.record_iterator import RecordIterator
 from pypaimon.read.reader.iface.record_reader import RecordReader
 from pypaimon.table.row.internal_row import InternalRow
@@ -62,20 +63,10 @@ class 
OuterProjectionRecordReader(RecordReader[InternalRow]):
         self._inner = inner
         self._flat_arity = len(name_paths)
         self._file_io = file_io
-        self._blob_field_indices = None
-        if blob_field_indices is not None:
-            self._blob_field_indices = {
-                proj_pos
-                for proj_pos, spec in enumerate(self._specs)
-                if not spec.sub_names and spec.top_idx in blob_field_indices
-            }
-        self._vector_field_indices = None
-        if vector_field_indices is not None:
-            self._vector_field_indices = {
-                proj_pos
-                for proj_pos, spec in enumerate(self._specs)
-                if not spec.sub_names and spec.top_idx in vector_field_indices
-            }
+        self._blob_field_indices = project_top_level_field_indices(
+            blob_field_indices, self._specs)
+        self._vector_field_indices = project_top_level_field_indices(
+            vector_field_indices, self._specs)
 
     def read_batch(self) -> Optional[RecordIterator[InternalRow]]:
         inner_batch = self._inner.read_batch()
diff --git 
a/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py 
b/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
index 5f97fb4ad5..d25434f396 100644
--- a/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
+++ b/paimon-python/pypaimon/read/reader/row_range_filter_record_reader.py
@@ -32,8 +32,7 @@ class RowIdFilterRecordBatchReader(RecordBatchReader):
         self.reader = reader
         self.current_row_id = first_row_id
         self.row_id_ranges = row_id_ranges
-        self.file_io = reader.file_io
-        self.blob_field_indices = reader.blob_field_indices
+        self._adopt_metadata(reader)
 
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         while True:
diff --git a/paimon-python/pypaimon/read/reader/shard_batch_reader.py 
b/paimon-python/pypaimon/read/reader/shard_batch_reader.py
index 3d5cf77f68..aa00814b82 100644
--- a/paimon-python/pypaimon/read/reader/shard_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/shard_batch_reader.py
@@ -31,6 +31,7 @@ class ShardBatchReader(RecordBatchReader):
         self.start_pos = start_pos
         self.end_pos = end_pos
         self.current_pos = 0
+        self._adopt_metadata(reader)
 
     def read_arrow_batch(self) -> Optional[RecordBatch]:
         # Check if reader is FormatBlobReader (blob type)
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index 9167b7824a..d230975d5c 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -32,7 +32,11 @@ from pypaimon.globalindex import Range
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
 from pypaimon.read.interval_partition import IntervalPartition, SortedRun
 from pypaimon.read.partition_info import PartitionInfo
-from pypaimon.read.push_down_utils import rewrite_predicate_indices, 
trim_predicate_by_fields
+from pypaimon.read.push_down_utils import (
+    predicate_field_names,
+    rewrite_predicate_indices,
+    trim_predicate_by_fields,
+)
 from pypaimon.read.reader.concat_batch_reader import (
     BlobFallbackBatchReader, ConcatBatchReader,
     MergeAllBatchReader, DataEvolutionMergeReader)
@@ -42,6 +46,8 @@ from pypaimon.read.reader.data_file_batch_reader import 
DataFileBatchReader
 from pypaimon.read.reader.drop_delete_reader import DropDeleteRecordReader
 from pypaimon.read.reader.empty_record_reader import EmptyFileRecordReader
 from pypaimon.read.reader.field_bunch import BlobBunch, DataBunch, FieldBunch, 
VectorBunch
+from pypaimon.read.reader.field_indices import (
+    blob_field_indices, vector_field_indices)
 from pypaimon.read.reader.filter_record_reader import FilterRecordReader
 from pypaimon.read.reader.format_avro_reader import FormatAvroReader
 from pypaimon.read.reader.blob_descriptor_convert_reader import 
BlobInlineConvertReader
@@ -65,7 +71,6 @@ from pypaimon.read.reader.aggregation_merge_function import (
     AggregateMergeFunction, build_field_aggregators)
 from pypaimon.read.reader.sort_merge_reader import (SortMergeReaderWithMinHeap,
                                                     builtin_seq_comparator)
-from pypaimon.read.push_down_utils import _get_all_fields
 from pypaimon.read.split import Split
 from pypaimon.read.sliced_split import SlicedSplit
 from pypaimon.schema.data_types import DataField, PyarrowFieldParser
@@ -81,16 +86,6 @@ ROW_SIDECAR_FORMAT = CoreOptions.FILE_FORMAT_ROW
 _COMPRESS_EXTENSIONS = frozenset(['gz', 'bz2', 'deflate', 'snappy', 'lz4', 
'zst'])
 
 
-def _blob_field_indices(fields: List[DataField]) -> set:
-    return {i for i, f in enumerate(fields)
-            if hasattr(f.type, 'type') and f.type.type == 'BLOB'}
-
-
-def _vector_field_indices(fields: List[DataField]) -> set:
-    from pypaimon.schema.data_types import VectorType
-    return {i for i, f in enumerate(fields) if isinstance(f.type, VectorType)}
-
-
 def format_identifier(file_name):
     idx = file_name.rfind('.')
     assert idx != -1, "%s is not a legal file name." % file_name
@@ -151,7 +146,7 @@ class SplitRead(ABC):
         read_type_names = {f.name for f in read_type}
         if (
                 self.predicate is not None
-                and _get_all_fields(self.predicate).issubset(read_type_names)
+                and 
predicate_field_names(self.predicate).issubset(read_type_names)
         ):
             self.predicate_for_reader = rewrite_predicate_indices(
                 self.predicate, read_type
@@ -309,7 +304,9 @@ class SplitRead(ABC):
                 raise NotImplementedError(
                     "Nested-field projection is not supported on Vortex files")
             ordered_read_fields = [name_to_field[n] for n in read_file_fields 
if n in name_to_field]
-            predicate_fields = _get_all_fields(self.push_down_predicate) if 
self.push_down_predicate else set()
+            predicate_fields = (
+                predicate_field_names(self.push_down_predicate)
+                if self.push_down_predicate else set())
             format_reader = FormatVortexReader(self.table.file_io, file_path, 
ordered_read_fields,
                                                read_arrow_predicate, 
batch_size=batch_size,
                                                row_indices=row_indices,
@@ -787,50 +784,35 @@ class RawFileSplitRead(SplitRead):
 
         concat_reader = ConcatBatchReader(
             data_readers, file_io=self.table.file_io,
-            blob_field_indices=_blob_field_indices(self.read_fields),
-            vector_field_indices=_vector_field_indices(self.read_fields))
-        # if the table is appendonly table, we don't need extra filter, all 
predicates has pushed down
+            blob_field_indices=blob_field_indices(self.read_fields),
+            vector_field_indices=vector_field_indices(self.read_fields))
+        reader = concat_reader
         if self.table.is_primary_key_table and self.predicate_for_reader:
-            reader = FilterRecordReader(concat_reader, 
self.predicate_for_reader)
-            if self.outer_extract_name_paths:
-                # Row-level extraction: the filter evaluates rows in the
-                # widened top-level coordinate space, so extract after it.
-                from pypaimon.read.reader.outer_projection_record_reader 
import \
-                    OuterProjectionRecordReader
-                reader = OuterProjectionRecordReader(
-                    reader, [f.name for f in self.read_fields],
-                    self.outer_extract_name_paths,
-                    file_io=self.table.file_io,
-                    blob_field_indices=_blob_field_indices(self.read_fields),
-                    
vector_field_indices=_vector_field_indices(self.read_fields))
-            if self.limit is not None:
-                reader = LimitedRecordReader(reader, self.limit)
-        else:
-            reader = concat_reader
-            if self.outer_extract_name_paths:
-                from pypaimon.read.reader.nested_leaf_batch_reader import \
-                    NestedLeafBatchReader
-                reader = NestedLeafBatchReader(
-                    reader, self.outer_extract_name_paths,
-                    self.outer_flat_read_type)
-                # A predicate on a projected nested leaf cannot be pushed down:
-                # its leaf path is absent from the widened top-level read
-                # fields, so SplitRead.__init__ dropped it 
(predicate_for_reader
-                # is None). Without re-applying it the filter is silently lost
-                # and every row is returned. Re-evaluate it on the extracted
-                # flat batches, whose column names match the predicate fields;
-                # trim to the projected columns so a filter on a non-projected
-                # column keeps the existing "dropped" semantics rather than
-                # referencing a missing column.
-                if self.predicate is not None and self.predicate_for_reader is 
None:
-                    flat_names = [f.name for f in self.outer_flat_read_type]
-                    trimmed = trim_predicate_by_fields(self.predicate, 
flat_names)
-                    if trimmed is not None:
-                        from pypaimon.read.reader.filter_record_batch_reader \
-                            import FilterRecordBatchReader
-                        reader = FilterRecordBatchReader(reader, trimmed)
-            if self.limit is not None:
-                reader = LimitedRecordBatchReader(reader, self.limit)
+            reader = FilterRecordBatchReader(
+                reader,
+                self.predicate_for_reader,
+                field_names=[f.name for f in self.read_fields],
+                schema_fields=self.read_fields,
+            )
+        if self.outer_extract_name_paths:
+            from pypaimon.read.reader.nested_leaf_batch_reader import \
+                NestedLeafBatchReader
+            reader = NestedLeafBatchReader(
+                reader, self.outer_extract_name_paths,
+                self.outer_flat_read_type)
+            # A predicate on a projected nested leaf cannot be pushed down:
+            # its leaf path is absent from the widened top-level read fields,
+            # so SplitRead.__init__ dropped it (predicate_for_reader is None).
+            # Without re-applying it the filter is silently lost and every row
+            # is returned. Re-evaluate it on the extracted flat batches, whose
+            # column names match the predicate fields.
+            if self.predicate is not None and self.predicate_for_reader is 
None:
+                flat_names = [f.name for f in self.outer_flat_read_type]
+                trimmed = trim_predicate_by_fields(self.predicate, flat_names)
+                if trimmed is not None:
+                    reader = FilterRecordBatchReader(reader, trimmed)
+        if self.limit is not None:
+            reader = LimitedRecordBatchReader(reader, self.limit)
         return reader
 
     def _all_data_fields_from(self, fields):
@@ -960,8 +942,8 @@ class MergeFileSplitRead(SplitRead):
                 reader, [f.name for f in inner_value_fields],
                 self.outer_extract_name_paths,
                 file_io=self.table.file_io,
-                blob_field_indices=_blob_field_indices(inner_value_fields),
-                vector_field_indices=_vector_field_indices(inner_value_fields))
+                blob_field_indices=blob_field_indices(inner_value_fields),
+                vector_field_indices=vector_field_indices(inner_value_fields))
             # A predicate on a projected nested leaf is not pushed down (its 
leaf
             # path is absent from the widened-to-full-ROW read fields, so it 
was
             # dropped in __init__). Without re-applying it after extraction the
@@ -995,7 +977,9 @@ class DataEvolutionSplitRead(SplitRead):
             split: Split,
             row_tracking_enabled: bool,
             nested_name_paths: Optional[List[List[str]]] = None,
-            limit: Optional[int] = None):
+            limit: Optional[int] = None,
+            outer_extract_name_paths: Optional[List[List[str]]] = None,
+            outer_flat_read_type: Optional[List[DataField]] = None):
         self.row_ranges = None
         actual_split = split
         if isinstance(split, IndexedSplit):
@@ -1006,6 +990,8 @@ class DataEvolutionSplitRead(SplitRead):
             nested_name_paths=nested_name_paths,
             limit=limit,
         )
+        self.outer_extract_name_paths = outer_extract_name_paths
+        self.outer_flat_read_type = outer_flat_read_type
 
     def _push_down_predicate(self) -> Optional[Predicate]:
         # Data evolution: files may have different schemas, so we don't push 
predicate
@@ -1051,8 +1037,8 @@ class DataEvolutionSplitRead(SplitRead):
 
         merge_reader = ConcatBatchReader(
             suppliers, file_io=self.table.file_io,
-            blob_field_indices=_blob_field_indices(self.read_fields),
-            vector_field_indices=_vector_field_indices(self.read_fields))
+            blob_field_indices=blob_field_indices(self.read_fields),
+            vector_field_indices=vector_field_indices(self.read_fields))
         if self.predicate_for_reader is not None:
             reader = FilterRecordBatchReader(
                 merge_reader,
@@ -1063,6 +1049,16 @@ class DataEvolutionSplitRead(SplitRead):
         else:
             reader = merge_reader
 
+        if self.outer_extract_name_paths:
+            if self.outer_flat_read_type is None:
+                raise ValueError(
+                    "outer_flat_read_type is required when 
outer_extract_name_paths "
+                    "is set")
+            from pypaimon.read.reader.nested_leaf_batch_reader import \
+                NestedLeafBatchReader
+            reader = NestedLeafBatchReader(
+                reader, self.outer_extract_name_paths, 
self.outer_flat_read_type)
+
         if self.limit is not None:
             reader = LimitedRecordBatchReader(reader, self.limit)
 
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index b19fff109d..7c4a98f3d1 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -23,6 +23,7 @@ import pandas
 import pyarrow
 
 from pypaimon.common.predicate import Predicate
+from pypaimon.read.push_down_utils import predicate_field_names
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.read.split import Split
 from pypaimon.read.split_read import (DataEvolutionSplitRead,
@@ -99,6 +100,12 @@ class TableRead:
         self.table: FileStoreTable = table
         self.predicate = predicate
         self.read_type = read_type
+        # 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``.
+        self._predicate_extra_fields = 
self._predicate_fields_outside_read_type()
+        self._scan_read_type = self.read_type + self._predicate_extra_fields
+        self._output_column_names = [f.name for f in self.read_type]
         self.include_row_kind = include_row_kind
         self.nested_name_paths = nested_name_paths
         self.limit = limit
@@ -222,6 +229,7 @@ class TableRead:
                     for batch in iter(reader.read_arrow_batch, None):
                         if remaining is not None and batch.num_rows > 
remaining:
                             batch = batch.slice(0, remaining)
+                        batch = self._project_batch_to_output(batch)
                         if self.include_row_kind:
                             batch = self._add_row_kind_column_to_batch(batch, 
"+I")
                         yield batch
@@ -388,6 +396,7 @@ class TableRead:
                         break
                     if allowed < batch.num_rows:
                         batch = batch.slice(0, allowed)
+                    batch = self._project_batch_to_output(batch)
                     if self.include_row_kind:
                         batch = self._add_row_kind_column_to_batch(batch, "+I")
                     out.append(batch)
@@ -627,13 +636,14 @@ class TableRead:
 
     def _build_split_read(self, split: Split) -> SplitRead:
         if self.table.is_primary_key_table and not split.raw_convertible:
-            inner_read_type = self.read_type
+            inner_read_type = self._scan_read_type
             outer_extract_name_paths: Optional[List[List[str]]] = None
             if self.nested_name_paths and any(
                     len(p) > 1 for p in self.nested_name_paths):
                 # Inner: full ROW for the merge function. Outer: extract
                 # the requested sub-paths back to the user's flat schema.
-                inner_read_type = self._widen_to_top_level_for_merge()
+                inner_read_type = self._with_predicate_extra_fields(
+                    self._widen_to_top_level_for_merge())
                 outer_extract_name_paths = self.nested_name_paths
 
             # When the user's projection drops a ``sequence.field``, the merge
@@ -661,6 +671,10 @@ class TableRead:
                         # user's requested (flat) columns in order.
                         outer_extract_name_paths = [
                             [f.name] for f in self.read_type]
+            if outer_extract_name_paths is None and 
self._needs_output_projection():
+                # Split readers own the output projection for iterator reads;
+                # the TableRead Arrow projection below is a final guard.
+                outer_extract_name_paths = self._output_extract_name_paths()
             return MergeFileSplitRead(
                 table=self.table,
                 predicate=self.predicate,
@@ -678,17 +692,24 @@ class TableRead:
                 raise NotImplementedError(
                     "Nested-field projection on data-evolution tables is "
                     "not yet supported")
+            outer_extract_name_paths = None
+            if self._needs_output_projection():
+                # Keep iterator output narrow inside DataEvolutionSplitRead.
+                outer_extract_name_paths = self._output_extract_name_paths()
             return DataEvolutionSplitRead(
                 table=self.table,
                 predicate=self.predicate,
-                read_type=self.read_type,
+                read_type=self._scan_read_type,
                 split=split,
                 row_tracking_enabled=True,
                 nested_name_paths=self.nested_name_paths,
+                outer_extract_name_paths=outer_extract_name_paths,
+                outer_flat_read_type=(
+                    self.read_type if outer_extract_name_paths else None),
                 limit=self.limit,
             )
         else:
-            inner_read_type = self.read_type
+            inner_read_type = self._scan_read_type
             outer_extract_name_paths: Optional[List[List[str]]] = None
             if self.nested_name_paths and any(
                     len(p) > 1 for p in self.nested_name_paths):
@@ -697,8 +718,13 @@ class TableRead:
                 # only valid against the latest schema, not each file's own
                 # names/types), then extract the requested sub-paths back to
                 # the user's flat schema.
-                inner_read_type = self._widen_to_top_level_for_merge()
+                inner_read_type = self._with_predicate_extra_fields(
+                    self._widen_to_top_level_for_merge())
                 outer_extract_name_paths = self.nested_name_paths
+            if outer_extract_name_paths is None and 
self._needs_output_projection():
+                # Split readers own the output projection for iterator reads;
+                # the TableRead Arrow projection below is a final guard.
+                outer_extract_name_paths = self._output_extract_name_paths()
             return RawFileSplitRead(
                 table=self.table,
                 predicate=self.predicate,
@@ -711,6 +737,45 @@ class TableRead:
                 limit=self.limit,
             )
 
+    def _project_batch_to_output(self, batch: pyarrow.RecordBatch) -> 
pyarrow.RecordBatch:
+        if not self._needs_output_projection():
+            return batch
+        if batch.schema.names == self._output_column_names:
+            return batch
+        name_to_pos = {name: i for i, name in enumerate(batch.schema.names)}
+        arrays = [batch.column(name_to_pos[name]) for name in 
self._output_column_names]
+        fields = [batch.schema.field(name_to_pos[name]) for name in 
self._output_column_names]
+        return pyarrow.RecordBatch.from_arrays(
+            arrays, schema=pyarrow.schema(fields))
+
+    def _needs_output_projection(self) -> bool:
+        return bool(self._predicate_extra_fields)
+
+    def _output_extract_name_paths(self) -> List[List[str]]:
+        return [[f.name] for f in self.read_type]
+
+    def _with_predicate_extra_fields(self, fields: List[DataField]) -> 
List[DataField]:
+        names = {f.name for f in fields}
+        extras = [f for f in self._predicate_extra_fields if f.name not in 
names]
+        return fields + extras
+
+    def _predicate_fields_outside_read_type(self) -> List[DataField]:
+        if self.predicate is None:
+            return []
+        read_names = {f.name for f in self.read_type}
+        predicate_fields = predicate_field_names(self.predicate)
+        missing = predicate_fields - read_names
+        if not missing:
+            return []
+        return [f for f in self._table_read_fields() if f.name in missing]
+
+    def _table_read_fields(self) -> List[DataField]:
+        from pypaimon.table.special_fields import SpecialFields
+        fields = self.table.fields
+        if self.table.options.row_tracking_enabled():
+            fields = SpecialFields.row_type_with_row_tracking(fields)
+        return fields
+
     def _widen_to_top_level_for_merge(self) -> List[DataField]:
         """Unique top-level fields from ``self.nested_name_paths``, in path 
order."""
         table_fields_by_name = {f.name: f for f in self.table.fields}
diff --git a/paimon-python/pypaimon/tests/data_evolution_test.py 
b/paimon-python/pypaimon/tests/data_evolution_test.py
index b0090f1a56..ffa10c018e 100644
--- a/paimon-python/pypaimon/tests/data_evolution_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_test.py
@@ -796,9 +796,10 @@ class DataEvolutionTest(unittest.TestCase):
         )
         self.assertEqual(
             len(result_non_projected),
-            3,
-            "Filter c > 150 with projection [id]: c not in read_type so filter 
is dropped, all 3 rows returned.",
+            1,
+            "Filter c > 150 with projection [id] should still evaluate c 
internally.",
         )
+        self.assertEqual(result_non_projected["id"].tolist(), [3])
         self.assertEqual(
             list(result_non_projected.columns),
             ["id"],
@@ -810,13 +811,13 @@ class DataEvolutionTest(unittest.TestCase):
         try:
             rows_from_iterator = list(table_read.to_iterator(splits))
         except ValueError as e:
-            if "Expected Arrow table or array" in str(e):
+            if "Expected Arrow" in str(e) and "RecordBatch" in str(e):
                 self.skipTest(
                     "RecordBatchReader path uses 
polars.from_arrow(RecordBatch) which fails; "
                     "skip to_iterator projection assertion on this path"
                 )
             raise
-        self.assertEqual(len(rows_from_iterator), 3, "to_iterator should 
return same row count as to_pandas")
+        self.assertEqual(len(rows_from_iterator), 1, "to_iterator should 
return same row count as to_pandas")
         for row in rows_from_iterator:
             self.assertIsInstance(row, OffsetRow)
             self.assertEqual(
diff --git a/paimon-python/pypaimon/tests/projection_predicate_index_test.py 
b/paimon-python/pypaimon/tests/projection_predicate_index_test.py
index 493efb21a0..2953456223 100644
--- a/paimon-python/pypaimon/tests/projection_predicate_index_test.py
+++ b/paimon-python/pypaimon/tests/projection_predicate_index_test.py
@@ -91,11 +91,38 @@ class ProjectionPredicateIndexTest(unittest.TestCase):
         w.close()
         return self.catalog.get_table(full)
 
+    def _populate_data_evolution(self, table_name: str):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            options={
+                'row-tracking.enabled': 'true',
+                'data-evolution.enabled': 'true',
+            },
+        )
+        full = 'default.{}'.format(table_name)
+        self.catalog.create_table(full, schema, False)
+        table = self.catalog.get_table(full)
+        wb = table.new_batch_write_builder()
+        w = wb.new_write()
+        w.write_arrow(self.data)
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+        return self.catalog.get_table(full)
+
     def _read(self, read_builder):
         scan = read_builder.new_scan()
         read = read_builder.new_read()
         return read.to_arrow(scan.plan().splits())
 
+    @staticmethod
+    def _rows_by_id(table):
+        data = table.to_pydict()
+        rows = [
+            {name: values[i] for name, values in data.items()}
+            for i in range(table.num_rows)
+        ]
+        return sorted(rows, key=lambda row: row['id'])
+
     def test_pk_filter_on_non_pk_with_projection_keeping_filter_column(self):
         """The OffsetRow handed to FilterRecordReader uses read_type indices.
         Before the fix this used to raise IndexError because the predicate
@@ -108,12 +135,12 @@ class ProjectionPredicateIndexTest(unittest.TestCase):
 
         # Projection narrows read_type from [id, name, value] to [id, value]
         rb = table.new_read_builder().with_projection(['id', 
'value']).with_filter(pred)
-        actual = self._read(rb).sort_by('id')
+        actual = self._read(rb)
+        rows = self._rows_by_id(actual)
 
         self.assertEqual(actual.num_rows, 1)
         self.assertEqual(actual.column_names, ['id', 'value'])
-        self.assertEqual(actual.column('id').to_pylist(), [3])
-        self.assertEqual(actual.column('value').to_pylist(), [30])
+        self.assertEqual(rows, [{'id': 3, 'value': 30}])
 
     def test_pk_filter_on_non_pk_with_projection_reordering_columns(self):
         """Reordering the projection (value before id) changes the column
@@ -125,12 +152,15 @@ class ProjectionPredicateIndexTest(unittest.TestCase):
         pred = pb.greater_than('value', 15)
 
         rb = table.new_read_builder().with_projection(['value', 
'id']).with_filter(pred)
-        actual = self._read(rb).sort_by('id')
+        actual = self._read(rb)
+        rows = self._rows_by_id(actual)
 
         self.assertEqual(actual.num_rows, 2)
         self.assertEqual(actual.column_names, ['value', 'id'])
-        self.assertEqual(actual.column('id').to_pylist(), [2, 3])
-        self.assertEqual(actual.column('value').to_pylist(), [20, 30])
+        self.assertEqual(rows, [
+            {'value': 20, 'id': 2},
+            {'value': 30, 'id': 3},
+        ])
 
     def test_pk_filter_no_projection_still_works(self):
         """Sanity: with no projection, behaviour must match the pre-fix 
path."""
@@ -140,47 +170,77 @@ class ProjectionPredicateIndexTest(unittest.TestCase):
         pred = pb.equal('value', 20)
 
         rb = table.new_read_builder().with_filter(pred)
-        actual = self._read(rb).sort_by('id')
+        actual = self._read(rb)
+        rows = self._rows_by_id(actual)
 
         self.assertEqual(actual.num_rows, 1)
-        self.assertEqual(actual.column('name').to_pylist(), ['b'])
-        self.assertEqual(actual.column('value').to_pylist(), [20])
-
-    def test_pk_filter_column_outside_projection_drops_filter(self):
-        """When the filter column is *not* projected we cannot evaluate the
-        predicate at row level — the contract is to fall through (no
-        FilterRecordReader). The reader still produces the projected columns,
-        and the count must not be smaller than the unfiltered projection.
-        """
+        self.assertEqual(rows, [{'id': 2, 'name': 'b', 'value': 20}])
+
+    def test_pk_filter_column_outside_projection_still_filters(self):
+        """The filter column does not have to be visible in the final 
output."""
         table = self._populate_pk('test_pk_filter_outside_proj')
 
         pb = table.new_read_builder().new_predicate_builder()
         pred = pb.equal('value', 30)
 
-        # value is NOT in the projection — predicate cannot apply at row level
         rb = table.new_read_builder().with_projection(['id']).with_filter(pred)
-        actual = self._read(rb).sort_by('id')
+        actual = self._read(rb)
+
+        self.assertEqual(actual.num_rows, 1)
+        self.assertEqual(actual.column_names, ['id'])
+        self.assertEqual(sorted(actual.column('id').to_pylist()), [3])
 
-        # All three rows are still returned because the filter is dropped,
-        # not misapplied with a stale index.
-        self.assertEqual(actual.num_rows, 3)
+    def test_pk_string_filter_outside_projection_still_filters(self):
+        table = self._populate_pk('test_pk_string_filter_outside_proj')
+
+        pb = table.new_read_builder().new_predicate_builder()
+        pred = pb.startswith('name', 'a')
+
+        rb = table.new_read_builder().with_projection(['id']).with_filter(pred)
+        actual = self._read(rb)
+
+        self.assertEqual(actual.num_rows, 1)
         self.assertEqual(actual.column_names, ['id'])
-        self.assertEqual(actual.column('id').to_pylist(), [1, 2, 3])
+        self.assertEqual(actual.column('id').to_pylist(), [1])
 
     def test_append_only_filter_with_projection_unchanged(self):
-        """Append-only path uses arrow file-level pushdown by field name, not
-        by index. Guard against any regression introduced by the fix.
-        """
         table = self._populate_append('test_append_proj_filter')
 
         pb = table.new_read_builder().new_predicate_builder()
         pred = pb.equal('value', 30)
 
         rb = table.new_read_builder().with_projection(['id', 
'value']).with_filter(pred)
-        actual = self._read(rb).sort_by('id')
+        actual = self._read(rb)
+        rows = self._rows_by_id(actual)
 
         self.assertEqual(actual.num_rows, 1)
         self.assertEqual(actual.column_names, ['id', 'value'])
+        self.assertEqual(rows, [{'id': 3, 'value': 30}])
+
+    def test_append_only_filter_column_outside_projection_still_filters(self):
+        table = self._populate_append('test_append_filter_outside_proj')
+
+        pb = table.new_read_builder().new_predicate_builder()
+        pred = pb.equal('value', 30)
+
+        rb = table.new_read_builder().with_projection(['id']).with_filter(pred)
+        actual = self._read(rb)
+
+        self.assertEqual(actual.num_rows, 1)
+        self.assertEqual(actual.column_names, ['id'])
+        self.assertEqual(actual.column('id').to_pylist(), [3])
+
+    def 
test_data_evolution_filter_column_outside_projection_still_filters(self):
+        table = self._populate_data_evolution('test_de_filter_outside_proj')
+
+        pb = table.new_read_builder().new_predicate_builder()
+        pred = pb.equal('value', 30)
+
+        rb = table.new_read_builder().with_projection(['id']).with_filter(pred)
+        actual = self._read(rb)
+
+        self.assertEqual(actual.num_rows, 1)
+        self.assertEqual(actual.column_names, ['id'])
         self.assertEqual(actual.column('id').to_pylist(), [3])
 
 
@@ -262,6 +322,39 @@ class RewritePredicateIndicesUnitTest(unittest.TestCase):
 
         self.assertIsNone(rewrite_predicate_indices(None, []))
 
+    def test_arrow_filter_support_marks_only_unsafe_string_predicates(self):
+        from pypaimon.read.push_down_utils import 
predicate_supports_arrow_filter
+
+        pb = self._build_predicate()
+        safe = pb.greater_than('c', 1)
+        unsafe = pb.startswith('b', 'a')
+        mixed = pb.and_predicates([safe, unsafe])
+
+        self.assertTrue(predicate_supports_arrow_filter(safe))
+        self.assertFalse(predicate_supports_arrow_filter(unsafe))
+        self.assertFalse(predicate_supports_arrow_filter(mixed))
+
+    def test_missing_first_row_id_materializes_null_row_ids(self):
+        from pypaimon.read.reader.data_file_batch_reader import 
DataFileBatchReader
+        from pypaimon.table.special_fields import SpecialFields
+
+        reader = object.__new__(DataFileBatchReader)
+        reader.system_fields = {SpecialFields.ROW_ID.name: 0}
+        reader.first_row_id = None
+        reader.row_id_offsets = None
+        reader._row_id_cursor = 0
+
+        batch = pa.RecordBatch.from_arrays(
+            [pa.array([0, 0], type=pa.int64())],
+            schema=pa.schema([
+                pa.field(SpecialFields.ROW_ID.name, pa.int64(), nullable=False)
+            ]),
+        )
+
+        actual = reader._assign_row_tracking(batch)
+
+        self.assertEqual(actual.column(0).to_pylist(), [None, None])
+
     def test_raises_when_leaf_field_missing(self):
         from pypaimon.read.push_down_utils import rewrite_predicate_indices
         from pypaimon.schema.data_types import AtomicType, DataField
diff --git a/paimon-python/pypaimon/tests/reader_parallel_test.py 
b/paimon-python/pypaimon/tests/reader_parallel_test.py
index adad137341..9e9897fb50 100644
--- a/paimon-python/pypaimon/tests/reader_parallel_test.py
+++ b/paimon-python/pypaimon/tests/reader_parallel_test.py
@@ -400,6 +400,21 @@ class ParallelReaderPrimaryKeyTest(unittest.TestCase):
         df_p = parallel.to_pandas().sort_values(['dt', 
'user_id']).reset_index(drop=True)
         self.assertTrue(df_s.equals(df_p))
 
+    def test_parallel_row_kind_survives_output_projection(self):
+        rb_full = self.table.new_read_builder()
+        predicate = rb_full.new_predicate_builder().equal('behavior', 
'v2-updated')
+        rb = self.table.new_read_builder().with_projection(
+            ['user_id', 'dt']).with_filter(predicate)
+        splits = rb.new_scan().plan().splits()
+        read = rb.new_read()
+        read.include_row_kind = True
+
+        result = read.to_arrow(splits, parallelism=4)
+
+        self.assertEqual(result.schema.names, ['_row_kind', 'user_id', 'dt'])
+        self.assertEqual(result.num_rows, 5)
+        self.assertEqual(set(result.column('_row_kind').to_pylist()), {'+I'})
+
 
 if __name__ == '__main__':
     unittest.main()

Reply via email to