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 1da14460c1 [python] Expand native read coverage for nested and
streaming reads (#10009)
1da14460c1 is described below
commit 1da14460c1dfe90051e518c33eddbaf32c255ce4
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 19:00:19 2026 +0800
[python] Expand native read coverage for nested and streaming reads (#10009)
---
paimon-python/pypaimon/read/native_plan.py | 18 +-
paimon-python/pypaimon/read/stream_read_builder.py | 36 ++-
.../pypaimon/read/streaming_table_scan.py | 45 ++-
paimon-python/pypaimon/read/table_read.py | 101 +++++--
.../pypaimon/tests/native_plan_incremental_test.py | 85 +++++-
.../pypaimon/tests/native_plan_integration_test.py | 323 ++++++++++++++++++++-
paimon-python/pypaimon/tests/native_read_test.py | 60 ++++
.../pypaimon/tests/streaming_table_scan_test.py | 36 +++
.../tests/test_read_builder_nested_projection.py | 29 ++
9 files changed, 686 insertions(+), 47 deletions(-)
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index 6a15b41939..89ba3616cc 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -305,26 +305,36 @@ def _native_read_builder(table):
return builder
-def _configure_native_read_builder(builder, predicate, limit, projection):
- if projection is not None:
+def _configure_native_read_builder(builder, predicate, limit, projection,
+ nested_projection=None,
+ include_row_kind=False):
+ if nested_projection is not None:
+ builder = builder.with_nested_projection(nested_projection)
+ elif projection is not None:
builder = builder.with_projection(projection)
if predicate is not None:
builder = builder.with_filter(_predicate_to_native(predicate))
if limit is not None:
builder = builder.with_limit(limit)
+ if include_row_kind:
+ builder = builder.with_include_row_kind(True)
return builder
def native_read(table, splits, predicate: Optional[Predicate] = None,
limit: Optional[int] = None,
projection: Optional[List[str]] = None,
- blob_parallelism: Optional[int] = None):
+ blob_parallelism: Optional[int] = None,
+ nested_projection: Optional[List[List[str]]] = None,
+ include_row_kind: bool = False):
"""Read Rust ``Split`` objects into PyArrow ``RecordBatch`` objects."""
if not native_reader_available():
raise RuntimeError(
"read.native.enabled needs the pypaimon-rust native reader API")
builder = _configure_native_read_builder(
- _native_read_builder(table), predicate, limit, projection)
+ _native_read_builder(table), predicate, limit, projection,
+ nested_projection=nested_projection,
+ include_row_kind=include_row_kind)
if blob_parallelism is not None:
builder = builder.with_blob_parallelism(blob_parallelism)
reader = builder.new_read()
diff --git a/paimon-python/pypaimon/read/stream_read_builder.py
b/paimon-python/pypaimon/read/stream_read_builder.py
index 4ea4fbe694..0a2753eebe 100644
--- a/paimon-python/pypaimon/read/stream_read_builder.py
+++ b/paimon-python/pypaimon/read/stream_read_builder.py
@@ -26,10 +26,10 @@ from typing import Callable, List, Optional, Set
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.read.read_builder import ReadBuilder
from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
from pypaimon.read.table_read import TableRead
from pypaimon.schema.data_types import DataField
-from pypaimon.table.special_fields import SpecialFields
class StreamReadBuilder:
@@ -115,6 +115,8 @@ class StreamReadBuilder:
def new_streaming_scan(self) -> AsyncStreamingTableScan:
"""Create a new AsyncStreamingTableScan with this builder's
settings."""
+ projection = self._projection_builder()
+ projection._validate_map_key_filter()
scan = AsyncStreamingTableScan(
table=self.table,
predicate=self._predicate,
@@ -122,30 +124,38 @@ class StreamReadBuilder:
bucket_filter=self._bucket_filter,
consumer_id=self._consumer_id
)
- scan._read_type = self.read_type()
+ scan._read_type = projection._scan_read_type()
return scan
def new_read(self) -> TableRead:
"""Create a new TableRead with this builder's settings."""
+ projection = self._projection_builder()
+ projection._validate_map_key_filter()
return TableRead(
table=self.table,
predicate=self._predicate,
- read_type=self.read_type(),
+ read_type=projection.read_type(),
+ nested_name_paths=projection._nested_name_paths(),
include_row_kind=self._include_row_kind
)
def new_predicate_builder(self) -> PredicateBuilder:
"""Create a PredicateBuilder for building filter predicates."""
- return PredicateBuilder(self.read_type())
+ return self._projection_builder().new_predicate_builder()
def read_type(self) -> List[DataField]:
"""Get the read schema fields, applying projection if set."""
- table_fields = self.table.fields
-
- if not self._projection:
- return table_fields
- else:
- if self.table.options.row_tracking_enabled():
- table_fields =
SpecialFields.row_type_with_row_tracking(table_fields)
- field_map = {field.name: field for field in table_fields}
- return [field_map[name] for name in self._projection if name in
field_map]
+ return self._projection_builder().read_type()
+
+ def _nested_name_paths(self) -> Optional[List[List[str]]]:
+ """Return nested paths resolved with the batch read-builder rules."""
+ return self._projection_builder()._nested_name_paths()
+
+ def _projection_builder(self) -> ReadBuilder:
+ """Share projection and validation semantics with batch reads."""
+ builder = ReadBuilder(self.table)
+ if self._projection is not None:
+ builder.with_projection(self._projection)
+ if self._predicate is not None:
+ builder.with_filter(self._predicate)
+ return builder
diff --git a/paimon-python/pypaimon/read/streaming_table_scan.py
b/paimon-python/pypaimon/read/streaming_table_scan.py
index ee0cb669ee..7cdc137e01 100644
--- a/paimon-python/pypaimon/read/streaming_table_scan.py
+++ b/paimon-python/pypaimon/read/streaming_table_scan.py
@@ -348,14 +348,57 @@ class AsyncStreamingTableScan:
def _create_initial_plan(self, snapshot: Snapshot) -> Plan:
"""Create a Plan for the initial full scan of the latest snapshot."""
auth_result = self.__auth_query()
- plan = self.__create_initial_plan_raw(snapshot, auth_result)
+ plan = None
+ if auth_result is None:
+ plan = self._try_native_plan(snapshot.id)
+ if plan is None:
+ plan = self.__create_initial_plan_raw(snapshot, auth_result)
return wrap_plan_with_auth(auth_result, plan)
def _create_delta_plan(self, snapshot: Snapshot) -> Plan:
"""Read new files from delta_manifest_list
(changelog-producer=none)."""
+ plan = self._try_native_plan(
+ snapshot.id, incremental_range=(snapshot.id - 1, snapshot.id))
+ if plan is not None:
+ return plan
manifest_files = self._manifest_list_manager.read_delta(snapshot)
return self._create_plan_from_manifests(manifest_files, snapshot.id)
+ def _try_native_plan(self, expected_snapshot_id: int,
+ incremental_range=None) -> Optional[Plan]:
+ """Plan an initial or delta streaming frame with pypaimon-rust.
+
+ An arbitrary Python bucket predicate cannot be represented by the Rust
+ planner, so sharded stream consumers retain the Python plan. Any native
+ setup or planning error is an optimization miss and falls back before
+ a plan is returned.
+ """
+ if (not self.table.options.native_plan_enabled()
+ or self._bucket_filter is not None):
+ return None
+ try:
+ from pypaimon.read.native_plan import native_plan
+ plan = native_plan(
+ self.table,
+ predicate=self.predicate,
+ projection=(
+ [field.name for field in self._read_type]
+ if self._read_type is not None else None),
+ incremental_range=incremental_range,
+ )
+ if plan.snapshot_id != expected_snapshot_id:
+ logging.warning(
+ "Native streaming plan resolved snapshot %s, expected %s; "
+ "falling back to Python planning",
+ plan.snapshot_id, expected_snapshot_id)
+ return None
+ return plan
+ except Exception as error:
+ logging.warning(
+ "Native streaming plan failed, falling back to Python
planning: %s",
+ error)
+ return None
+
def _create_changelog_plan(self, snapshot: Snapshot) -> Plan:
"""Read from changelog_manifest_list
(changelog-producer=input/full-compaction/lookup)."""
manifest_files = self._manifest_list_manager.read_changelog(snapshot)
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index e697b51d2f..5595298422 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -24,6 +24,7 @@ from typing import Any, Callable, Dict, Iterator, List,
Optional
import pandas
import pyarrow
+import pyarrow.compute as pyarrow_compute
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate import Predicate
@@ -414,9 +415,6 @@ class TableRead:
"""Return Rust-read batches, or ``None`` when this read must fall
back."""
if not self.table.options.native_read_enabled():
return None
- # These Python-only output controls do not yet have native equivalents.
- if self.include_row_kind or self.nested_name_paths:
- return None
if self.table.options.file_format() not in _NATIVE_READ_FILE_FORMATS:
return None
if not splits:
@@ -446,13 +444,7 @@ class TableRead:
if streaming:
groups = self._native_split_groups(
rust_splits, parallelism, split_weights)
- read_kwargs = {
- 'predicate': self.predicate,
- 'limit': self.limit,
- 'projection': [field.name for field in self.read_type],
- }
- if blob_parallelism is not None:
- read_kwargs['blob_parallelism'] = blob_parallelism
+ read_kwargs = self._native_read_kwargs(blob_parallelism)
readers = []
try:
for group in groups:
@@ -484,13 +476,7 @@ class TableRead:
"Native read failed, falling back to the Python reader:
%s", e)
return None
try:
- read_kwargs = {
- 'predicate': self.predicate,
- 'limit': self.limit,
- 'projection': [field.name for field in self.read_type],
- }
- if blob_parallelism is not None:
- read_kwargs['blob_parallelism'] = blob_parallelism
+ read_kwargs = self._native_read_kwargs(blob_parallelism)
batches = native_read(self.table, rust_splits, **read_kwargs)
except Exception as e:
logger.warning(
@@ -498,6 +484,20 @@ class TableRead:
return None
return self._convert_native_batches(batches, schema)
+ def _native_read_kwargs(self, blob_parallelism=None):
+ kwargs = {
+ 'predicate': self.predicate,
+ 'limit': self.limit,
+ 'projection': [field.name for field in self.read_type],
+ }
+ if blob_parallelism is not None:
+ kwargs['blob_parallelism'] = blob_parallelism
+ if self.nested_name_paths:
+ kwargs['nested_projection'] = self.nested_name_paths
+ if self.include_row_kind:
+ kwargs['include_row_kind'] = True
+ return kwargs
+
@staticmethod
def _native_split_weight(split):
"""Estimate native split work from its physical data-file bytes."""
@@ -682,13 +682,7 @@ class TableRead:
if remaining_state.exhausted():
return []
try:
- read_kwargs = {
- 'predicate': self.predicate,
- 'limit': self.limit,
- 'projection': [field.name for field in self.read_type],
- }
- if blob_parallelism is not None:
- read_kwargs['blob_parallelism'] = blob_parallelism
+ read_kwargs = self._native_read_kwargs(blob_parallelism)
batches = native_read(self.table, rust_splits, **read_kwargs)
except Exception as e:
raise _NativeReadSetupError(str(e)) from e
@@ -701,6 +695,7 @@ class TableRead:
break
if allowed < batch.num_rows:
batch = batch.slice(0, allowed)
+ batch = self._prepare_native_batch(batch, schema)
batch = self._project_batch_to_output(batch)
result.append(self._try_to_pad_batch_by_schema(batch, schema))
if remaining_state.exhausted():
@@ -726,6 +721,7 @@ class TableRead:
continue
if remaining is not None and batch.num_rows > remaining:
batch = batch.slice(0, remaining)
+ batch = self._prepare_native_batch(batch, schema)
batch = self._project_batch_to_output(batch)
yield self._try_to_pad_batch_by_schema(batch, schema)
if remaining is not None:
@@ -737,6 +733,54 @@ class TableRead:
if close is not None:
close()
+ def _prepare_native_batch(self, batch, schema):
+ 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
+ for position, name in enumerate(batch.schema.names)
+ ])
+ if self.nested_name_paths:
+ batch = self._flatten_native_nested_batch(batch, schema)
+ return batch
+
+ def _flatten_native_nested_batch(self, batch, schema):
+ arrays = []
+ fields = []
+ if self.include_row_kind:
+ index = batch.schema.get_field_index(ROW_KIND_COLUMN)
+ if index < 0:
+ raise ValueError('Native row-kind read did not return rowkind')
+ arrays.append(batch.column(index))
+ fields.append(schema.field(ROW_KIND_COLUMN))
+
+ for path, output in zip(self.nested_name_paths, self.read_type):
+ index = batch.schema.get_field_index(path[0])
+ if index < 0:
+ raise ValueError(
+ "Native nested read did not return top-level field %r"
+ % path[0])
+ array = batch.column(index)
+ parent_nulls = []
+ for name in path[1:]:
+ if pyarrow.types.is_struct(array.type):
+ parent_nulls.append(pyarrow_compute.is_null(array))
+ array = array.field(name)
+ elif pyarrow.types.is_map(array.type):
+ array = pyarrow_compute.map_lookup(array, name, 'first')
+ else:
+ raise ValueError(
+ "Native nested path %r cannot descend through %s"
+ % (path, array.type))
+ for nulls in reversed(parent_nulls):
+ array = pyarrow_compute.if_else(
+ nulls, pyarrow.scalar(None, type=array.type), array)
+ arrays.append(array)
+ fields.append(schema.field(output.name))
+
+ return pyarrow.RecordBatch.from_arrays(
+ arrays, schema=pyarrow.schema(fields))
+
def _arrow_batch_generator(self, splits: List[Split], schema:
pyarrow.Schema,
blob_parallelism: int = 1) ->
Iterator[pyarrow.RecordBatch]:
chunk_size = 65536
@@ -1452,11 +1496,14 @@ class TableRead:
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:
+ 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 batch.schema.names == output_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]
+ arrays = [batch.column(name_to_pos[name]) for name in output_names]
+ fields = [batch.schema.field(name_to_pos[name]) for name in
output_names]
return pyarrow.RecordBatch.from_arrays(
arrays, schema=pyarrow.schema(fields))
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index 9dde912e44..afc46d5608 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -18,6 +18,7 @@
"""Compare committed timestamp windows, including cross-snapshot change
events."""
import json
+import os
from contextlib import ExitStack
from unittest.mock import patch
@@ -321,21 +322,103 @@ def
test_incremental_reader_preserves_all_physical_row_kinds(catalog, native):
assert plan.snapshot_id == snapshot_id
expected.append((str(kind), kind))
builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower(),
'incremental-between-timestamp':
'0,400'}).new_read_builder()
batch_scan = builder.new_scan()
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(batch_scan.file_scanner, 'scan',
side_effect=AssertionError('native fallback')))
- plan = batch_scan.plan()
+ plan = batch_scan.plan()
+ if native:
+ # The fixture rewrites Parquet bytes after commit to manufacture all
+ # four physical kinds. Refresh the opaque split's file-size metadata;
+ # real committed files already have matching manifest sizes.
+ from pypaimon_rust.datafusion import Split as NativeSplit
+ for split in plan.splits():
+ _, (state,) = split._native_split.__reduce__()
+ native_state = json.loads(bytes(state))
+ for native_file, data_file in zip(
+ native_state['data_files'], split.files):
+ native_file['_FILE_SIZE'] = os.path.getsize(
+ data_file.file_path)
+ split._native_split = NativeSplit(
+ json.dumps(native_state).encode())
rows = list(builder.new_read().to_iterator(plan.splits()))
assert sorted((row.get_field(1), row.get_row_kind().value) for row in
rows) == expected
+ if native:
+ read = builder.new_read()
+ read.include_row_kind = True
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('native row-kind read fell back')):
+ arrow = read.to_arrow(plan.splits(), parallelism=2)
+ assert sorted(zip(
+ arrow.column('v').to_pylist(),
+ arrow.column('_row_kind').to_pylist(),
+ )) == [('0', '+I'), ('1', '-U'), ('2', '+U'), ('3', '-D')]
# Initial streaming bootstrap remains a merged snapshot, where the last -D
removes the key.
initial =
scan._create_initial_plan(table.snapshot_manager().get_snapshot_by_id(4))
assert all(not split.is_streaming for split in initial.splits())
assert
list(table.new_read_builder().new_read().to_iterator(initial.splits())) == []
[email protected]_plan
[email protected](
+ not native_method_available('ReadBuilder', 'with_nested_projection'),
+ reason='pypaimon_rust nested native reader API required')
+def test_stream_read_builder_combines_native_nested_projection_and_row_kind(
+ catalog):
+ from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
+
+ schema = pa.schema([
+ ('k', pa.int64()),
+ ('payload', pa.struct([
+ ('score', pa.int32()),
+ ('ignored', pa.string()),
+ ])),
+ ])
+ catalog.create_table(
+ 'default.stream_nested',
+ Schema.from_pyarrow_schema(schema, options={'bucket': '-1'}),
+ False,
+ )
+ table = catalog.get_table('default.stream_nested')
+ write_builder = table.new_batch_write_builder()
+ writer, commit = write_builder.new_write(), write_builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist([
+ {'k': 1, 'payload': {'score': 10, 'ignored': 'a'}},
+ {'k': 2, 'payload': None},
+ ], schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ native_table = table.copy({
+ 'scan.native-plan.enabled': 'true',
+ 'read.native.enabled': 'true',
+ })
+ builder = (native_table.new_stream_read_builder()
+ .with_projection(['payload.score', 'k'])
+ .with_include_row_kind())
+ # Use the same delta-plan primitive as the streaming loop without polling.
+ scan = builder.new_streaming_scan()
+ assert isinstance(scan, AsyncStreamingTableScan)
+ snapshot = native_table.snapshot_manager().get_latest_snapshot()
+ plan = scan._create_delta_plan(snapshot)
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('streaming nested native read fell
back')):
+ rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+
+ assert rows == [
+ {'_row_kind': '+I', 'payload_score': 10, 'k': 1},
+ {'_row_kind': '+I', 'payload_score': None, 'k': 2},
+ ]
+
+
def test_streaming_reader_honors_explicit_split_deletion_vector(catalog,
native, tmp_path):
from pypaimon.deletionvectors.bitmap_deletion_vector import
BitmapDeletionVector
from pypaimon.table.source.deletion_file import DeletionFile
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 41858d3997..35eeb9edbc 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -16,6 +16,8 @@
# under the License.
import datetime
+import importlib.util
+import io
import os
import pickle
import tempfile
@@ -31,6 +33,7 @@ from pypaimon.read.native_plan import (
native_family_search_modes_available, native_method_available, native_read,
native_reader_available,
)
+from pypaimon.schema.schema_change import SchemaChange
from pypaimon.table.row.blob import BlobDescriptor, BlobRef
from pypaimon.utils.range import Range
@@ -51,6 +54,23 @@ def _has_native_row_ranges():
return hasattr(ReadBuilder, 'with_row_ranges')
+def _mosaic_supports_nested_row():
+ if importlib.util.find_spec('mosaic') is None:
+ return False
+
+ import mosaic
+
+ schema = pa.schema([('nested', pa.struct([('value', pa.int32())]))])
+ table = pa.Table.from_pylist([{'nested': {'value': 1}}], schema=schema)
+ try:
+ mosaic.write_table(table, io.BytesIO())
+ except RuntimeError as error:
+ if 'unsupported DataType: Struct' in str(error):
+ return False
+ raise
+ return True
+
+
@pytest.mark.native_plan
@unittest.skipUnless(_has_native_planner(),
"pypaimon_rust with split-planning API not installed")
@@ -262,11 +282,312 @@ class NativePlanIntegrationTest(unittest.TestCase):
with patch(
'pypaimon.read.table_read.TableRead._create_split_read',
side_effect=AssertionError('Python reader was used')):
+ native_read_instance = builder.new_read()
native = sorted(
- builder.new_read().to_arrow(plan.splits()).to_pylist(),
+ native_read_instance.to_arrow(plan.splits()).to_pylist(),
key=lambda row: row['k'])
+ native_read_instance.include_row_kind = True
+ with_row_kind = native_read_instance.to_arrow(
+ plan.splits()).to_pylist()
self.assertEqual(native, normal)
+ self.assertEqual(
+ {row['_row_kind'] for row in with_row_kind}, {'+I'})
+ self.assertEqual(
+ sorted(({key: value for key, value in row.items()
+ if key != '_row_kind'} for row in with_row_kind),
+ key=lambda row: row['k']),
+ normal,
+ )
+
+ @unittest.skipUnless(
+ native_method_available('ReadBuilder', 'with_nested_projection'),
+ "pypaimon-rust nested native reader API not installed")
+ def test_native_read_nested_rows_and_map_keys_across_formats(self):
+ base_fields = [
+ ('id', pa.int64()),
+ ('payload', pa.struct([
+ ('details', pa.struct([
+ ('score', pa.int32()),
+ ('label', pa.string()),
+ ])),
+ ('ignored', pa.string()),
+ ])),
+ ]
+ rows = [
+ {'id': 1,
+ 'payload': {'details': {'score': 7, 'label': 'a'}, 'ignored':
'x'},
+ 'attrs': {'selected': 10, 'other': 11},
+ 'top.with.dot': 'first'},
+ {'id': 2, 'payload': None, 'attrs': None, 'top.with.dot':
'second'},
+ {'id': 3,
+ 'payload': {'details': None, 'ignored': 'z'},
+ 'attrs': {},
+ 'top.with.dot': 'third'},
+ ]
+ formats = ['parquet', 'orc', 'avro', 'row']
+ if _mosaic_supports_nested_row():
+ formats.append('mosaic')
+ for file_format in formats:
+ with self.subTest(file_format=file_format):
+ # PyPaimon's Avro writer has no MAP conversion; it still covers
+ # recursive ROW pruning. Other formats also exercise literal
+ # MAP-key extraction from the native batch.
+ include_map = file_format != 'avro'
+ include_dotted_name = file_format != 'avro'
+ nested_schema = pa.schema(
+ base_fields
+ + ([('attrs', pa.map_(pa.string(), pa.int32()))]
+ if include_map else [])
+ + ([('top.with.dot', pa.string())]
+ if include_dotted_name else []))
+ projection = ['payload.details.score']
+ if include_map:
+ projection.append("attrs['selected']")
+ if include_dotted_name:
+ projection.append('top.with.dot')
+ projection.append('id')
+ expected = [
+ {'payload_details_score': 7,
+ 'top.with.dot': 'first', 'id': 1},
+ {'payload_details_score': None,
+ 'top.with.dot': 'second', 'id': 2},
+ {'payload_details_score': None,
+ 'top.with.dot': 'third', 'id': 3},
+ ]
+ if include_map:
+ for expected_row, value in zip(expected, [10, None, None]):
+ expected_row['attrs_selected'] = value
+ if not include_dotted_name:
+ for expected_row in expected:
+ expected_row.pop('top.with.dot')
+ name = 'native_nested_' + file_format
+ options = {'file.format': file_format, 'bucket': '-1'}
+ if file_format == 'parquet':
+ options.update({
+ 'fields.attrs.map.storage-layout': 'shared-shredding',
+ 'fields.attrs.map.shared-shredding.max-columns': '2',
+ })
+ self.cat.create_table(
+ 'default.' + name,
+ Schema.from_pyarrow_schema(
+ nested_schema, options=options),
+ False,
+ )
+ table = self.cat.get_table('default.' + name)
+ write_builder = table.new_batch_write_builder()
+ write = write_builder.new_write()
+ write.write_arrow(pa.Table.from_pylist(rows,
schema=nested_schema))
+ write_builder.new_commit().commit(write.prepare_commit())
+ write.close()
+
+ native_table = table.copy({
+ 'scan.native-plan.enabled': 'true',
+ 'read.native.enabled': 'true',
+ })
+ builder = native_table.new_read_builder().with_projection(
+ projection)
+ plan = builder.new_scan().plan()
+ with patch(
+
'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError(
+ 'nested native read fell back to Python')):
+ actual = builder.new_read().to_arrow(
+ plan.splits(), parallelism=2).to_pylist()
+
+ self.assertEqual(actual, expected)
+
+ @unittest.skipUnless(
+ native_method_available('ReadBuilder', 'with_nested_projection'),
+ "pypaimon-rust nested native reader API not installed")
+ def test_native_read_nested_projection_after_primary_key_merge(self):
+ nested_schema = pa.schema([
+ ('id', pa.int64()),
+ ('payload', pa.struct([
+ ('score', pa.int32()),
+ ('ignored', pa.string()),
+ ])),
+ ])
+ self.cat.create_table(
+ 'default.native_nested_pk',
+ Schema.from_pyarrow_schema(
+ nested_schema, primary_keys=['id'], options={'bucket': '1'}),
+ False,
+ )
+ table = self.cat.get_table('default.native_nested_pk')
+ for rows in (
+ [{'id': 1, 'payload': {'score': 1, 'ignored': 'old'}},
+ {'id': 2, 'payload': {'score': 2, 'ignored': 'keep'}}],
+ [{'id': 1, 'payload': {'score': 10, 'ignored': 'new'}}],
+ ):
+ write_builder = table.new_batch_write_builder()
+ write = write_builder.new_write()
+ write.write_arrow(pa.Table.from_pylist(rows, schema=nested_schema))
+ write_builder.new_commit().commit(write.prepare_commit())
+ write.close()
+
+ native_table = table.copy({
+ 'scan.native-plan.enabled': 'true',
+ 'read.native.enabled': 'true',
+ })
+ builder = native_table.new_read_builder().with_projection(
+ ['id', 'payload.score'])
+ plan = builder.new_scan().plan()
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('nested PK native read fell back')):
+ actual = sorted(
+ builder.new_read().to_arrow(plan.splits()).to_pylist(),
+ key=lambda row: row['id'])
+
+ self.assertEqual(actual, [
+ {'id': 1, 'payload_score': 10},
+ {'id': 2, 'payload_score': 2},
+ ])
+
+ @unittest.skipUnless(
+ native_method_available('ReadBuilder', 'with_nested_projection'),
+ "pypaimon-rust nested native reader API not installed")
+ def test_native_nested_projection_after_partial_update_merge(self):
+ nested_schema = pa.schema([
+ ('id', pa.int64()),
+ ('payload', pa.struct([
+ ('score', pa.int32()),
+ ('ignored', pa.string()),
+ ])),
+ ])
+ self.cat.create_table(
+ 'default.native_nested_partial_update',
+ Schema.from_pyarrow_schema(
+ nested_schema,
+ primary_keys=['id'],
+ options={'bucket': '1', 'merge-engine': 'partial-update'},
+ ),
+ False,
+ )
+ table = self.cat.get_table('default.native_nested_partial_update')
+ for rows in (
+ [{'id': 1, 'payload': {'score': 1, 'ignored': 'old'}},
+ {'id': 2, 'payload': {'score': 2, 'ignored': 'keep'}}],
+ [{'id': 1, 'payload': {'score': 10, 'ignored': 'new'}}],
+ ):
+ write_builder = table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_write.write_arrow(pa.Table.from_pylist(rows,
schema=nested_schema))
+ write_builder.new_commit().commit(table_write.prepare_commit())
+ table_write.close()
+
+ native_table = table.copy({
+ 'scan.native-plan.enabled': 'true',
+ 'read.native.enabled': 'true',
+ })
+ builder = native_table.new_read_builder().with_projection(
+ ['id', 'payload.score'])
+ plan = builder.new_scan().plan()
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError(
+ 'nested partial-update native read fell back')):
+ actual = sorted(
+ builder.new_read().to_arrow(plan.splits()).to_pylist(),
+ key=lambda row: row['id'])
+
+ self.assertEqual(actual, [
+ {'id': 1, 'payload_score': 10},
+ {'id': 2, 'payload_score': 2},
+ ])
+
+ @unittest.skipUnless(
+ native_method_available('ReadBuilder', 'with_nested_projection'),
+ "pypaimon-rust nested native reader API not installed")
+ def
test_native_nested_projection_across_schema_rename_filter_and_limit(self):
+ name = 'default.native_nested_evolution'
+ old_schema = pa.schema([
+ ('id', pa.int64()),
+ ('payload', pa.struct([
+ ('old_score', pa.int32()),
+ ('ignored', pa.string()),
+ ])),
+ ])
+ self.cat.create_table(
+ name,
+ Schema.from_pyarrow_schema(old_schema, options={
+ 'bucket': '-1',
+ 'data-evolution.enabled': 'true',
+ 'row-tracking.enabled': 'true',
+ 'file.format': 'parquet',
+ }),
+ False,
+ )
+
+ def write(table, schema, rows):
+ write_builder = table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_write.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ write_builder.new_commit().commit(table_write.prepare_commit())
+ table_write.close()
+
+ table = self.cat.get_table(name)
+ write(table, old_schema, [
+ {'id': 1, 'payload': {'old_score': 10, 'ignored': 'old-1'}},
+ {'id': 2, 'payload': {'old_score': 20, 'ignored': 'old-2'}},
+ ])
+ self.cat.alter_table(
+ name,
+ [SchemaChange.rename_column(['payload', 'old_score'], 'score')],
+ False,
+ )
+ new_schema = pa.schema([
+ ('id', pa.int64()),
+ ('payload', pa.struct([
+ ('score', pa.int32()),
+ ('ignored', pa.string()),
+ ])),
+ ])
+ table = self.cat.get_table(name)
+ write(table, new_schema, [
+ {'id': 3, 'payload': {'score': 30, 'ignored': 'new-3'}},
+ {'id': 4, 'payload': None},
+ ])
+
+ native_table = table.copy({
+ 'scan.native-plan.enabled': 'true',
+ 'read.native.enabled': 'true',
+ 'read.parallelism': '3',
+ })
+ builder = native_table.new_read_builder().with_projection(
+ ['payload.score', 'id'])
+ predicate = builder.new_predicate_builder().greater_or_equal('id', 2)
+ builder.with_filter(predicate)
+ plan = builder.new_scan().plan()
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError(
+ 'evolved nested native read fell back to Python')):
+ actual = sorted(
+ builder.new_read().to_arrow(
+ plan.splits(), parallelism=3).to_pylist(),
+ key=lambda row: row['id'],
+ )
+
+ self.assertEqual(actual, [
+ {'payload_score': 20, 'id': 2},
+ {'payload_score': 30, 'id': 3},
+ {'payload_score': None, 'id': 4},
+ ])
+
+ limited = native_table.new_read_builder().with_projection(
+ ['payload.score', 'id']).with_limit(2)
+ limited_plan = limited.new_scan().plan()
+ with patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError(
+ 'limited nested native read fell back to Python')):
+ limited_rows = limited.new_read().to_arrow(
+ limited_plan.splits(), parallelism=3).to_pylist()
+ self.assertEqual(len(limited_rows), 2)
+ self.assertTrue(all(set(row) == {'payload_score', 'id'}
+ for row in limited_rows))
def test_append_distribution_matches_interleaved_partition_buckets(self):
self.schema = pa.schema([('k', pa.int64()), ('v', pa.string()), ('p',
pa.string())])
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index cae8c4deb5..8376a878d7 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -85,6 +85,66 @@ def
test_native_read_consumes_retained_rust_splits_and_enforces_limit():
)
+def test_native_read_flattens_nested_rows_and_map_keys_with_parent_nulls():
+ read = _table_read()
+ read.read_type = [
+ DataField(2, 'payload_score', AtomicType('INT')),
+ DataField(3, 'attrs_selected', AtomicType('INT')),
+ ]
+ read._output_column_names = [field.name for field in read.read_type]
+ read.nested_name_paths = [
+ ['payload', 'details', 'score'],
+ ['attrs', 'selected'],
+ ]
+ split = _Split()
+ split._native_split = object()
+ payload_type = pa.struct([
+ ('details', pa.struct([('score', pa.int32()), ('ignored',
pa.string())])),
+ ('ignored', pa.string()),
+ ])
+ batch = pa.record_batch([
+ pa.array([
+ {'details': {'score': 7, 'ignored': 'x'}, 'ignored': 'x'},
+ None,
+ {'details': None, 'ignored': 'z'},
+ ], type=payload_type),
+ pa.array([
+ [('selected', 10), ('other', 11)],
+ None,
+ [],
+ ], type=pa.map_(pa.string(), pa.int32())),
+ ], names=['payload', 'attrs'])
+
+ with patch('pypaimon.read.native_plan.native_read',
+ return_value=[batch]) as native:
+ result = read.to_arrow([split])
+
+ assert result.to_pydict() == {
+ 'payload_score': [7, None, None],
+ 'attrs_selected': [10, None, None],
+ }
+ assert native.call_args.kwargs['nested_projection'] ==
read.nested_name_paths
+
+
+def test_native_read_preserves_physical_row_kinds():
+ read = _table_read()
+ read.include_row_kind = True
+ split = _Split()
+ split._native_split = object()
+ batch = pa.record_batch([
+ pa.array(['+I', '-U', '+U', '-D']),
+ pa.array([1, 2, 3, 4], type=pa.int32()),
+ ], names=['rowkind', 'id'])
+
+ with patch('pypaimon.read.native_plan.native_read',
+ return_value=[batch]) as native:
+ result = read.to_arrow([split])
+
+ assert result.schema.names == ['_row_kind', 'id']
+ assert result.column('_row_kind').to_pylist() == ['+I', '-U', '+U', '-D']
+ assert native.call_args.kwargs['include_row_kind'] is True
+
+
def test_native_read_falls_back_for_transformed_python_split():
read = _table_read()
schema = pa.schema([('id', pa.int32())])
diff --git a/paimon-python/pypaimon/tests/streaming_table_scan_test.py
b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
index a1b0ffe58e..630f1324af 100644
--- a/paimon-python/pypaimon/tests/streaming_table_scan_test.py
+++ b/paimon-python/pypaimon/tests/streaming_table_scan_test.py
@@ -51,6 +51,7 @@ def _create_mock_table(latest_snapshot_id: int = 5):
table.options.data_evolution_enabled.return_value = False
table.options.deletion_vectors_enabled.return_value = False
table.options.changelog_producer.return_value = ChangelogProducer.NONE
+ table.options.native_plan_enabled.return_value = False
table.field_names = ['col1', 'col2']
table.trimmed_primary_keys = []
table.partition_keys = []
@@ -93,6 +94,41 @@ class AsyncStreamingTableScanTest(unittest.TestCase):
self.assertIsInstance(plan, Plan)
self.assertEqual(scan.next_snapshot_id, 6)
+ @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
+ @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
+ @patch('pypaimon.read.native_plan.native_plan')
+ def test_delta_scan_uses_native_plan_with_projection(
+ self, native_plan, _manifest_files, _manifest_lists):
+ table, _ = _create_mock_table()
+ table.options.native_plan_enabled.return_value = True
+ native_plan.return_value = Plan([], snapshot_id=5)
+ scan = AsyncStreamingTableScan(table, predicate=Mock())
+ scan._read_type = [Mock(name='payload'), Mock(name='id')]
+ scan._read_type[0].name = 'payload'
+ scan._read_type[1].name = 'id'
+
+ plan = scan._create_delta_plan(_create_mock_snapshot(5))
+
+ self.assertIs(plan, native_plan.return_value)
+ self.assertEqual(native_plan.call_args.kwargs['incremental_range'],
(4, 5))
+ self.assertEqual(
+ native_plan.call_args.kwargs['projection'], ['payload', 'id'])
+
+ @patch('pypaimon.read.streaming_table_scan.ManifestListManager')
+ @patch('pypaimon.read.streaming_table_scan.ManifestFileManager')
+ @patch('pypaimon.read.native_plan.native_plan')
+ def test_bucket_filtered_delta_scan_keeps_python_planner(
+ self, native_plan, _manifest_files, manifest_lists):
+ table, _ = _create_mock_table()
+ table.options.native_plan_enabled.return_value = True
+ manifest_lists.return_value.read_delta.return_value = []
+ scan = AsyncStreamingTableScan(table, bucket_filter=lambda bucket:
True)
+
+ plan = scan._create_delta_plan(_create_mock_snapshot(5))
+
+ self.assertEqual(plan.snapshot_id, 5)
+ native_plan.assert_not_called()
+
@patch('pypaimon.read.streaming_table_scan.ManifestListManager')
@patch('pypaimon.read.streaming_table_scan.FileScanner')
def test_stream_skips_non_append_commits(self, MockStartingScanner,
MockManifestListManager):
diff --git
a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
index 61e7396e40..4897bd96b1 100644
--- a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
+++ b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
@@ -141,5 +141,34 @@ class
ReadBuilderProjectionFieldIdTest(_ReadBuilderTestBase):
self.assertEqual(leaf_ids, [sub_v.id, sub_x.id])
+class StreamReadBuilderNestedProjectionTest(_ReadBuilderTestBase):
+
+ def test_stream_builder_matches_batch_nested_projection(self):
+ projection = [
+ 'mv.latest_version',
+ "attrs['key.with.dots']",
+ 'pk',
+ ]
+ batch = self.table.new_read_builder().with_projection(projection)
+ stream =
self.table.new_stream_read_builder().with_projection(projection)
+
+ self.assertEqual(
+ [field.name for field in batch.read_type()],
+ [field.name for field in stream.read_type()],
+ )
+ self.assertEqual(
+ batch._nested_name_paths(),
+ stream._nested_name_paths(),
+ )
+ self.assertEqual(
+ [field.name for field in batch.new_scan()._read_type],
+ [field.name for field in stream.new_streaming_scan()._read_type],
+ )
+
+ table_read = stream.with_include_row_kind().new_read()
+ self.assertEqual(batch._nested_name_paths(),
table_read.nested_name_paths)
+ self.assertTrue(table_read.include_row_kind)
+
+
if __name__ == '__main__':
unittest.main()