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 cab6a2f268 [python] Route row iterators through native readers (#10639)
cab6a2f268 is described below
commit cab6a2f268e507f325736f966de4f61e1c1106e2
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 18 13:56:09 2026 +0800
[python] Route row iterators through native readers (#10639)
---
paimon-python/README.md | 9 +-
paimon-python/pypaimon/read/native_row_iterator.py | 55 +++
paimon-python/pypaimon/read/table_read.py | 101 ++++-
paimon-python/pypaimon/table/row/offset_row.py | 2 +-
.../pypaimon/tests/native_iterator_test.py | 502 +++++++++++++++++++++
paimon-python/pypaimon/tests/native_read_test.py | 1 +
6 files changed, 653 insertions(+), 17 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index 7ea5a3c127..b911b37322 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -401,7 +401,9 @@ Native reads return PyArrow batches through the Arrow C
Data interface. Native
planner handles are used directly when available; splits refined by Python
index or shuffle logic are serialized through the stable split contract and
reconstructed by Rust. Nested projection and row-kind output are supported.
-Query authorization retains the Python reader. For both materialized
+Authorization captured by an original Native plan is evaluated in Rust;
+Python-planned or serialized authorization rules retain the Python reader.
+For both materialized
`to_arrow()` and streaming `to_arrow_batch_reader()` reads, the effective split
parallelism (the method argument, `read.parallelism`, or the automatic default)
runs independent Rust readers. Splits stay in input order but contiguous groups
@@ -412,6 +414,11 @@ interrupts native reads that are still in flight. A
missing reader capability,
unsupported route, or native-reader construction failure falls back to Python;
I/O and data errors raised after streaming starts surface to the caller.
+`to_iterator()` also uses Native reads when enabled, yielding the existing
+reusable `InternalRow` view with its row kind, BLOB and VECTOR accessors. It
+visits splits serially and shares one row limit across them. Close the iterator
+when stopping before exhaustion to release its current Native reader.
+
Native planning and reading require Rust's resolved-schema bindings:
`Table.from_resolved_schema()` for filesystem, JDBC and path-based tables, and
`Table.copy_with_resolved_schema()` for REST tables. The adapter passes the
Python
diff --git a/paimon-python/pypaimon/read/native_row_iterator.py
b/paimon-python/pypaimon/read/native_row_iterator.py
new file mode 100644
index 0000000000..5964751873
--- /dev/null
+++ b/paimon-python/pypaimon/read/native_row_iterator.py
@@ -0,0 +1,55 @@
+# 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.
+
+"""Adapt already filtered native batches to the existing InternalRow view."""
+
+import polars
+
+from pypaimon.read.reader.field_indices import (
+ blob_field_indices, descriptor_field_indices_for_table,
vector_field_indices)
+from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader,
_polars_arrow_table
+from pypaimon.table.row.offset_row import OffsetRow
+from pypaimon.table.row.row_kind import RowKind
+
+
+def native_rows(batches, table, fields):
+ # Descriptor options name source fields. Match their physical identity so
+ # duplicate output columns and collision suffixes keep the same accessor.
+ source = table.fields
+ # Native Arrow reads have already resolved descriptor columns in payload
+ # mode, just as the Python row reader's BlobInlineConvertReader does.
+ descriptor_ids = ({source[index].id for index in
descriptor_field_indices_for_table(table, source)}
+ if table.options.blob_as_descriptor() else set())
+ row = OffsetRow(
+ None, 0, len(fields), file_io=table.file_io,
+ blob_field_indices=blob_field_indices(fields),
+ descriptor_field_indices={i for i, field in enumerate(fields) if
field.id in descriptor_ids},
+ vector_field_indices=vector_field_indices(fields))
+ for batch in batches:
+ # The caller requests row kind even for snapshot reads, so an empty
+ # user projection still has a physical column preserving its row count.
+ # Use the same MAP/ROW conversion as Python RecordBatchReader,
including
+ # NULL containers and list-of-entry dictionaries for MAP values.
+ if len(set(batch.schema.names)) != batch.num_columns:
+ # Arrow permits duplicate result names; Polars requires unique
+ # names. Row access here is positional and retains every copy.
+ batch = batch.rename_columns([str(index) for index in
range(batch.num_columns)])
+ frame = polars.from_arrow(_polars_arrow_table(batch))
+ for values in RecordBatchReader._iter_df_rows(frame):
+ row.replace(values[1:])
+ row.set_row_kind_byte(RowKind.from_string(values[0]).value)
+ yield row
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 05a77f3615..fe6ab07581 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -208,20 +208,54 @@ class TableRead:
if self.table.options.data_evolution_enabled() else set()
)
self.include_row_kind = include_row_kind
+ self._row_kind_column = ROW_KIND_COLUMN
self.limit = limit
self._read_parallelism = self.table.options.read_parallelism()
self._parquet_row_group_cache = None
def to_iterator(self, splits: List[Split]) -> Iterator:
+ """Lazily read InternalRows, using native reads when available.
+
+ Rows retain their kind separately from projected fields. Like the
+ Python split reader, the iterator reuses its row view and visits splits
+ in order. Close the iterator when stopping before exhaustion.
+ """
if self.output_projection is not None and self.output_projection.named:
raise RuntimeError(
"Projection expressions are not supported by to_iterator(); "
"use to_arrow() or to_arrow_batch_reader()")
- self._check_python_merge_supported()
self._begin_auth_read(splits)
limit = self.limit
def _record_generator():
+ if limit is not None and limit <= 0:
+ return
+ from copy import copy
+ from pypaimon.read.native_row_iterator import native_rows
+ from pypaimon.read.read_type import output_fields
+
+ # InternalRow carries row kind separately from its fields. Keep it
+ # in the native transport even when the Arrow API did not request
+ # the extra column, without changing this reader's public schema.
+ read = copy(self)
+ read.include_row_kind = True
+ # Keep Rust's reserved metadata name in this private transport.
+ # A user field named _row_kind is legal and must remain
projectable.
+ read._row_kind_column = 'rowkind'
+ schema =
read._add_row_kind_to_schema(read._output_arrow_schema(splits),
read._row_kind_column)
+ fields = output_fields(self.read_type, self.output_projection)
+ batches = read._try_native_batches(splits, schema, parallelism=1,
streaming=True,
+ preserve_split_order=True)
+ if batches is not None:
+ try:
+ yield from native_rows(batches, self.table, fields)
+ finally:
+ close = getattr(batches, 'close', None)
+ if close is not None:
+ close()
+ return
+
+ self._check_python_merge_supported()
count = 0
for split in splits:
if limit is not None and count >= limit:
@@ -328,9 +362,9 @@ class TableRead:
return reader, batch_iterator
@staticmethod
- def _add_row_kind_to_schema(schema: pyarrow.Schema) -> pyarrow.Schema:
- """Add _row_kind column to the schema as the first column."""
- row_kind_field = pyarrow.field(ROW_KIND_COLUMN, pyarrow.string())
+ def _add_row_kind_to_schema(schema: pyarrow.Schema,
column_name=ROW_KIND_COLUMN) -> pyarrow.Schema:
+ """Prepend the row-kind transport column, normally named _row_kind."""
+ row_kind_field = pyarrow.field(column_name, pyarrow.string())
return pyarrow.schema([row_kind_field] + list(schema))
@staticmethod
@@ -350,10 +384,13 @@ class TableRead:
columns = []
num_rows = batch.num_rows
+ same_order = batch.schema.names == target_schema.names
- for field in target_schema:
+ for index, field in enumerate(target_schema):
if field.name in batch.schema.names:
- col = batch.column(field.name)
+ # Result projections may repeat a column. A name lookup is
+ # ambiguous even when the batch already has the correct order.
+ col = batch.column(index if same_order else field.name)
if col.type != field.type:
if not allow_type_cast:
raise TypeError(
@@ -560,7 +597,8 @@ class TableRead:
schema: pyarrow.Schema,
parallelism: Optional[int] = None,
blob_parallelism: Optional[int] = None,
- streaming: bool = False):
+ streaming: bool = False,
+ preserve_split_order: bool = False):
"""Return Rust-read batches, or ``None`` when this read must fall
back."""
if not self.table.options.native_read_enabled():
return self._native_fallback("read.native.enabled is false")
@@ -602,6 +640,15 @@ class TableRead:
log=True)
rust_splits.append(rust_split)
split_weights.append(self._native_split_weight(split))
+ if preserve_split_order:
+ read_kwargs = self._native_read_kwargs(blob_parallelism)
+ try:
+ read_splits = _prepare_native_read(self.table, **read_kwargs)
+ except Exception as e:
+ _raise_if_native_fork_safety_error(e)
+ return self._native_fallback(str(e), e, log=True)
+ return self._convert_native_batches(
+ self._native_batches_ordered(read_splits, rust_splits,
read_kwargs), schema)
if (parallelism is not None
and self._should_run_parallel(splits, parallelism)):
read_kwargs = self._native_read_kwargs(blob_parallelism)
@@ -648,6 +695,30 @@ class TableRead:
return self._native_fallback(str(e), e, log=True)
return self._convert_native_batches(batches, schema)
+ def _native_batches_ordered(self, read_splits, splits, read_kwargs):
+ from pypaimon.read.native_plan import _prepare_native_read
+
+ remaining = self.limit
+ for index, split in enumerate(splits):
+ if remaining is not None and remaining <= 0:
+ break
+ if index and remaining is not None:
+ # BLOB readers must see the remaining quota before opening any
+ # payload, including when earlier splits already spent it.
+ read_splits = _prepare_native_read(self.table,
**dict(read_kwargs, limit=remaining))
+ batches = read_splits([split])
+ try:
+ for batch in batches:
+ yield batch
+ if remaining is not None:
+ remaining -= batch.num_rows
+ if remaining <= 0:
+ break
+ finally:
+ close = getattr(batches, 'close', None)
+ if close is not None:
+ close()
+
def _native_read_kwargs(self, blob_parallelism=None):
kwargs = {
'predicate': self.predicate,
@@ -894,7 +965,7 @@ class TableRead:
if self.include_row_kind and 'rowkind' in batch.schema.names:
index = batch.schema.get_field_index('rowkind')
batch = batch.rename_columns([
- ROW_KIND_COLUMN if position == index else name
+ self._row_kind_column if position == index else name
for position, name in enumerate(batch.schema.names)
])
if self.nested_name_paths:
@@ -920,8 +991,8 @@ class TableRead:
(field.name, path) for field, path in zip(fields, paths)]))
fields = [field.with_nullable(field.nullable or projected[i].nullable)
for i, field in enumerate(fields)]
if self.include_row_kind:
- arrays.insert(0, batch.column(ROW_KIND_COLUMN))
- fields.insert(0, batch.schema.field(ROW_KIND_COLUMN))
+ arrays.insert(0, batch.column(self._row_kind_column))
+ fields.insert(0, batch.schema.field(self._row_kind_column))
return pyarrow.RecordBatch.from_arrays(arrays,
schema=pyarrow.schema(fields))
def _arrow_batch_generator(self, splits: List[Split], schema:
pyarrow.Schema,
@@ -1739,9 +1810,9 @@ class TableRead:
actual = output_schema(batch.schema, adapted_projection)
declared = output_schema(read_arrow_schema(self.read_type),
self.output_projection)
fields = [field.with_nullable(field.nullable or
actual[i].nullable) for i, field in enumerate(declared)]
- if self.include_row_kind and ROW_KIND_COLUMN in batch.schema.names:
- arrays.insert(0, batch.column(ROW_KIND_COLUMN))
- fields.insert(0, batch.schema.field(ROW_KIND_COLUMN))
+ if self.include_row_kind and self._row_kind_column in
batch.schema.names:
+ arrays.insert(0, batch.column(self._row_kind_column))
+ fields.insert(0, batch.schema.field(self._row_kind_column))
if not arrays:
return zero_column_batch(batch.num_rows, batch.schema.metadata)
return pyarrow.RecordBatch.from_arrays(
@@ -1749,8 +1820,8 @@ class TableRead:
if not self._needs_output_projection():
return batch
output_names = list(self._output_column_names)
- if self.include_row_kind and ROW_KIND_COLUMN in batch.schema.names:
- output_names.insert(0, ROW_KIND_COLUMN)
+ if self.include_row_kind and self._row_kind_column in
batch.schema.names:
+ output_names.insert(0, self._row_kind_column)
if batch.schema.names == output_names:
return batch
name_to_pos = {name: i for i, name in enumerate(batch.schema.names)}
diff --git a/paimon-python/pypaimon/table/row/offset_row.py
b/paimon-python/pypaimon/table/row/offset_row.py
index 4e0255f969..1592b94ae4 100644
--- a/paimon-python/pypaimon/table/row/offset_row.py
+++ b/paimon-python/pypaimon/table/row/offset_row.py
@@ -31,7 +31,7 @@ class OffsetRow(InternalRow):
self.row_tuple = row_tuple
self.offset = offset
self.arity = arity
- self.row_kind_byte: int = 1
+ self.row_kind_byte: int = RowKind.INSERT.value
self._file_io = file_io
self._blob_field_indices: FrozenSet[int] = (
frozenset(blob_field_indices) if blob_field_indices is not None
else frozenset()
diff --git a/paimon-python/pypaimon/tests/native_iterator_test.py
b/paimon-python/pypaimon/tests/native_iterator_test.py
new file mode 100644
index 0000000000..4f20936b75
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_iterator_test.py
@@ -0,0 +1,502 @@
+# 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.
+
+"""The existing InternalRow iterator can consume native table reads."""
+
+import json
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.row_kind import RowKind
+from pypaimon.tests import native_plan_rest_test
+
+rest_catalog = native_plan_rest_test.rest_catalog
+
+pytestmark = pytest.mark.native_plan
+
+
+def _write(table, rows, schema):
+ 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()
+
+
+def _table(tmp_path, mode='append'):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([
+ ('id', pa.int32()), ('p', pa.string()), ('v', pa.int64()),
+ ('profile', pa.struct([('score', pa.int32()), ('label',
pa.string())])),
+ ('items', pa.list_(pa.int32())), ('attrs', pa.map_(pa.string(),
pa.int32())),
+ ])
+ options = {'bucket': '1' if mode == 'pk' else '-1',
'write.native.enabled': 'false',
+ 'read.batch-size': '2', 'source.split.target-size': '1b'}
+ if mode == 'de':
+ options.update({'data-evolution.enabled': 'true',
'row-tracking.enabled': 'true',
+ 'deletion-vectors.enabled': 'true'})
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['p', 'id'] if mode == 'pk' else None,
+ partition_keys=['p'], options=options), False)
+ table = catalog.get_table('db.t')
+ rows = [
+ {'id': 1, 'p': 'a', 'v': 10, 'profile': {'score': 7, 'label': 'one'},
+ 'items': [1, None], 'attrs': [('a', 1), ('b', None)]},
+ {'id': 2, 'p': 'a', 'v': 20, 'profile': None, 'items': None, 'attrs':
None},
+ {'id': 3, 'p': 'b', 'v': 30, 'profile': {'score': None, 'label':
'three'},
+ 'items': [], 'attrs': []},
+ ]
+ _write(table, rows[:2], schema)
+ _write(table, rows[2:], schema)
+ if mode == 'pk':
+ _write(table, [dict(rows[0], v=11)], schema)
+ return table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'}), schema
+
+
+def _rows(read, splits):
+ return [(tuple(row.get_field(i) for i in range(len(row))),
row.get_row_kind())
+ for row in read.to_iterator(splits)]
+
+
[email protected]('mode', ['append', 'de', 'pk'])
[email protected]('projection', [None, ['v', 'id'], ['id', 'id'], []])
[email protected]('limit', [None, 0, 1, 5])
+def test_iterator_uses_native_read(tmp_path, mode, projection, limit):
+ table, _ = _table(tmp_path, mode)
+ builder = table.new_read_builder()
+ if projection is not None:
+ builder.with_projection(projection)
+ if limit is not None:
+ builder.with_limit(limit)
+ predicate =
table.new_read_builder().new_predicate_builder().greater_than('v', 10)
+ builder.with_filter(predicate)
+ splits = builder.new_scan().plan().splits()
+ python = table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}).new_read_builder()
+ if projection is not None:
+ python.with_projection(projection)
+ if limit is not None:
+ python.with_limit(limit)
+ python.with_filter(predicate)
+ expected = _rows(python.new_read(), splits)
+ read = builder.new_read()
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python read fallback')):
+ actual = _rows(read, splits)
+ assert [row for row, _ in actual] == [row for row, _ in expected]
+ assert all(kind == RowKind.INSERT for _, kind in actual)
+
+
+def test_append_iterator_row_kind_is_insert(tmp_path):
+ table, _ = _table(tmp_path)
+ table = table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'})
+ builder = table.new_read_builder().with_projection(['id'])
+ rows = _rows(builder.new_read(), builder.new_scan().plan().splits())
+ assert rows and all(kind == RowKind.INSERT for _, kind in rows)
+
+
+class _Batches:
+ def __init__(self, values):
+ self.values = iter(values)
+ self.closed = False
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ value = next(self.values)
+ if isinstance(value, Exception):
+ raise value
+ return value
+
+ def close(self):
+ self.closed = True
+
+
+def _batch(ids, kinds=None):
+ return pa.record_batch([
+ pa.array(kinds or ['+I'] * len(ids)), pa.array(ids, type=pa.int32())],
names=['rowkind', 'id'])
+
+
+def _planned_read(tmp_path, limit=None):
+ table, _ = _table(tmp_path)
+ builder = table.new_read_builder().with_projection(['id'])
+ if limit is not None:
+ builder.with_limit(limit)
+ return builder.new_read(), builder.new_scan().plan().splits()
+
+
[email protected]('consume', [False, True])
+def test_iterator_is_lazy_and_close_stops_native_input(tmp_path, consume):
+ read, splits = _planned_read(tmp_path)
+ batches = _Batches([_batch([1, 2]), RuntimeError('must not fetch next
batch')])
+ with patch('pypaimon.read.native_plan._prepare_native_read',
return_value=lambda _: batches) as prepare, \
+ patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ iterator = read.to_iterator(splits)
+ prepare.assert_not_called()
+ if consume:
+ row = next(iterator)
+ assert row.get_field(0) == 1
+ assert row.get_row_kind() == RowKind.INSERT
+ iterator.close()
+ assert batches.closed == consume
+ assert prepare.call_count == int(consume)
+ assert read.include_row_kind is False
+
+
[email protected]('failure', ['read', 'schema', 'kind'])
+def
test_iterator_closes_native_input_on_failure_without_replaying_python(tmp_path,
failure):
+ read, splits = _planned_read(tmp_path)
+ bad = (RuntimeError('native read failed') if failure == 'read' else
+ pa.record_batch([pa.array(['+I']), pa.array(['wrong type'])],
names=['rowkind', 'id'])
+ if failure == 'schema' else _batch([2], ['invalid']))
+ batches = _Batches([_batch([1]), bad])
+ with patch('pypaimon.read.native_plan._prepare_native_read',
return_value=lambda _: batches), \
+ patch.object(read, '_create_split_read',
side_effect=AssertionError('Python replay')):
+ iterator = read.to_iterator(splits)
+ assert next(iterator).get_field(0) == 1
+ with pytest.raises((RuntimeError, TypeError, ValueError)):
+ next(iterator)
+ assert batches.closed
+
+
+def test_iterator_spends_limit_before_opening_next_split(tmp_path):
+ read, splits = _planned_read(tmp_path, limit=3)
+ assert len(splits) >= 2
+ prepared = []
+ opened = []
+ readers = []
+
+ def prepare(table, **kwargs):
+ prepared.append(kwargs['limit'])
+
+ def open_split(group):
+ assert len(group) == 1
+ assert not readers or readers[-1].closed
+ opened.append(group[0])
+ reader = _Batches([_batch(list(range(min(2, kwargs['limit']))))])
+ readers.append(reader)
+ return reader
+ return open_split
+
+ with patch('pypaimon.read.native_plan._prepare_native_read',
side_effect=prepare), \
+ patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ result = _rows(read, splits + splits)
+ assert len(result) == 3
+ assert prepared == [3, 1]
+ assert opened == [splits[0]._native_split, splits[1]._native_split]
+ assert all(reader.closed for reader in readers)
+ assert read.limit == 3
+
+
+def test_iterator_setup_failure_falls_back_before_native_rows(tmp_path):
+ read, splits = _planned_read(tmp_path)
+ expected = _rows(read.table.copy({'read.native.enabled':
'false'}).new_read_builder()
+ .with_projection(['id']).new_read(), splits)
+ with patch('pypaimon.read.native_plan._prepare_native_read',
side_effect=RuntimeError('setup failed')):
+ assert _rows(read, splits) == expected
+
+
+def test_iterator_qualifies_every_split_before_starting_native(tmp_path):
+ read, splits = _planned_read(tmp_path)
+ with patch.object(read, '_native_split_files_supported',
side_effect=[True, False]), \
+ patch('pypaimon.read.native_plan._prepare_native_read') as prepare:
+ assert len(_rows(read, splits)) == 3
+ prepare.assert_not_called()
+
+
[email protected]('limit', [0, 1, None])
+def test_iterator_preserves_all_row_kinds_and_hides_transport_column(tmp_path,
limit):
+ read, splits = _planned_read(tmp_path, limit)
+ kinds = ['+I', '-U', '+U', '-D']
+ batches = _Batches([_batch([1, 2, 3, 4], kinds)])
+ with patch('pypaimon.read.native_plan._prepare_native_read',
return_value=lambda _: batches) as prepare:
+ rows = _rows(read, splits[:1])
+ count = 4 if limit is None else limit
+ assert rows == [((i + 1,), RowKind.from_string(kind)) for i, kind in
enumerate(kinds[:count])]
+ assert prepare.call_count == int(limit != 0)
+ assert batches.closed == (limit != 0)
+
+
[email protected]('mode', ['append', 'pk'])
[email protected]('projection', [
+ ['profile.score', 'id'], ["attrs['a']", 'id'], ['profile',
'profile.score', 'id']])
+def test_iterator_nested_projection_and_filter_only_inputs(tmp_path, mode,
projection):
+ table, _ = _table(tmp_path, mode)
+ predicate =
table.new_read_builder().new_predicate_builder().greater_than('v', 10)
+ builder =
table.new_read_builder().with_projection(projection).with_filter(predicate)
+ splits = builder.new_scan().plan().splits()
+ expected = _rows(table.copy({'read.native.enabled':
'false'}).new_read_builder()
+
.with_projection(projection).with_filter(predicate).new_read(), splits)
+ read = builder.new_read()
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ actual = _rows(read, splits)
+ assert actual == expected
+ assert actual and all(len(values) == len(projection) for values, _ in
actual)
+
+
[email protected]('mode', ['append', 'de', 'pk'])
[email protected]('projection', [['id', 'id'], ['v', 'id', 'v']])
+def test_duplicate_arrow_outputs_align_metadata_by_position(tmp_path, mode,
projection):
+ table, _ = _table(tmp_path, mode)
+ builder = table.new_read_builder().with_projection(projection)
+ splits = builder.new_scan().plan().splits()
+ read = builder.new_read()
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ for result in (read.to_arrow(splits),
read.to_arrow_batch_reader(splits).read_all()):
+ assert result.schema.names == projection
+ assert result.column(0).to_pylist() == result.column(1 if
len(projection) == 2 else 2).to_pylist()
+
+
[email protected]('mode', ['append', 'de', 'pk'])
+def test_iterator_schema_evolution_keeps_current_nested_types(tmp_path, mode):
+ from pypaimon.schema.schema_change import SchemaChange
+ from pypaimon.schema.data_types import AtomicType
+
+ table, _ = _table(tmp_path, mode)
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.alter_table('db.t', [
+ SchemaChange.rename_column(['profile', 'score'], 'points'),
+ SchemaChange.update_column_type(['profile', 'points'],
AtomicType('BIGINT')),
+ SchemaChange.update_column_type(['v'], AtomicType('STRING')),
+ ], False)
+ table = catalog.get_table('db.t').copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ builder = table.new_read_builder().with_projection(['profile', 'v', 'id'])
+ splits = builder.new_scan().plan().splits()
+ read = builder.new_read()
+ expected = _rows(table.copy({'read.native.enabled':
'false'}).new_read_builder()
+ .with_projection(['profile', 'v', 'id']).new_read(),
splits)
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ assert _rows(read, splits) == expected
+
+
[email protected]('descriptors', [False, True])
[email protected]('inline', [False, True])
[email protected]('duplicate', [False, True])
+def test_iterator_blob_accessors_survive_projection_and_close(tmp_path,
descriptors, inline, duplicate):
+ from pypaimon.tests.native_blob_write_test import _table as blob_table,
_data
+
+ options = {'read.native.enabled': 'true', 'scan.native-plan.enabled':
'true',
+ 'blob-as-descriptor': str(descriptors).lower()}
+ if inline:
+ options['blob-inline-field'] = 'large'
+ table = blob_table(tmp_path, options)
+ data = _data()
+ _write(table, data.to_pylist(), data.schema)
+ projection = ['small', 'large', 'id'] + (['large'] if duplicate else [])
+ builder = table.new_read_builder().with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ result = []
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ iterator = read.to_iterator(splits)
+ for row in iterator:
+ result.append((row.get_field(2), [row.get_blob(index) for index in
[0, 1] + ([3] if duplicate else [])]))
+ iterator.close()
+ result.sort(key=lambda pair: pair[0])
+ payloads = [[None if blob is None else blob.to_data() for blob in blobs]
for _, blobs in result]
+ assert payloads == [[b'', b'\x01' * 40] + ([b'\x01' * 40] if duplicate
else []),
+ [b'abc', None] + ([None] if duplicate else [])]
+
+
[email protected]('descriptors', [False, True])
+def test_iterator_pk_blob_respects_payload_mode_and_limit(tmp_path,
descriptors):
+ from pypaimon.tests.native_pk_blob_limit_test import _table as blob_table,
_descriptor, _write as write
+
+ table = blob_table(tmp_path).copy({'blob-as-descriptor':
str(descriptors).lower()})
+ write(table, [
+ {'id': 1, 'tag': 1, 'payload': _descriptor(tmp_path, 'valid',
b'selected'),
+ 'output': _descriptor(tmp_path, 'output', b'returned')},
+ {'id': 2, 'tag': 2, 'payload': _descriptor(tmp_path, 'missing'),
+ 'output': _descriptor(tmp_path, 'also-missing')},
+ ])
+ builder = table.new_read_builder().with_projection(['id',
'output']).with_limit(1)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ iterator = read.to_iterator(splits)
+ row = next(iterator)
+ assert row.get_field(0) == 1
+ assert row.get_blob(1).to_data() == b'returned'
+ with pytest.raises(StopIteration):
+ next(iterator)
+
+
[email protected]('native_write', [False, True])
[email protected]('projection', [['value', 'key'], []])
+def test_iterator_reads_actual_incremental_changelog_kinds(tmp_path,
native_write, projection):
+ from pypaimon.tests.native_changelog_batch_test import _table as
changelog_table, _write as write
+
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ table = changelog_table(catalog, {'bucket': '1', 'write.native.enabled':
str(native_write).lower()})
+ for index, kind in enumerate([RowKind.INSERT, RowKind.UPDATE_AFTER,
RowKind.UPDATE_BEFORE, RowKind.DELETE]):
+ write(table, (index + 1) * 100, [{'key': 1, 'value': index}],
kind=kind)
+ table = table.copy({'scan.mode': 'incremental',
'incremental-between-timestamp': '0,400',
+ 'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ builder = table.new_read_builder().with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ rows = _rows(read, splits)
+ assert sorted((values, kind.value) for values, kind in rows) == sorted([
+ ((index, 1) if projection else (), kind.value)
+ for index, kind in enumerate([RowKind.INSERT, RowKind.UPDATE_AFTER,
RowKind.UPDATE_BEFORE, RowKind.DELETE])])
+
+
[email protected]('mode', ['append', 'de', 'pk'])
[email protected]('projection', [None, ['_row_kind'], ['_row_kind',
'id', '_row_kind']])
+def
test_iterator_user_row_kind_column_does_not_collide_with_transport(tmp_path,
mode, projection):
+ table, _ = _table(tmp_path, mode)
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ from pypaimon.schema.schema_change import SchemaChange
+ from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+
+ catalog.alter_table('db.t', [SchemaChange.add_column('_row_kind',
AtomicType('STRING'))], False)
+ table = catalog.get_table('db.t').copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+ _write(table, [{'id': 4, 'p': 'c', 'v': 40, '_row_kind': 'user value'}],
schema)
+ builder = table.new_read_builder()
+ python = table.copy({'read.native.enabled': 'false'}).new_read_builder()
+ if projection is not None:
+ builder.with_projection(projection)
+ python.with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ expected = _rows(python.new_read(), splits)
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ assert _rows(read, splits) == expected
+
+
[email protected]('duplicate', [False, True])
+def
test_iterator_reads_parquet_vector_sidecars_and_keeps_typed_accessors(tmp_path,
duplicate):
+ from pypaimon.tests.native_parquet_vector_update_test import _table as
vector_table
+
+ table, _ = vector_table(tmp_path, native=True)
+ table = table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ projection = ['embedding', 'id', 'payload'] + (['embedding'] if duplicate
else [])
+ builder = table.new_read_builder().with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ result = []
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ for row in read.to_iterator(splits):
+ id_ = row.get_field(1)
+ result.append(id_)
+ assert row.get_vector(0).to_list() == [float(id_), float(id_ + 1)]
+ assert row.get_blob(2).to_data() == ('blob-%d' % id_).encode()
+ if duplicate:
+ assert row.get_vector(3) == row.get_vector(0)
+ assert sorted(result) == [1, 2, 3, 4]
+
+
[email protected]('mode', ['native', 'setup-fallback', 'wire-fallback'])
+def
test_iterator_keeps_plan_authorization_across_splits_and_fallback(rest_catalog,
mode):
+ from pypaimon.api.api_response import AuthTableQueryResponse
+ from pypaimon.common.predicate_builder import PredicateBuilder
+ from pypaimon.read.split_serializer import deserialize_split_v1,
serialize_split_v1
+ from pypaimon.schema.schema_change import SchemaChange
+
+ catalog, server = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('p', pa.string()), ('secret',
pa.string())])
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(schema,
partition_keys=['p'], options={
+ 'write.native.enabled': 'false', 'source.split.target-size': '1b'}),
False)
+ table = catalog.get_table('default.t')
+ _write(table, [{'id': i, 'p': str(i), 'secret': 'hidden'} for i in [1, 2,
3]], schema)
+ catalog.alter_table(table.identifier,
[SchemaChange.set_option('query-auth.enabled', 'true')])
+ table = catalog.get_table('default.t').copy({'read.native.enabled':
'true', 'scan.native-plan.enabled': 'true'})
+ server.table_query_auth_store[table.identifier.get_full_name()] =
AuthTableQueryResponse(
+ filter=[json.dumps({'kind': 'LEAF', 'transform': {'name': 'FIELD_REF',
'fieldRef': {
+ 'name': 'id', 'type': 'INT', 'index': 0}}, 'function':
'GREATER_THAN', 'literals': [1]})],
+ column_masking={'secret': json.dumps({'name': 'CONCAT', 'inputs':
['visible']})})
+ builder = (table.new_read_builder().with_projection(['id']).with_limit(2)
+ .with_filter(PredicateBuilder(table.fields).equal('secret',
'visible')))
+ splits = builder.new_scan().plan().splits()
+ assert len(splits) == 3
+ # A later grant must not replace the policy captured by the plan, including
+ # its hidden masking dependency and the quota after authorization
filtering.
+ server.table_query_auth_store[table.identifier.get_full_name()] =
AuthTableQueryResponse()
+ read = builder.new_read()
+ if mode == 'native':
+ context = patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback'))
+ else:
+ failure = RuntimeError('setup failed')
+ if mode == 'wire-fallback':
+ splits = [deserialize_split_v1(serialize_split_v1(split), []) for
split in splits]
+ failure = AssertionError('Serialized rules must not mint a native
grant')
+ context = patch('pypaimon.read.native_plan._prepare_native_read',
side_effect=failure)
+ with context:
+ assert sorted(_rows(read, splits), key=lambda row: row[0]) == [
+ ((2,), RowKind.INSERT), ((3,), RowKind.INSERT)]
+
+
[email protected]('operation', ['update', 'delete'])
+def
test_iterator_data_evolution_applies_partial_updates_and_deletion_vectors(tmp_path,
operation):
+ table, _ = _table(tmp_path, 'de')
+ builder = table.new_batch_write_builder()
+ update = builder.new_update()
+ predicate = table.new_read_builder().new_predicate_builder().equal('id', 2)
+ messages = (update.update_by_predicate(predicate, {'v': 99}) if operation
== 'update'
+ else update.delete_by_predicate(predicate))
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+ projection = ['id', 'v', '_ROW_ID']
+ builder = table.new_read_builder().with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ expected = _rows(table.copy({'read.native.enabled':
'false'}).new_read_builder()
+ .with_projection(projection).new_read(), splits)
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('Python fallback')):
+ actual = _rows(read, splits)
+ assert actual == expected
+ assert {row[0]: row[1] for row, _ in actual} == (
+ {1: 10, 2: 99, 3: 30} if operation == 'update' else {1: 10, 3: 30})
+
+
[email protected]('descriptors', [False, True])
[email protected]('duplicate', [False, True])
+def test_iterator_map_blob_leaf_keeps_blob_accessor(tmp_path, descriptors,
duplicate):
+ from pypaimon.tests.native_blob_collection_write_test import _table as
collection_table, _data
+
+ table = collection_table(tmp_path, native=False)
+ data, _ = _data(table, tmp_path, False, 1)
+ _write(table, data.to_pylist(), data.schema)
+ projection = ["attrs['first']", 'id'] + (["attrs['first']"] if duplicate
else [])
+ results = []
+ for native in [False, True]:
+ view = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': 'true',
+ 'blob-as-descriptor': str(descriptors).lower()})
+ builder = view.new_read_builder().with_projection(projection)
+ read, splits = builder.new_read(), builder.new_scan().plan().splits()
+ values = []
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch.object(
+ read, '_create_split_read',
side_effect=AssertionError('Python fallback')))
+ for row in read.to_iterator(splits):
+ assert len(row) == len(projection)
+ blob = row.get_blob(0)
+ payload = None if blob is None else blob.to_data()
+ values.append((row.get_field(1), payload))
+ if duplicate:
+ other = row.get_blob(2)
+ assert (None if other is None else other.to_data()) ==
payload
+ results.append(sorted(values))
+ assert results == [[(1, b'payload' * 10), (2, None), (3, None)]] * 2
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index 094eb89aaf..1a9e52a51e 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -52,6 +52,7 @@ def _table_read(limit=None):
read.predicate = None
read.read_type = [DataField(0, 'id', AtomicType('INT'))]
read.include_row_kind = False
+ read._row_kind_column = '_row_kind'
read.output_projection = None
read.table.options.row_tracking_enabled.return_value = False
read.table.fields = read.read_type