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 a4bfec1bd2 [python] Prune Parquet row groups for row-id range reads
(#8893)
a4bfec1bd2 is described below
commit a4bfec1bd2242847510c09ff7faf017a9ee2efbb
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Jul 29 13:55:19 2026 +0800
[python] Prune Parquet row groups for row-id range reads (#8893)
---
.../pypaimon/read/reader/data_file_batch_reader.py | 49 ++-
.../pypaimon/read/reader/format_pyarrow_reader.py | 192 +++++++++-
paimon-python/pypaimon/read/split_read.py | 39 +-
.../pypaimon/tests/parquet_row_range_test.py | 393 +++++++++++++++++++++
4 files changed, 658 insertions(+), 15 deletions(-)
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 1de9f7a405..2bdf38689e 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from typing import List, Optional
+from typing import List, Optional, Tuple
import pyarrow as pa
import pyarrow.compute as pc
@@ -106,6 +106,35 @@ def _constructed_to_string_array(array, file_type):
return pa.array(out, type=pa.string())
+class _RowIdRangeCursor:
+ """Generate selected row offsets batch by batch from compact ranges."""
+
+ def __init__(self, ranges: List[Tuple[int, int]]):
+ self._ranges = ranges
+ self._range_index = 0
+ self._next_offset = ranges[0][0] if ranges else None
+
+ def take(self, count: int) -> List[int]:
+ offsets = []
+ while len(offsets) < count:
+ if self._range_index >= len(self._ranges):
+ raise ValueError(
+ "Row range offsets were exhausted before the reader")
+ lower, upper = self._ranges[self._range_index]
+ current = max(lower, self._next_offset)
+ take_count = min(count - len(offsets), upper - current + 1)
+ offsets.extend(range(current, current + take_count))
+ current += take_count
+ if current > upper:
+ self._range_index += 1
+ self._next_offset = (
+ self._ranges[self._range_index][0]
+ if self._range_index < len(self._ranges) else None)
+ else:
+ self._next_offset = current
+ return offsets
+
+
class DataFileBatchReader(RecordBatchReader):
"""
Reads record batch from files of different formats
@@ -120,7 +149,13 @@ class DataFileBatchReader(RecordBatchReader):
file_io: Optional[FileIO] = None,
row_id_offsets: Optional[List[int]] = None,
file_data_fields: Optional[List[DataField]] = None,
- target_data_fields: Optional[List[DataField]] = None):
+ target_data_fields: Optional[List[DataField]] = None,
+ row_id_offset_ranges: Optional[List[Tuple[int, int]]] = None):
+ if (row_id_offsets is not None
+ and row_id_offset_ranges is not None):
+ raise ValueError(
+ "row_id_offsets and row_id_offset_ranges cannot both "
+ "be provided")
self.format_reader = format_reader
self.index_mapping = index_mapping
self.partition_info = partition_info
@@ -130,6 +165,9 @@ class DataFileBatchReader(RecordBatchReader):
self.first_row_id = first_row_id
self.row_id_offsets = row_id_offsets
self._row_id_cursor = 0
+ self._row_id_range_cursor = (
+ _RowIdRangeCursor(row_id_offset_ranges)
+ if row_id_offset_ranges is not None else None)
self.max_sequence_number = max_sequence_number
self.system_fields = system_fields
self.file_io = file_io
@@ -351,6 +389,13 @@ class DataFileBatchReader(RecordBatchReader):
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())
self._row_id_cursor = end
+ elif self._row_id_range_cursor is not None:
+ row_ids = [
+ self.first_row_id + offset
+ for offset in self._row_id_range_cursor.take(
+ record_batch.num_rows)
+ ]
+ arrays[idx] = pa.array(row_ids, type=pa.int64())
else:
row_id_range = range(self.first_row_id, self.first_row_id +
record_batch.num_rows)
arrays[idx] = pa.array(row_id_range, type=pa.int64())
diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
index 956ca39f46..565ec1f5b1 100644
--- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py
@@ -15,7 +15,8 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Any, Dict, List, Optional, Set
+from collections import deque
+from typing import Any, Deque, Dict, Iterator, List, Optional, Set, Tuple
import pyarrow as pa
import pyarrow.dataset as ds
@@ -57,10 +58,61 @@ class FormatPyArrowReader(RecordBatchReader):
push_down_predicate: Any, batch_size: int = 1024,
options: CoreOptions = None,
nested_name_paths: Optional[List[List[str]]] = None,
- predicate_field_names: Optional[Set[str]] = None):
+ predicate_field_names: Optional[Set[str]] = None,
+ row_indices: Optional[List[int]] = None,
+ row_ranges: Optional[List[Tuple[int, int]]] = None):
self._predicate_field_names = predicate_field_names or set()
file_path_for_pyarrow = file_io.to_filesystem_path(file_path)
self.dataset = ds.dataset(file_path_for_pyarrow, format=file_format,
filesystem=file_io.filesystem)
+ self._range_slicer = None
+ self._selected_parquet_row_groups = None
+ self._exhausted = False
+ if row_indices is not None and row_ranges is not None:
+ raise ValueError(
+ "row_indices and row_ranges cannot both be provided")
+ row_selection_supplied = (
+ row_indices is not None or row_ranges is not None)
+ if row_selection_supplied and file_format == 'parquet':
+ if push_down_predicate is not None:
+ raise ValueError(
+ "row selections cannot be combined with a scanner-level "
+ "push-down predicate because filtering shifts row "
+ "positions")
+ runs = (
+ _normalize_runs(row_ranges)
+ if row_ranges is not None
+ else _to_runs(row_indices)
+ )
+ if not runs:
+ self._exhausted = True
+ else:
+ fragment = next(iter(self.dataset.get_fragments()), None)
+ row_group_fragments = (
+ list(fragment.split_by_row_group())
+ if fragment is not None else [])
+ selected_infos = []
+ selected_ids = []
+ offset = 0
+ run_index = 0
+ for row_group_fragment in row_group_fragments:
+ row_group = row_group_fragment.row_groups[0]
+ row_count = row_group.num_rows
+ lower, upper = offset, offset + row_count - 1
+ while (
+ run_index < len(runs)
+ and runs[run_index][1] < lower):
+ run_index += 1
+ if (run_index < len(runs)
+ and runs[run_index][0] <= upper):
+ selected_infos.append((offset, row_count))
+ selected_ids.append(row_group.id)
+ offset += row_count
+ if not selected_ids:
+ self._exhausted = True
+ else:
+ self._selected_parquet_row_groups = selected_ids
+ self._range_slicer = _RowRunSlicer(
+ selected_infos, runs)
self._file_format = file_format
self.read_fields = read_fields
self._read_field_names = [f.name for f in read_fields]
@@ -115,12 +167,15 @@ class FormatPyArrowReader(RecordBatchReader):
# Read projected VARIANT columns in bounded batches.
self._parquet_file = None
- if self._bounded_variant_read:
+ if (self._bounded_variant_read
+ or self._selected_parquet_row_groups is not None):
import pyarrow.parquet as pq
# ParquetFile(filesystem=...) is unavailable in PyArrow 6.
self._parquet_file = pq.ParquetFile(
file_io.filesystem.open_input_file(file_path_for_pyarrow))
- if self._parquet_file is not None:
+ if self._exhausted:
+ self._raw_batches = iter(())
+ elif self._parquet_file is not None:
self._raw_batches = self._iter_row_group_batches()
else:
reader = self.dataset.scanner(
@@ -192,6 +247,8 @@ class FormatPyArrowReader(RecordBatchReader):
return columns
def _select_existing_fields(self, batch):
+ if not self.existing_fields:
+ return _zero_column_batch(batch.num_rows)
columns = []
fields = []
for name in self.existing_fields:
@@ -218,11 +275,15 @@ class FormatPyArrowReader(RecordBatchReader):
column = column.flatten()[index]
columns.append(column)
names.append(field.name)
+ if not columns:
+ return _zero_column_batch(batch.num_rows)
return pa.RecordBatch.from_arrays(columns, names=names)
def _surviving_row_group_ids(self):
total = self._parquet_file.num_row_groups
if self._scan_filter is None:
+ if self._selected_parquet_row_groups is not None:
+ return self._selected_parquet_row_groups
return range(total)
try:
ids = set()
@@ -236,7 +297,10 @@ class FormatPyArrowReader(RecordBatchReader):
return range(total)
def read_arrow_batch(self) -> Optional[RecordBatch]:
- batch = next(self._raw_batches, None)
+ if self._range_slicer is not None:
+ batch = self._range_slicer.next_batch(self._raw_batches)
+ else:
+ batch = next(self._raw_batches, None)
if batch is None:
return None
return self._post_process_batch(batch)
@@ -349,6 +413,124 @@ def _path_exists_in_arrow_schema(schema: pa.Schema, path:
List[str]) -> bool:
return True
+def _zero_column_batch(num_rows: int) -> RecordBatch:
+ """Build a zero-column batch without losing its logical row count."""
+ empty_struct = pa.Array.from_buffers(
+ pa.struct([]), num_rows, [None], children=[])
+ return pa.RecordBatch.from_struct_array(empty_struct)
+
+
+def _to_runs(row_indices: List[int]) -> List[Tuple[int, int]]:
+ """Collapse row indices into sorted, distinct, inclusive runs."""
+ if not row_indices:
+ return []
+ sorted_indices = sorted(set(row_indices))
+ runs = []
+ start = previous = sorted_indices[0]
+ for index in sorted_indices[1:]:
+ if index == previous + 1:
+ previous = index
+ continue
+ runs.append((start, previous))
+ start = previous = index
+ runs.append((start, previous))
+ return runs
+
+
+def _normalize_runs(
+ row_ranges: List[Tuple[int, int]]) -> List[Tuple[int, int]]:
+ """Sort and merge inclusive row ranges without expanding their rows."""
+ if not row_ranges:
+ return []
+ ranges = sorted(row_ranges)
+ merged = []
+ for lower, upper in ranges:
+ if lower > upper:
+ raise ValueError(
+ "Invalid row range: {} > {}".format(lower, upper))
+ if merged and lower <= merged[-1][1] + 1:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], upper))
+ else:
+ merged.append((lower, upper))
+ return merged
+
+
+class _RowRunSlicer:
+ """Slice selected Parquet row groups down to requested file-local rows."""
+
+ def __init__(
+ self,
+ selected_infos: List[Tuple[int, int]],
+ runs: List[Tuple[int, int]]):
+ self._segments = []
+ concatenated_offset = 0
+ for file_offset, row_count in selected_infos:
+ self._segments.append((
+ concatenated_offset,
+ concatenated_offset + row_count,
+ file_offset,
+ ))
+ concatenated_offset += row_count
+ self._runs = [(lower, upper + 1) for lower, upper in runs]
+ self._stream_offset = 0
+ self._segment_index = 0
+ self._run_index = 0
+ self._pending: Deque[RecordBatch] = deque()
+
+ def next_batch(
+ self, batches: Iterator[RecordBatch]) -> Optional[RecordBatch]:
+ while not self._pending:
+ batch = next(batches, None)
+ if batch is None:
+ return None
+ self._slice_batch(batch)
+ return self._pending.popleft()
+
+ def _slice_batch(self, batch: RecordBatch) -> None:
+ batch_start = self._stream_offset
+ batch_end = batch_start + batch.num_rows
+ self._stream_offset = batch_end
+ position = batch_start
+
+ while position < batch_end:
+ while (
+ self._segment_index < len(self._segments)
+ and position >= self._segments[
+ self._segment_index][1]):
+ self._segment_index += 1
+ if self._segment_index >= len(self._segments):
+ return
+
+ segment_start, segment_end, file_start = self._segments[
+ self._segment_index]
+ part_end = min(batch_end, segment_end)
+ local_start = file_start + position - segment_start
+ local_end = file_start + part_end - segment_start
+
+ while (
+ self._run_index < len(self._runs)
+ and self._runs[self._run_index][1] <= local_start):
+ self._run_index += 1
+ run_index = self._run_index
+ while (
+ run_index < len(self._runs)
+ and self._runs[run_index][0] < local_end):
+ run_start, run_end = self._runs[run_index]
+ lower = max(local_start, run_start)
+ upper = min(local_end, run_end)
+ if lower < upper:
+ offset = (
+ position - batch_start + lower - local_start)
+ self._pending.append(
+ batch.slice(offset, upper - lower))
+ if run_end <= local_end:
+ run_index += 1
+ else:
+ break
+ self._run_index = run_index
+ position = part_end
+
+
def _contains_variant(data_type) -> bool:
if isinstance(data_type, AtomicType):
return data_type.type.upper() == 'VARIANT'
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index e3d7f3f16d..d34c1800e0 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -225,19 +225,37 @@ class SplitRead(ABC):
file_path = self._aligned_extra_file_path(file, row_sidecar_file)
file_format = ROW_SIDECAR_FORMAT
- # Convert global row_ranges (IndexedSplit) to local row_indices for
native pushdown.
+ # Prepare file-local native row selection. Existing native formats
+ # consume row indices; Parquet keeps compact ranges to avoid expanding
+ # large selections into millions of Python integers.
row_indices = None
+ parquet_row_ranges = None
if effective_row_ranges is not None:
row_index_formats = (CoreOptions.FILE_FORMAT_BLOB,
CoreOptions.FILE_FORMAT_VORTEX,
CoreOptions.FILE_FORMAT_LANCE,
CoreOptions.FILE_FORMAT_ROW)
if file_format in row_index_formats:
- row_indices = []
- for r in effective_row_ranges:
- start = r.from_ - file.first_row_id
- end = r.to - file.first_row_id
- row_indices.extend(range(start, end + 1))
+ row_indices = [
+ row_id - file.first_row_id
+ for row_range in effective_row_ranges
+ for row_id in range(row_range.from_, row_range.to + 1)
+ ]
+ elif (file_format == CoreOptions.FILE_FORMAT_PARQUET
+ and read_arrow_predicate is None):
+ parquet_row_ranges = []
+ merged_ranges = Range.sort_and_merge_overlap(
+ effective_row_ranges, True)
+ for r in merged_ranges:
+ start = max(0, r.from_ - file.first_row_id)
+ end = min(
+ file.row_count - 1,
+ r.to - file.first_row_id,
+ )
+ if end >= start:
+ parquet_row_ranges.append((start, end))
+ if not parquet_row_ranges:
+ return EmptyRecordBatchReader()
# Map nested paths into the order the format reader will see.
nested_path_by_name = self._nested_path_by_name()
@@ -338,7 +356,8 @@ class SplitRead(ABC):
ordered_read_fields, read_arrow_predicate,
batch_size=batch_size,
options=self.table.options,
nested_name_paths=ordered_nested_paths,
- predicate_field_names=predicate_fields)
+ predicate_field_names=predicate_fields,
+ row_ranges=parquet_row_ranges)
elif file_format == CoreOptions.FILE_FORMAT_ROW:
if has_nested:
raise NotImplementedError(
@@ -394,6 +413,7 @@ class SplitRead(ABC):
system_fields,
file_io=self.table.file_io,
row_id_offsets=row_indices,
+ row_id_offset_ranges=parquet_row_ranges,
file_data_fields=file_read_fields,
target_data_fields=target_fields)
else:
@@ -409,11 +429,14 @@ class SplitRead(ABC):
system_fields,
file_io=self.table.file_io,
row_id_offsets=row_indices,
+ row_id_offset_ranges=parquet_row_ranges,
file_data_fields=file_read_fields,
target_data_fields=target_fields)
# For non-Vortex formats, wrap with RowIdFilterRecordBatchReader
- if row_ranges is not None and row_indices is None:
+ if (row_ranges is not None
+ and row_indices is None
+ and parquet_row_ranges is None):
reader = RowIdFilterRecordBatchReader(reader, file.first_row_id,
effective_row_ranges)
# For formats without native shard support, wrap with ShardBatchReader
diff --git a/paimon-python/pypaimon/tests/parquet_row_range_test.py
b/paimon-python/pypaimon/tests/parquet_row_range_test.py
new file mode 100644
index 0000000000..6bba358924
--- /dev/null
+++ b/paimon-python/pypaimon/tests/parquet_row_range_test.py
@@ -0,0 +1,393 @@
+################################################################################
+# 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.
+################################################################################
+
+import os
+import shutil
+import tempfile
+import unittest
+from unittest import mock
+
+import pyarrow as pa
+import pyarrow.fs as pafs
+import pyarrow.parquet as pq
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
+N = 300
+ROW_GROUP_SIZE = 64
+TABLE_OPTIONS = {
+ "row-tracking.enabled": "true",
+ "data-evolution.enabled": "true",
+ "read.batch-size": "50",
+}
+VARIANT_TYPE = pa.struct(
+ [
+ pa.field("value", pa.binary(), nullable=False),
+ pa.field("metadata", pa.binary(), nullable=False),
+ ]
+)
+
+
+class _LocalFileIO:
+ filesystem = pafs.LocalFileSystem()
+
+ def to_filesystem_path(self, path):
+ return path
+
+
+def _commit_with_row_groups(table, data):
+ original = table.file_io.write_parquet
+
+ def patched(path, arrow_table, **kwargs):
+ kwargs.setdefault("row_group_size", ROW_GROUP_SIZE)
+ return original(path, arrow_table, **kwargs)
+
+ table.file_io.write_parquet = patched
+ try:
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+ finally:
+ table.file_io.write_parquet = original
+
+
+def _data_file_paths(table):
+ warehouse_root = table.table_path.replace("file://", "")
+ paths = []
+ for root, _, files in os.walk(warehouse_root):
+ parts = set(os.path.relpath(root, warehouse_root).split(os.sep))
+ if parts & {"manifest", "schema", "snapshot", "index"}:
+ continue
+ for file_name in files:
+ if file_name.endswith(".parquet"):
+ paths.append(os.path.join(root, file_name))
+ return sorted(paths)
+
+
+def _rows(schema, count):
+ return pa.table(
+ {
+ "group_id": ["g{}".format(index // 100)
+ for index in range(count)],
+ "payload": [bytes([index % 256]) * 64
+ for index in range(count)],
+ "value": list(range(count)),
+ },
+ schema=schema,
+ )
+
+
+class ToRunsTest(unittest.TestCase):
+
+ def test_runs_are_sorted_deduplicated_and_empty_safe(self):
+ from pypaimon.read.reader.format_pyarrow_reader import (
+ _normalize_runs,
+ _to_runs,
+ )
+
+ self.assertEqual(_to_runs([]), [])
+ self.assertEqual(_to_runs([1, 2, 3]), [(1, 3)])
+ self.assertEqual(
+ _to_runs([9, 4, 1, 3, 4]),
+ [(1, 1), (3, 4), (9, 9)],
+ )
+ self.assertEqual(_normalize_runs([]), [])
+ self.assertEqual(
+ _normalize_runs([
+ (900_000_000, 999_999_999),
+ (0, 900_000_001),
+ ]),
+ [(0, 999_999_999)],
+ )
+
+
+class RowIdRangeCursorTest(unittest.TestCase):
+
+ def test_generates_offsets_across_batches_and_ranges(self):
+ from pypaimon.read.reader.data_file_batch_reader import (
+ _RowIdRangeCursor,
+ )
+
+ cursor = _RowIdRangeCursor([(10, 12), (100, 102)])
+ self.assertEqual(cursor.take(2), [10, 11])
+ self.assertEqual(cursor.take(3), [12, 100, 101])
+ self.assertEqual(cursor.take(1), [102])
+
+
+class ParquetRowRangeTest(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ cls.tempdir = tempfile.mkdtemp()
+ cls.catalog = CatalogFactory.create(
+ {"warehouse": os.path.join(cls.tempdir, "warehouse")})
+ cls.catalog.create_database("default", False)
+ cls.arrow_schema = pa.schema(
+ [
+ ("group_id", pa.string()),
+ ("payload", pa.large_binary()),
+ ("value", pa.int32()),
+ ]
+ )
+ identifier = "default.parquet_row_range"
+ cls.catalog.create_table(
+ identifier,
+ Schema.from_pyarrow_schema(
+ cls.arrow_schema,
+ options=TABLE_OPTIONS,
+ ),
+ False,
+ )
+ table = cls.catalog.get_table(identifier)
+ _commit_with_row_groups(table, _rows(cls.arrow_schema, N))
+ cls.table = cls.catalog.get_table(identifier)
+
+ data_files = _data_file_paths(cls.table)
+ assert len(data_files) == 1, data_files
+ cls.data_file = data_files[0]
+ assert pq.ParquetFile(cls.data_file).num_row_groups == 5
+
+ @classmethod
+ def tearDownClass(cls):
+ shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+ def _read(
+ self,
+ predicate_factory=None,
+ columns=("group_id", "value", "_ROW_ID")):
+ read_builder = self.table.new_read_builder().with_projection(
+ list(columns))
+ if predicate_factory is not None:
+ predicate_builder = read_builder.new_predicate_builder()
+ read_builder = read_builder.with_filter(
+ predicate_factory(predicate_builder))
+ splits = read_builder.new_scan().plan().splits()
+ return read_builder.new_read().to_arrow(splits)
+
+ def test_reads_only_intersecting_row_groups(self):
+ result = self._read(
+ lambda builder: builder.between("_ROW_ID", 100, 163))
+ self.assertEqual(
+ result.column("_ROW_ID").to_pylist(),
+ list(range(100, 164)),
+ )
+
+ reader = FormatPyArrowReader(
+ _LocalFileIO(),
+ "parquet",
+ self.data_file,
+ [DataField(0, "value", AtomicType("INT"))],
+ None,
+ batch_size=50,
+ row_ranges=[(100, 163)],
+ )
+ self.assertIsNotNone(reader._parquet_file)
+ self.assertEqual(
+ reader._selected_parquet_row_groups,
+ [1, 2],
+ )
+
+ def test_direct_row_indices_are_sorted_and_deduplicated(self):
+ reader = FormatPyArrowReader(
+ _LocalFileIO(),
+ "parquet",
+ self.data_file,
+ [DataField(0, "value", AtomicType("INT"))],
+ None,
+ batch_size=2,
+ row_indices=[130, 11, 10, 10],
+ )
+ values = []
+ while True:
+ batch = reader.read_arrow_batch()
+ if batch is None:
+ break
+ values.extend(batch.column(0).to_pylist())
+ self.assertEqual(values, [10, 11, 130])
+
+ def test_system_only_projection_preserves_rows(self):
+ result = self._read(
+ lambda builder: builder.between("_ROW_ID", 2, 7),
+ columns=("_ROW_ID",),
+ )
+ self.assertEqual(
+ result.column("_ROW_ID").to_pylist(),
+ list(range(2, 8)),
+ )
+
+ def test_missing_only_projection_preserves_rows(self):
+ reader = FormatPyArrowReader(
+ _LocalFileIO(),
+ "parquet",
+ self.data_file,
+ [DataField(99, "missing", AtomicType("INT"))],
+ None,
+ batch_size=2,
+ row_ranges=[(2, 7)],
+ )
+ values = []
+ while True:
+ batch = reader.read_arrow_batch()
+ if batch is None:
+ break
+ values.extend(batch.column(0).to_pylist())
+ self.assertEqual(values, [None] * 6)
+
+ def test_skips_per_row_python_range_filter(self):
+ from pypaimon.read.reader.row_range_filter_record_reader import (
+ RowIdFilterRecordBatchReader,
+ )
+
+ with mock.patch.object(
+ RowIdFilterRecordBatchReader,
+ "_is_row_in_range",
+ wraps=RowIdFilterRecordBatchReader._is_row_in_range,
+ ) as range_spy:
+ result = self._read(
+ lambda builder: builder.between("_ROW_ID", 100, 163))
+ self.assertEqual(result.num_rows, 64)
+ range_spy.assert_not_called()
+
+ def test_disjoint_ranges_preserve_order_and_payload(self):
+ result = self._read(
+ lambda builder: builder.or_predicates(
+ [
+ builder.between("_ROW_ID", 10, 20),
+ builder.between("_ROW_ID", 130, 140),
+ builder.between("_ROW_ID", 290, 299),
+ ]
+ ),
+ columns=("payload", "value", "_ROW_ID"),
+ )
+ expected = (
+ list(range(10, 21))
+ + list(range(130, 141))
+ + list(range(290, 300))
+ )
+ self.assertEqual(
+ result.column("_ROW_ID").to_pylist(), expected)
+ self.assertEqual(result.column("value").to_pylist(), expected)
+ self.assertEqual(
+ result.column(
+ result.schema.get_field_index("payload")).to_pylist(),
+ [bytes([index % 256]) * 64 for index in expected],
+ )
+
+ def test_column_predicate_falls_back_without_position_shift(self):
+ result = self._read(
+ lambda builder: builder.and_predicates(
+ [
+ builder.between("_ROW_ID", 64, 250),
+ builder.greater_or_equal("value", 200),
+ ]
+ )
+ )
+ self.assertEqual(
+ result.column("_ROW_ID").to_pylist(),
+ list(range(200, 251)),
+ )
+ self.assertEqual(
+ result.column("value").to_pylist(),
+ list(range(200, 251)),
+ )
+
+ result = self._read(
+ lambda builder: builder.and_predicates(
+ [
+ builder.between("_ROW_ID", 90, 210),
+ builder.equal("group_id", "g1"),
+ ]
+ )
+ )
+ self.assertEqual(
+ result.column("_ROW_ID").to_pylist(),
+ list(range(100, 200)),
+ )
+
+ def test_full_scan_is_unchanged(self):
+ real_parquet_file = pq.ParquetFile
+ with mock.patch.object(
+ pq,
+ "ParquetFile",
+ wraps=real_parquet_file,
+ ) as parquet_file_spy:
+ result = self._read(columns=("value",))
+ self.assertEqual(
+ sorted(result.column("value").to_pylist()),
+ list(range(N)),
+ )
+ parquet_file_spy.assert_not_called()
+
+ def test_projected_variant_uses_same_exact_row_slicing(self):
+ path = os.path.join(self.tempdir, "variant-row-ranges.parquet")
+ payload = pa.array(
+ [
+ {"value": "v{}".format(index).encode(), "metadata": b"m"}
+ for index in range(N)
+ ],
+ type=VARIANT_TYPE,
+ )
+ pq.write_table(
+ pa.table({"payload": payload}),
+ path,
+ row_group_size=ROW_GROUP_SIZE,
+ )
+ requested = list(range(60, 71)) + list(range(130, 134))
+ reader = FormatPyArrowReader(
+ _LocalFileIO(),
+ "parquet",
+ path,
+ [DataField(0, "payload", AtomicType("VARIANT"))],
+ None,
+ batch_size=50,
+ row_ranges=[(60, 70), (130, 133)],
+ )
+ self.assertIsNotNone(reader._parquet_file)
+ self.assertEqual(
+ reader._selected_parquet_row_groups,
+ [0, 1, 2],
+ )
+ values = []
+ while True:
+ batch = reader.read_arrow_batch()
+ if batch is None:
+ break
+ values.extend(
+ batch.column(
+ batch.schema.get_field_index("payload")).to_pylist())
+ self.assertEqual(
+ values,
+ [
+ {
+ "value": "v{}".format(index).encode(),
+ "metadata": b"m",
+ }
+ for index in requested
+ ],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()