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 9e22d0f8d0 [python] Enable native read in Python Rust Plan CI (#10067)
9e22d0f8d0 is described below
commit 9e22d0f8d0e7e96edc249605af66aa3c567ada0d
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 11:33:06 2026 +0800
[python] Enable native read in Python Rust Plan CI (#10067)
---
.github/workflows/ci-python.yml | 3 +-
paimon-python/conftest.py | 99 ++++++++++++++++------
paimon-python/dev/lint-python.sh | 3 +-
paimon-python/pypaimon/data/variant_shredding.py | 25 ++++--
paimon-python/pypaimon/read/native_plan.py | 10 +--
.../pypaimon/read/reader/data_file_batch_reader.py | 39 +++++++--
.../read/reader/nested_leaf_batch_reader.py | 4 +-
.../pypaimon/tests/batch_vector_raw_scan_test.py | 4 +
paimon-python/pypaimon/tests/blob_test.py | 2 +
.../tests/contiguous_window_dataset_test.py | 2 +
.../pypaimon/tests/data_evolution_formats_test.py | 1 +
.../pypaimon/tests/deferred_blob_resolve_test.py | 2 +
.../pypaimon/tests/deletion_vector_path_test.py | 5 +-
.../pypaimon/tests/multimodal_table_test.py | 3 +
.../pypaimon/tests/multimodal_temporal_test.py | 3 +
.../pypaimon/tests/native_plan_incremental_test.py | 25 +++++-
paimon-python/pypaimon/tests/native_read_test.py | 15 ++++
.../pypaimon/tests/parquet_page_index_test.py | 1 +
.../pypaimon/tests/query_auth_validation_test.py | 2 +
.../pypaimon/tests/reader_parallel_test.py | 4 +
.../pypaimon/tests/rest/rest_base_test.py | 25 ++----
.../pypaimon/tests/rest/rest_read_write_test.py | 28 +++---
.../tests/schema_evolution_nested_read_test.py | 36 ++++++++
.../pypaimon/tests/schema_evolution_read_test.py | 27 +++---
.../pypaimon/tests/test_early_row_range_filter.py | 2 +
paimon-python/pypaimon/tests/variant_test.py | 37 ++++++++
26 files changed, 315 insertions(+), 92 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 0d9fd9abcf..ea2a988695 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -232,10 +232,11 @@ jobs:
assert hasattr(ReadBuilder, 'with_row_ranges'), 'Missing
ReadBuilder.with_row_ranges'
PY
- - name: Run Python tests with Rust planning
+ - name: Run Python tests with Rust planning and reading
shell: bash
env:
PYPAIMON_TEST_NATIVE_PLAN: '1'
+ PYPAIMON_TEST_NATIVE_READ: '1'
run: |
bash paimon-python/dev/lint-python.sh -i pytest
diff --git a/paimon-python/conftest.py b/paimon-python/conftest.py
index 7928854abc..b5090ed771 100644
--- a/paimon-python/conftest.py
+++ b/paimon-python/conftest.py
@@ -20,8 +20,11 @@ import pytest
_NATIVE_PLAN_ENV = "PYPAIMON_TEST_NATIVE_PLAN"
+_NATIVE_READ_ENV = "PYPAIMON_TEST_NATIVE_READ"
_native_plan_count = 0
+_native_read_count = 0
_force_native_for_test = False
+_force_native_read_for_test = False
def pytest_addoption(parser):
@@ -35,60 +38,106 @@ def _native_plan_enabled():
return os.environ.get(_NATIVE_PLAN_ENV) == "1"
+def _native_read_enabled():
+ return os.environ.get(_NATIVE_READ_ENV) == "1"
+
+
def pytest_configure(config):
config.addinivalue_line(
"markers", "python_plan: keep Python planner assertions on the Python
lane")
+ config.addinivalue_line(
+ "markers", "python_read: keep Python reader assertions on the Python
lane")
config.addinivalue_line(
"markers", "native_plan: exercise the real Rust planner in the Rust
main CI job")
- if not _native_plan_enabled():
- return
+ if _native_plan_enabled():
+ from pypaimon.read.table_scan import TableScan
+
+ original_plan = TableScan._try_native_plan
+
+ def tracked_plan(self):
+ global _native_plan_count
+ plan = original_plan(self)
+ if plan is not None and _force_native_for_test:
+ _native_plan_count += 1
+ return plan
+
+ TableScan._try_native_plan = tracked_plan
- from pypaimon.read.table_scan import TableScan
+ if _native_read_enabled():
+ from pypaimon.read.table_read import TableRead
- original = TableScan._try_native_plan
+ original_read = TableRead._try_native_batches
- def tracked(self):
- global _native_plan_count
- plan = original(self)
- if plan is not None and _force_native_for_test:
- _native_plan_count += 1
- return plan
+ def tracked_read(self, splits, *args, **kwargs):
+ global _native_read_count
+ batches = original_read(self, splits, *args, **kwargs)
+ if batches is not None and splits and _force_native_read_for_test:
+ _native_read_count += 1
+ return batches
- TableScan._try_native_plan = tracked
+ TableRead._try_native_batches = tracked_read
+
+
+def pytest_collection_modifyitems(items):
+ if _native_plan_enabled():
+ return
+ skip_native = pytest.mark.skip(reason="native plan tests run in the Rust
Plan job")
+ for item in items:
+ if item.get_closest_marker("native_plan") is not None:
+ item.add_marker(skip_native)
@pytest.fixture(autouse=True)
-def enable_native_plan(request, monkeypatch):
- global _force_native_for_test
- if (not _native_plan_enabled()
- or request.node.get_closest_marker("python_plan") is not None
- or request.path.name in (
- "native_plan_test.py", "native_plan_integration_test.py",
- "native_plan_capabilities_test.py")):
+def enable_native_plan_and_read(request, monkeypatch):
+ global _force_native_for_test, _force_native_read_for_test
+ python_plan = request.node.get_closest_marker("python_plan") is not None
+ python_read = request.node.get_closest_marker("python_read") is not None
+ native_plan_test = request.path.name in (
+ "native_plan_test.py", "native_plan_integration_test.py",
+ "native_plan_capabilities_test.py")
+ force_plan = _native_plan_enabled() and not python_plan and not
native_plan_test
+ force_read = (_native_read_enabled() and not python_plan and not
python_read
+ and not native_plan_test)
+ if not (force_plan or force_read):
yield
return
from pypaimon.common.options.core_options import CoreOptions
- original = CoreOptions.native_plan_enabled
+ if force_plan:
+ original_plan = CoreOptions.native_plan_enabled
- def enabled(self, default=None):
- return original(self, True if default is None else default)
+ def plan_enabled(self, default=None):
+ return original_plan(self, True if default is None else default)
- monkeypatch.setattr(CoreOptions, "native_plan_enabled", enabled)
- _force_native_for_test = True
+ monkeypatch.setattr(CoreOptions, "native_plan_enabled", plan_enabled)
+ if force_read:
+ original_read = CoreOptions.native_read_enabled
+
+ def read_enabled(self, default=None):
+ return original_read(self, True if default is None else default)
+
+ monkeypatch.setattr(CoreOptions, "native_read_enabled", read_enabled)
+ _force_native_for_test = force_plan
+ _force_native_read_for_test = force_read
try:
yield
finally:
_force_native_for_test = False
+ _force_native_read_for_test = False
def pytest_sessionfinish(session, exitstatus):
- if _native_plan_enabled() and exitstatus == 0 and _native_plan_count == 0:
- session.exitstatus = pytest.ExitCode.TESTS_FAILED
+ if exitstatus == 0:
+ if ((_native_plan_enabled() and _native_plan_count == 0)
+ or (_native_read_enabled() and _native_read_count == 0)):
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
def pytest_terminal_summary(terminalreporter):
if _native_plan_enabled():
terminalreporter.write_line(
"native plans exercised: %d" % _native_plan_count)
+ if _native_read_enabled():
+ terminalreporter.write_line(
+ "native reads exercised: %d" % _native_read_count)
diff --git a/paimon-python/dev/lint-python.sh b/paimon-python/dev/lint-python.sh
index 8ce00549e3..1795379f17 100755
--- a/paimon-python/dev/lint-python.sh
+++ b/paimon-python/dev/lint-python.sh
@@ -247,8 +247,9 @@ function pytest_torch_check() {
}
# Mixed tests check - runs Java-Python interoperability tests
function mixed_check() {
- # Native-plan coverage is asserted only by the main pytest session.
+ # Native plan/read coverage is asserted only by the main pytest session.
unset PYPAIMON_TEST_NATIVE_PLAN
+ unset PYPAIMON_TEST_NATIVE_READ
# Get Python version
PYTHON_VERSION=$(python -c "import sys;
print(f'{sys.version_info.major}.{sys.version_info.minor}')")
diff --git a/paimon-python/pypaimon/data/variant_shredding.py
b/paimon-python/pypaimon/data/variant_shredding.py
index d67af9bf7a..ae95d5f800 100644
--- a/paimon-python/pypaimon/data/variant_shredding.py
+++ b/paimon-python/pypaimon/data/variant_shredding.py
@@ -315,10 +315,14 @@ def _append_scalar(builder, value, arrow_type:
pa.DataType) -> None:
# Object / array binary construction
# ---------------------------------------------------------------------------
-def _build_object_value(fields: List[Tuple[int, bytes]]) -> bytes:
+def _build_object_value(
+ fields: List[Tuple[int, bytes]],
+ key_dict: Optional[Dict[str, int]] = None,
+) -> bytes:
"""Build object variant value bytes from ``(key_id, value_bytes)`` pairs.
- The variant spec requires fields sorted by key_id.
+ Variant object fields are ordered by key name, not metadata key ID. The
+ metadata dictionary may assign IDs in a different order.
"""
if not fields:
# Empty object: header + size=0 + one zero-offset sentinel
@@ -328,7 +332,14 @@ def _build_object_value(fields: List[Tuple[int, bytes]])
-> bytes:
buf.append(0) # offset[0] = 0 (sentinel)
return bytes(buf)
- fields = sorted(fields, key=lambda f: f[0])
+ if key_dict is None:
+ fields = sorted(fields, key=lambda f: f[0])
+ else:
+ id_to_name = {key_id: name for name, key_id in key_dict.items()}
+ fields = sorted(
+ fields,
+ key=lambda f: id_to_name[f[0]].encode('utf-8'),
+ )
size = len(fields)
data = b''.join(vb for _, vb in fields)
data_size = len(data)
@@ -520,7 +531,7 @@ def _rebuild_object(
if overflow_bytes:
fields.extend(_extract_overflow_fields(bytes(overflow_bytes)))
- return _build_object_value(fields)
+ return _build_object_value(fields, key_dict)
def _rebuild_array(
@@ -901,7 +912,8 @@ def _decompose_field_bytes(
else:
typed_value[fname] = {'value': None, 'typed_value': None}
- overflow_bytes = _build_object_value(overflow_pairs) if overflow_pairs
else None
+ overflow_bytes = (_build_object_value(overflow_pairs, key_dict)
+ if overflow_pairs else None)
return {'value': overflow_bytes, 'typed_value': typed_value}
# No shredding sub-schema: treat field bytes as overflow
@@ -959,7 +971,8 @@ def decompose_variant(
else:
typed_value[fname] = {'value': None, 'typed_value': None}
- overflow_bytes = _build_object_value(overflow_pairs) if overflow_pairs
else None
+ overflow_bytes = (_build_object_value(overflow_pairs, key_dict)
+ if overflow_pairs else None)
return {'metadata': metadata, 'value': overflow_bytes, 'typed_value':
typed_value}
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index adae794d29..0908be7dc3 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -415,11 +415,11 @@ def native_plan(
for split in rust_splits
]
if table.options.native_read_enabled():
- # Retain the opaque Rust split next to the Python metadata view. The
- # normal planner/reader contract remains a Python Split list, while
- # native reads can consume the exact Rust split without a second lossy
- # conversion. Any Python split transformation creates a fresh object
- # without this marker and thus safely falls back to the Python reader.
+ # Retain the opaque Rust split next to the Python metadata view so an
+ # unchanged native plan can be read without reserializing each split.
+ # A caller that needs different metadata (such as an endpoint DV)
+ # passes a new Python split, which the native reader converts at read
+ # time from its current fields.
for split, rust_split in zip(splits, rust_splits):
split._native_split = rust_split
_restore_python_partition_paths(table, splits)
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 bd98bccf2a..f739b12961 100644
--- a/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/data_file_batch_reader.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+from decimal import Decimal, localcontext, ROUND_HALF_UP
from typing import List, Optional, Tuple
import pyarrow as pa
@@ -42,6 +43,32 @@ def _is_character_string_type(data_type) -> bool:
return t == 'STRING' or t.startswith('VARCHAR') or t.startswith('CHAR')
+def cast_array_for_schema_evolution(array, target_type):
+ """Cast old-file values using Java's DECIMAL rounding and overflow rules.
+
+ PyArrow's unsafe DECIMAL scale reduction truncates and can retain values
+ outside the target precision. Java rounds HALF_UP and returns NULL for
+ values that do not fit after rounding. Other conversions retain the
+ existing unsafe-cast behavior (such as DOUBLE -> INT truncation).
+ """
+ if (pa.types.is_decimal(array.type) and pa.types.is_decimal(target_type)
+ and array.type.scale > target_type.scale):
+ values = []
+ with localcontext() as context:
+ # Rounding a maximal value can carry into one extra digit.
+ context.prec = max(array.type.precision, target_type.precision) + 1
+ quantum = Decimal(1).scaleb(-target_type.scale)
+ for value in array.to_pylist():
+ if value is None:
+ values.append(None)
+ continue
+ rounded = value.quantize(quantum, rounding=ROUND_HALF_UP)
+ values.append(rounded if len(rounded.as_tuple().digits)
+ <= target_type.precision else None)
+ return pa.array(values, type=target_type)
+ return array.cast(target_type, safe=False)
+
+
def _unslice(array):
"""Re-materialize a sliced array so offsets/buffers start at zero.
@@ -306,7 +333,7 @@ class DataFileBatchReader(RecordBatchReader):
# Leaf / non-nested: cast to the target type when it differs.
target_pa_type = PyarrowFieldParser.from_paimon_type(target_type)
if array.type != target_pa_type:
- return array.cast(target_pa_type, safe=False)
+ return cast_array_for_schema_evolution(array, target_pa_type)
return array
def read_arrow_batch(self, start_idx=None, end_idx=None) ->
Optional[RecordBatch]:
@@ -392,11 +419,9 @@ class DataFileBatchReader(RecordBatchReader):
type. Columns whose type already matches are reused as-is, keeping the
common (non-evolution) path zero-copy.
- Casts use ``safe=False`` to match Java ``CastExecutors`` semantics for
- the read-time conversions a user-approved schema evolution implies
- (e.g. DECIMAL scale-down or DOUBLE -> INT truncate rather than raise).
- Evolution legality is the writer's concern (``DataTypeCasts``); the
read
- path only materializes the result.
+ DECIMAL scale reductions use Java's HALF_UP and overflow-to-NULL
+ semantics; other conversions retain the existing unsafe Arrow cast.
+ Evolution legality is the writer's concern (``DataTypeCasts``).
"""
out_arrays = []
out_fields = []
@@ -405,7 +430,7 @@ class DataFileBatchReader(RecordBatchReader):
if target_field is None:
target_field = pa.field(name, array.type)
elif array.type != target_field.type:
- array = array.cast(target_field.type, safe=False)
+ array = cast_array_for_schema_evolution(array,
target_field.type)
out_arrays.append(array)
out_fields.append(target_field)
return pa.RecordBatch.from_arrays(out_arrays,
schema=pa.schema(out_fields))
diff --git a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
index f85c818081..874fd447c8 100644
--- a/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/nested_leaf_batch_reader.py
@@ -23,6 +23,8 @@ from pyarrow import RecordBatch
from pypaimon.data.map_shared_shredding import \
assemble_normal_map_selected_keys
+from pypaimon.read.reader.data_file_batch_reader import \
+ cast_array_for_schema_evolution
from pypaimon.read.reader.field_indices import (
blob_field_indices, descriptor_field_indices, vector_field_indices)
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
@@ -80,7 +82,7 @@ class NestedLeafBatchReader(RecordBatchReader):
column = _struct_field(column, name)
target_type = self._schema.field(i).type
if column.type != target_type:
- column = column.cast(target_type, safe=False)
+ column = cast_array_for_schema_evolution(column, target_type)
arrays.append(column)
return pa.RecordBatch.from_arrays(arrays, schema=self._schema)
diff --git a/paimon-python/pypaimon/tests/batch_vector_raw_scan_test.py
b/paimon-python/pypaimon/tests/batch_vector_raw_scan_test.py
index 6bc5b4f818..80ae700f16 100644
--- a/paimon-python/pypaimon/tests/batch_vector_raw_scan_test.py
+++ b/paimon-python/pypaimon/tests/batch_vector_raw_scan_test.py
@@ -20,6 +20,7 @@ import threading
from unittest.mock import patch
import pyarrow as pa
+import pytest
from pypaimon.read.table_read import TableRead
from pypaimon.table.source.vector_search_read import BatchVectorSearchReadImpl
@@ -117,6 +118,7 @@ class BatchVectorRawScanTest(BatchModeMixin,
DataEvolutionTestBase, unittest.Tes
results = reader._read_raw_batch_search([Range(0, 99)], None,
'ivf-flat')
self.assertEqual([{}, {}], [_scores(r) for r in results])
+ @pytest.mark.python_read
def test_scoring_finishes_each_batch_before_reading_the_next(self):
table = self._create_table()
self._write_arrow(table, self._data([[1, 0], [0, 1], [2, 0], [0, 2]]))
@@ -198,6 +200,7 @@ class BatchVectorRawScanTest(BatchModeMixin,
DataEvolutionTestBase, unittest.Tes
self.assertEqual([1.0], list(_scores(current[0]).values()))
self.assertNotEqual(list(old[0].results()), list(current[0].results()))
+ @pytest.mark.python_read
def test_public_batch_search_preserves_split_parallelism(self):
table = self._create_table(partition_keys=['pt'])
for partition in range(4):
@@ -246,6 +249,7 @@ class BatchVectorRawScanTest(BatchModeMixin,
DataEvolutionTestBase, unittest.Tes
self.assertEqual({'active': 0, 'peak':
expected_workers,
'closed': expected_workers}, state)
+ @pytest.mark.python_read
def test_parallel_failure_closes_all_started_readers(self):
table = self._create_table(
partition_keys=['pt'], options=dict(self.table_options,
**{'read.parallelism': '2'}))
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index dee9d21488..7e25b720a3 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -31,6 +31,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pyarrow as pa
+import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
@@ -5664,6 +5665,7 @@ class BlobParallelismTest(unittest.TestCase):
for i in range(20):
self.assertEqual(got[i], self.payloads[i])
+ @pytest.mark.python_read
def test_blob_fallback_parallelism_end_to_end(self):
t = self.catalog.get_table('default.bp_test')
diff --git a/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
b/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
index c0e5d4d7dd..c498cca3d0 100644
--- a/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
+++ b/paimon-python/pypaimon/tests/contiguous_window_dataset_test.py
@@ -24,6 +24,7 @@ import unittest
from unittest.mock import patch
import pyarrow as pa
+import pytest
import torch
import pypaimon.multimodal as pmm
@@ -780,6 +781,7 @@ class ContiguousWindowDatasetTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, r"masks \['episode'\]"):
self._dataset(table)
+ @pytest.mark.python_read
def test_reads_only_the_files_and_row_ranges_a_window_touches(self):
table = self.conn.create_table(
"many_files", schema=self._schema(), options=_TABLE_OPTIONS)
diff --git a/paimon-python/pypaimon/tests/data_evolution_formats_test.py
b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
index 73057665ee..d3fb26ceec 100644
--- a/paimon-python/pypaimon/tests/data_evolution_formats_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_formats_test.py
@@ -197,6 +197,7 @@ class DataEvolutionFormatsTest(unittest.TestCase):
for file_meta in all_files:
self.assertEqual([], self._row_sidecar_files(file_meta))
+ @pytest.mark.python_read
def test_row_sidecar_serves_sparse_row_id_read(self):
pa_schema = pa.schema([
('id', pa.int32()),
diff --git a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
index e23ff0e8df..ee013017d6 100644
--- a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
+++ b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
@@ -24,6 +24,7 @@ from unittest.mock import patch
import pyarrow as pa
import pyarrow.compute as pc
+import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
@@ -104,6 +105,7 @@ class _PayloadAuthResult(TableQueryAuthResult):
batch.column("payload"), self._expected_payload)
[email protected]_read
class DeferredBlobResolveTest(unittest.TestCase):
@classmethod
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index da61d04dc4..b1d9d4ce72 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -195,8 +195,11 @@ def
test_missing_explicit_dv_is_not_replaced_by_a_local_copy(tmp_path, planner):
with table.file_io.new_output_stream(directory + '/' + file.file_name)
as stream:
stream.write(data)
table.file_io.delete_quietly(file.external_path)
- with pytest.raises(FileNotFoundError):
+ # The Python filesystem raises FileNotFoundError; the native reader wraps
+ # the same missing explicit path in its storage error.
+ with pytest.raises((FileNotFoundError, ValueError)) as error:
_read(table, planner, 2, [1, 2, 3])
+ assert file.file_name in str(error.value)
@pytest.mark.parametrize('planner', _PLANNERS)
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 3d4de045ee..2e7e680f72 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -26,6 +26,7 @@ from datetime import timedelta
from unittest.mock import patch
import pyarrow as pa
+import pytest
import pypaimon.multimodal as pmm
from pypaimon.multimodal import source_col
from pypaimon.common.predicate_builder import PredicateBuilder
@@ -630,6 +631,7 @@ class MultimodalTableTest(unittest.TestCase):
store.delete_object("images/cat.jpg")
self.assertEqual([], store.list_objects(prefix="images/"))
+ @pytest.mark.python_read
def test_blob_store_list_reads_batches_and_stops_at_limit(self):
from pypaimon.read.table_read import TableRead
@@ -1079,6 +1081,7 @@ class MultimodalTableTest(unittest.TestCase):
self.assertEqual(1, result.num_rows)
self.assertEqual([1], result["id"].to_pylist())
+ @pytest.mark.python_read
def test_scan_to_arrow_batch_reader(self):
users = self.conn.create_table(
"batch_users",
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
index 74eb806885..f74b8fe245 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
@@ -26,6 +26,7 @@ from unittest import mock
import numpy as np
import pyarrow as pa
+import pytest
import pypaimon.multimodal as pmm
from pypaimon.multimodal import temporal
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
@@ -741,6 +742,7 @@ class MultimodalTemporalTest(unittest.TestCase):
)
self.assertEqual(expected, row["matches"])
+ @pytest.mark.python_read
def test_window_join_prunes_unaggregated_right_columns(self):
anchors = self._table("window_projection_anchors", {
"episode_id": pa.int32(),
@@ -2402,6 +2404,7 @@ class MultimodalTemporalTest(unittest.TestCase):
self.assertIsNone(aligned.resolved_snapshots["right_1"]["snapshot_id"])
self.assertIsNone(aligned.to_list()[0]["value"])
+ @pytest.mark.python_read
def test_alignment_reuses_decoded_parquet_row_groups_across_batches(self):
anchors = self._table("cached_anchors", {
"episode_id": pa.int32(),
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index 17b4627b1a..54a32fd67a 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -28,6 +28,7 @@ import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.identifier import Identifier
from pypaimon.read.native_plan import native_method_available
+from pypaimon.read.split import DataSplit
from pypaimon.utils.range import Range
@@ -538,8 +539,24 @@ def
test_streaming_reader_honors_explicit_split_deletion_vector(catalog, native,
assert len(plan.splits()) == 1
split = plan.splits()[0]
assert split.is_streaming
- split.data_deletion_files = [DeletionFile(str(path), 0, len(encoded) - 8,
1)]
- # Planners do not attach endpoint DVs, but an explicit split DV is part of
- # the reader contract, including in Java streaming frames.
- result =
table.new_read_builder().new_read().to_arrow(plan.splits()).to_pylist()
+ # The planner cannot attach an endpoint DV. Build the reader input split
+ # with that DV instead of mutating a previously planned (and cached) split.
+ dv_split = DataSplit(
+ files=split.files,
+ partition=split.partition,
+ bucket=split.bucket,
+ raw_convertible=split.raw_convertible,
+ data_deletion_files=[DeletionFile(str(path), 0, len(encoded) - 8, 1)],
+ snapshot_id=split.snapshot_id,
+ is_streaming=split.is_streaming,
+ bucket_path=split.bucket_path,
+ total_buckets=split.total_buckets,
+ )
+ read_table = table.copy({'read.native.enabled': str(native).lower()})
+ with ExitStack() as stack:
+ if native:
+ stack.enter_context(patch(
+ 'pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('explicit DV native read fell
back')))
+ result =
read_table.new_read_builder().new_read().to_arrow([dv_split]).to_pylist()
assert result == [{'k': 1, 'v': '1'}, {'k': 3, 'v': '3'}]
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index 6a33c32374..63e49ed50b 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -700,6 +700,21 @@ def
test_native_read_falls_back_for_unsupported_file_format():
native.assert_not_called()
+def test_native_avro_read_uses_native_path():
+ read = _table_read()
+ read.table.options.file_format.return_value = 'avro'
+ split = _Split('data.avro')
+ split._native_split = object()
+
+ with patch('pypaimon.read.native_plan.native_read',
+ return_value=[_id_batch([7])]) as native:
+ batches = list(read._try_native_batches(
+ [split], pa.schema([('id', pa.int32())])))
+
+ assert batches[0].column('id').to_pylist() == [7]
+ native.assert_called_once()
+
+
def test_native_read_falls_back_for_unsupported_dedicated_file():
read = _table_read()
schema = pa.schema([('id', pa.int32())])
diff --git a/paimon-python/pypaimon/tests/parquet_page_index_test.py
b/paimon-python/pypaimon/tests/parquet_page_index_test.py
index 8a33e0b1dc..5af33654d4 100644
--- a/paimon-python/pypaimon/tests/parquet_page_index_test.py
+++ b/paimon-python/pypaimon/tests/parquet_page_index_test.py
@@ -403,6 +403,7 @@ def
test_page_index_switch_bypasses_metadata_processing_when_disabled(fixture, v
@pytest.mark.parametrize('nested', [False, True])
[email protected]_plan
def test_table_option_and_copy_control_page_index_reads(tmp_path, nested):
from pypaimon import CatalogFactory, Schema
diff --git a/paimon-python/pypaimon/tests/query_auth_validation_test.py
b/paimon-python/pypaimon/tests/query_auth_validation_test.py
index d95affffab..25ed0392bb 100644
--- a/paimon-python/pypaimon/tests/query_auth_validation_test.py
+++ b/paimon-python/pypaimon/tests/query_auth_validation_test.py
@@ -23,6 +23,7 @@ import unittest
from types import SimpleNamespace
import pyarrow as pa
+import pytest
from pypaimon.common.options import CoreOptions, Options
from pypaimon.read.query_auth_split import QueryAuthSplit
@@ -833,6 +834,7 @@ class TestSchemaRefreshAsksTheCatalog(unittest.TestCase):
read.to_arrow([QueryAuthSplit(object(), auth)], parallelism=1)
self.assertEqual(table.catalog_environment.get_table_calls, 3)
+ @pytest.mark.python_read
def test_a_read_without_rules_never_asks_the_catalog(self):
fields = [_string_field(0, "id"), _string_field(1, "email")]
table = self._table(fields, fields)
diff --git a/paimon-python/pypaimon/tests/reader_parallel_test.py
b/paimon-python/pypaimon/tests/reader_parallel_test.py
index ea0c3d8f61..b31ba265e4 100644
--- a/paimon-python/pypaimon/tests/reader_parallel_test.py
+++ b/paimon-python/pypaimon/tests/reader_parallel_test.py
@@ -24,6 +24,7 @@ import unittest
from unittest import mock
import pyarrow as pa
+import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.read.table_read import TableRead, _RemainingRows
@@ -237,6 +238,7 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
self.assertEqual(serial, auto)
self.assertEqual(auto.num_rows, self.expected_rows)
+ @pytest.mark.python_read
def test_default_none_auto_takes_parallel_path_when_multicore(self):
read = self.table.new_read_builder().new_read()
splits = self._scan_splits(self.table.new_read_builder())
@@ -263,6 +265,7 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
splits = self._scan_splits(self.table_opt_4.new_read_builder())
read.to_arrow(splits, parallelism=1)
+ @pytest.mark.python_read
def test_method_arg_overrides_option_to_parallel(self):
# option=1 (forces serial) but caller passes 4: should enable
parallelism.
read = self.table_opt_1.new_read_builder().new_read()
@@ -349,6 +352,7 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
df = rb.new_read().to_pandas(splits, parallelism=4)
self.assertEqual(len(df), limit)
+ @pytest.mark.python_read
def test_parallel_reader_error_propagates(self):
rb = self.table.new_read_builder()
splits = self._scan_splits(rb)
diff --git a/paimon-python/pypaimon/tests/rest/rest_base_test.py
b/paimon-python/pypaimon/tests/rest/rest_base_test.py
index 2ae483e6f1..763ac39023 100644
--- a/paimon-python/pypaimon/tests/rest/rest_base_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_base_test.py
@@ -199,22 +199,15 @@ class RESTBaseTest(unittest.TestCase):
self.assertTrue(os.path.exists(self.warehouse +
"/default/test_table/dt=p1"))
self.assertEqual(len(glob.glob(self.warehouse +
"/default/test_table/manifest/*")), 3)
- def _write_test_table(self, table):
+ def _write_test_table(self, table, data=None):
write_builder = table.new_batch_write_builder()
- table_pa_schema = self.pk_pa_schema if table.primary_keys else
self.pa_schema
+ if data is None:
+ data = self.pk_expected if table.primary_keys else self.expected
# first write
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
- data1 = {
- 'user_id': [1, 2, 3, 4],
- 'item_id': [1001, 1002, 1003, 1004],
- 'behavior': ['a', 'b', 'c', None],
- 'dt': ['p1', 'p1', 'p2', 'p1'],
- 'long-dt': ['2024-10-10', '2024-10-10', '2024-10-10',
'2024-01-01'],
- }
- pa_table = pa.Table.from_pydict(data1, schema=table_pa_schema)
- table_write.write_arrow(pa_table)
+ table_write.write_arrow(data.slice(0, 4))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
@@ -222,15 +215,7 @@ class RESTBaseTest(unittest.TestCase):
# second write
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
- data2 = {
- 'user_id': [5, 6, 7, 8],
- 'item_id': [1005, 1006, 1007, 1008],
- 'behavior': ['e', 'f', 'g', 'h'],
- 'dt': ['p2', 'p1', 'p2', 'p2'],
- 'long-dt': ['2024-10-10', '2025-01-23', 'abcdefghijklmnopk',
'2025-08-08'],
- }
- pa_table = pa.Table.from_pydict(data2, schema=table_pa_schema)
- table_write.write_arrow(pa_table)
+ table_write.write_arrow(data.slice(4, 4))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
diff --git a/paimon-python/pypaimon/tests/rest/rest_read_write_test.py
b/paimon-python/pypaimon/tests/rest/rest_read_write_test.py
index f46ce5444b..4c9a9fd066 100644
--- a/paimon-python/pypaimon/tests/rest/rest_read_write_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_read_write_test.py
@@ -34,6 +34,12 @@ import ray
class RESTTableReadWriteTest(RESTBaseTest):
+ @staticmethod
+ def _avro_field_names(data):
+ return data.rename_columns([
+ 'long_dt' if name == 'long-dt' else name for name in
data.schema.names
+ ])
+
def test_overwrite(self):
simple_pa_schema = pa.schema([
('f0', pa.int32()),
@@ -201,14 +207,15 @@ class RESTTableReadWriteTest(RESTBaseTest):
self.assertEqual(actual, self.expected)
def test_avro_ao_reader(self):
- schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'], options={'file.format': 'avro'})
+ expected = self._avro_field_names(self.expected)
+ schema = Schema.from_pyarrow_schema(expected.schema,
partition_keys=['dt'], options={'file.format': 'avro'})
self.rest_catalog.create_table('default.test_append_only_avro',
schema, False)
table = self.rest_catalog.get_table('default.test_append_only_avro')
- self._write_test_table(table)
+ self._write_test_table(table, expected)
read_builder = table.new_read_builder()
actual = self._read_test_table(read_builder).sort_by('user_id')
- self.assertEqual(actual, self.expected)
+ self.assertEqual(actual, expected)
def test_lance_ao_reader(self):
schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'], options={'file.format': 'lance'})
@@ -286,15 +293,15 @@ class RESTTableReadWriteTest(RESTBaseTest):
self.assertEqual(actual, expected)
def test_avro_ao_reader_with_projection(self):
- schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'], options={'file.format': 'avro'})
+ expected = self._avro_field_names(self.expected)
+ schema = Schema.from_pyarrow_schema(expected.schema,
partition_keys=['dt'], options={'file.format': 'avro'})
self.rest_catalog.create_table('default.test_avro_append_only_projection',
schema, False)
table =
self.rest_catalog.get_table('default.test_avro_append_only_projection')
- self._write_test_table(table)
+ self._write_test_table(table, expected)
read_builder = table.new_read_builder().with_projection(['dt',
'user_id'])
actual = self._read_test_table(read_builder).sort_by('user_id')
- expected = self.expected.select(['dt', 'user_id'])
- self.assertEqual(actual, expected)
+ self.assertEqual(actual, expected.select(['dt', 'user_id']))
def test_ao_reader_with_limit(self):
schema = Schema.from_pyarrow_schema(self.pa_schema,
partition_keys=['dt'])
@@ -344,7 +351,8 @@ class RESTTableReadWriteTest(RESTBaseTest):
self.assertEqual(col_a, col_b)
def test_pk_avro_reader(self):
- schema = Schema.from_pyarrow_schema(self.pa_schema,
+ expected = self._avro_field_names(self.pk_expected)
+ schema = Schema.from_pyarrow_schema(expected.schema,
partition_keys=['dt'],
primary_keys=['user_id', 'dt'],
options={
@@ -353,11 +361,11 @@ class RESTTableReadWriteTest(RESTBaseTest):
})
self.rest_catalog.create_table('default.test_pk_avro', schema, False)
table = self.rest_catalog.get_table('default.test_pk_avro')
- self._write_test_table(table)
+ self._write_test_table(table, expected)
read_builder = table.new_read_builder()
actual = self._read_test_table(read_builder).sort_by('user_id')
- self.assertEqual(actual, self.pk_expected)
+ self.assertEqual(actual, expected)
def test_pk_lance_reader(self):
schema = Schema.from_pyarrow_schema(self.pa_schema,
diff --git a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
index 7a9971c449..74bfec80f3 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_nested_read_test.py
@@ -28,6 +28,7 @@ Two layers are covered:
is not revived, and a type change is cast at read time.
"""
+import decimal
import os
import shutil
import tempfile
@@ -560,6 +561,41 @@ class SchemaEvolutionNestedContainerTest(_NestedBase):
MapType(True, AtomicType('STRING'), AtomicType('BIGINT')))
self.assertEqual(out.to_pylist(), [[('b', 2)], None])
+ def test_nested_decimal_scale_down_rounds_and_nulls_overflow(self):
+ from pypaimon.read.reader.data_file_batch_reader import \
+ DataFileBatchReader
+ reader = DataFileBatchReader.__new__(DataFileBatchReader)
+ source_type = RowType(True, [
+ DataField(1, 'price', AtomicType('DECIMAL(6, 3)'))])
+ target_type = RowType(True, [
+ DataField(1, 'price', AtomicType('DECIMAL(3, 2)'))])
+ source = pa.array([
+ {'price': decimal.Decimal('9.994')},
+ {'price': decimal.Decimal('9.995')},
+ {'price': decimal.Decimal('-4.565')},
+ None,
+ ], type=pa.struct([pa.field('price', pa.decimal128(6, 3))]))
+
+ actual = reader._align_array_by_id(source, source_type, target_type)
+
+ self.assertEqual(actual.to_pylist(), [
+ {'price': decimal.Decimal('9.99')},
+ {'price': None},
+ {'price': decimal.Decimal('-4.57')},
+ None,
+ ])
+
+ def test_decimal_scale_down_preserves_max_precision_values(self):
+ from pypaimon.read.reader.data_file_batch_reader import \
+ cast_array_for_schema_evolution
+ value = decimal.Decimal('12345678901234567890123456789012345.678')
+ source = pa.array([value], type=pa.decimal128(38, 3))
+
+ actual = cast_array_for_schema_evolution(source, pa.decimal128(38, 2))
+
+ self.assertEqual(actual.to_pylist(), [
+ decimal.Decimal('12345678901234567890123456789012345.68')])
+
def test_map_wrapper_token_validated(self):
# The token consumed when descending through a MAP must be 'value'.
val = pa.struct([('a', pa.int64())])
diff --git a/paimon-python/pypaimon/tests/schema_evolution_read_test.py
b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
index 916058812c..60705d5cad 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
@@ -247,12 +247,11 @@ class SchemaEvolutionReadTest(unittest.TestCase):
# CastExecutors), so 1.2/2.8 read back as 1/2.
("double_to_int", pa.float64(), pa.int32(), 'INT',
[1.2, 2.8], [1, 2], [3, 4]),
- # Lossy DECIMAL scale-down: (10,4) -> (10,2) truncates the extra
- # scale rather than raising.
+ # Java's DECIMAL cast rounds half-up when reducing scale.
("decimal_scale_down",
pa.decimal128(10, 4), pa.decimal128(10, 2), 'DECIMAL(10, 2)',
- [decimal.Decimal('1.2345'), decimal.Decimal('4.5678')],
- [decimal.Decimal('1.23'), decimal.Decimal('4.56')],
+ [decimal.Decimal('1.2345'), decimal.Decimal('-4.5650')],
+ [decimal.Decimal('1.23'), decimal.Decimal('-4.57')],
[decimal.Decimal('7.89'), decimal.Decimal('0.12')]),
]
@@ -307,16 +306,20 @@ class SchemaEvolutionReadTest(unittest.TestCase):
# Reading ONLY old-schema files after a lossy type change (no
# newer-schema file in the splits). The output type must equal the
# current read schema regardless of which files the read spans, and the
- # conversion must truncate to match Java CastExecutors rather than
- # raise. (A previous fix that relied on pyarrow's safe cast crashed
- # here on lossy evolutions.)
+ # DECIMAL scale reduction rounds half-up, while DOUBLE -> INT still
+ # truncates. Neither conversion should raise on these old-file reads.
import decimal
cases = [
("scale_down",
pa.decimal128(10, 4), pa.decimal128(10, 2), 'DECIMAL(10, 2)',
- [decimal.Decimal('1.2345'), decimal.Decimal('4.5678')],
- [decimal.Decimal('1.23'), decimal.Decimal('4.56')]),
+ [decimal.Decimal('4.5678'), decimal.Decimal('-4.5650')],
+ [decimal.Decimal('4.57'), decimal.Decimal('-4.57')]),
+ ("scale_and_precision_down",
+ pa.decimal128(6, 3), pa.decimal128(3, 2), 'DECIMAL(3, 2)',
+ [decimal.Decimal('9.994'), decimal.Decimal('9.995'),
+ decimal.Decimal('-9.995'), decimal.Decimal('999.999')],
+ [decimal.Decimal('9.99'), None, None, None]),
("double_to_int", pa.float64(), pa.int32(), 'INT',
[1.2, 2.8], [1, 2]),
]
@@ -336,7 +339,8 @@ class SchemaEvolutionReadTest(unittest.TestCase):
table_write = write_builder.new_write()
table_commit = write_builder.new_commit()
table_write.write_arrow(pa.Table.from_pydict(
- {'k': [1, 2], 'v': write_vals}, schema=old_schema))
+ {'k': list(range(1, len(write_vals) + 1)), 'v':
write_vals},
+ schema=old_schema))
table_commit.commit(table_write.prepare_commit())
table_write.close()
table_commit.close()
@@ -353,7 +357,8 @@ class SchemaEvolutionReadTest(unittest.TestCase):
actual = read_builder.new_read().to_arrow(
self._scan_table(read_builder))
expected = pa.Table.from_pydict(
- {'k': [1, 2], 'v': read_vals}, schema=new_schema)
+ {'k': list(range(1, len(read_vals) + 1)), 'v': read_vals},
+ schema=new_schema)
self.assertEqual(expected, actual)
def test_schema_evolution_with_scan_filter(self):
diff --git a/paimon-python/pypaimon/tests/test_early_row_range_filter.py
b/paimon-python/pypaimon/tests/test_early_row_range_filter.py
index 2253518a9f..b27184ab55 100644
--- a/paimon-python/pypaimon/tests/test_early_row_range_filter.py
+++ b/paimon-python/pypaimon/tests/test_early_row_range_filter.py
@@ -22,6 +22,7 @@ import unittest
from unittest.mock import patch
import pyarrow as pa
+import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.predicate import Predicate
@@ -77,6 +78,7 @@ class TestManifestReadRowRangePerformance(unittest.TestCase):
def tearDownClass(cls):
shutil.rmtree(cls.tempdir, ignore_errors=True)
+ @pytest.mark.python_plan
def test_scan_constructs_all_entries_without_early_row_range_filter(self):
"""With manifest.merge-min-count=1, all entries are in one manifest.
Querying with _ROW_ID BETWEEN 5 AND 14 should return 2 files, but
diff --git a/paimon-python/pypaimon/tests/variant_test.py
b/paimon-python/pypaimon/tests/variant_test.py
index fd5a6c9fef..6e85718d17 100644
--- a/paimon-python/pypaimon/tests/variant_test.py
+++ b/paimon-python/pypaimon/tests/variant_test.py
@@ -62,6 +62,7 @@ from pypaimon.data.variant_shredding import (
_NULL_VALUE_BYTES,
_build_array_value,
_build_object_value,
+ _extract_overflow_fields,
_encode_scalar_to_value_bytes,
assemble_shredded_column,
build_variant_schema,
@@ -746,6 +747,17 @@ class TestBuildBinary(unittest.TestCase):
gv = GenericVariant(obj_bytes, meta)
self.assertEqual(gv.to_python(), {'age': 30})
+ def test_build_object_orders_keys_not_metadata_ids(self):
+ metadata = _make_metadata('z', 'a')
+ key_dict = parse_metadata_dict(metadata)
+ scalar = _encode_scalar_to_value_bytes(1, pa.int64())
+ value = _build_object_value(
+ [(key_dict['z'], scalar), (key_dict['a'], scalar)], key_dict)
+ self.assertEqual(
+ [key_id for key_id, _ in _extract_overflow_fields(value)],
+ [key_dict['a'], key_dict['z']],
+ )
+
def test_build_array_empty(self):
arr_bytes = _build_array_value([])
gv = GenericVariant(arr_bytes, b'\x01\x00')
@@ -1075,6 +1087,31 @@ class TestShreddingWrite(unittest.TestCase):
overflow_gv = GenericVariant(overflow, result['metadata'])
self.assertIn('name', overflow_gv.to_python())
+ def test_unicode_overflow_and_rebuild_use_utf8_key_order(self):
+ bmp_key = '\uff21'
+ supplementary_key = '\U0001f600'
+ obj_fields = self._obj_fields_for('col', [('keep', 'BIGINT')])
+ original = GenericVariant.from_python({
+ 'keep': 0, bmp_key: 1, supplementary_key: 2})
+ key_dict = parse_metadata_dict(original.metadata())
+
+ shredded = decompose_variant(original, obj_fields)
+ self.assertEqual(
+ [key_id for key_id, _ in
_extract_overflow_fields(shredded['value'])],
+ [key_dict[bmp_key], key_dict[supplementary_key]],
+ )
+
+ schema =
build_variant_schema(shredding_schema_to_arrow_type(obj_fields))
+ rebuilt_value, metadata = rebuild(shredded, schema, key_dict)
+ self.assertEqual(
+ [key_id for key_id, _ in _extract_overflow_fields(rebuilt_value)],
+ [key_dict['keep'], key_dict[bmp_key], key_dict[supplementary_key]],
+ )
+ self.assertEqual(
+ GenericVariant(rebuilt_value, metadata).to_python(),
+ original.to_python(),
+ )
+
def test_decompose_absent_field_is_null(self):
"""A shredded field absent from the variant yields {value: None,
typed_value: None}."""
obj_fields = self._obj_fields_for('col', [('missing_field', 'BIGINT')])