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 e28c6582c6 [python] Support efficient field selection from wide
VARIANT objects (#10320)
e28c6582c6 is described below
commit e28c6582c6c864b82c22b6bb4768c092aeafdebd
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Sep 30 09:02:18 2026 +0800
[python] Support efficient field selection from wide VARIANT objects
(#10320)
---
docs/docs/pypaimon/data-types.md | 17 ++
paimon-python/pypaimon/data/__init__.py | 3 +
paimon-python/pypaimon/data/variant_path.py | 256 +++++++++++++++-
paimon-python/pypaimon/tests/variant_path_test.py | 343 ++++++++++++++++++++++
4 files changed, 613 insertions(+), 6 deletions(-)
diff --git a/docs/docs/pypaimon/data-types.md b/docs/docs/pypaimon/data-types.md
index 535bdd6833..a5bcf1e30e 100644
--- a/docs/docs/pypaimon/data-types.md
+++ b/docs/docs/pypaimon/data-types.md
@@ -218,6 +218,23 @@ Supported Paimon type strings for shredded sub-fields:
`BOOLEAN`, `TINYINT`, `SM
## VARIANT Path Updates
+### Decode selected VARIANT fields to Python
+
+`variant_to_pylist` decodes only the requested top-level fields, returning
+one Python dict per row (or `None` for SQL NULL):
+
+```python
+from pypaimon.data import variant_to_pylist
+
+rows = variant_to_pylist(result.column('payload'), ['state.x', 'action.y'])
+```
+
+Field names are literal (`state.x` is not a nested path). Missing fields are
+omitted; VARIANT NULL is `None`. Values use natural Python types, even when
+types differ between rows. This does not reduce Parquet I/O.
+
+### Read and update typed paths
+
Read existing paths as Arrow arrays, use Arrow compute, and replace them
without decoding unrelated fields:
diff --git a/paimon-python/pypaimon/data/__init__.py
b/paimon-python/pypaimon/data/__init__.py
index 692a8ac925..dae7ba93dd 100644
--- a/paimon-python/pypaimon/data/__init__.py
+++ b/paimon-python/pypaimon/data/__init__.py
@@ -24,6 +24,7 @@ if sys.version_info[:2] < (3, 7):
from pypaimon.data.decimal import Decimal
from pypaimon.data.variant_path import (
variant_get,
+ variant_to_pylist,
variant_replace,
variant_set,
)
@@ -32,6 +33,7 @@ __all__ = [
'Timestamp',
'Decimal',
'variant_get',
+ 'variant_to_pylist',
'variant_replace',
'variant_set',
]
@@ -40,6 +42,7 @@ _MODULE_BY_EXPORT = {
'Timestamp': 'pypaimon.data.timestamp',
'Decimal': 'pypaimon.data.decimal',
'variant_get': 'pypaimon.data.variant_path',
+ 'variant_to_pylist': 'pypaimon.data.variant_path',
'variant_replace': 'pypaimon.data.variant_path',
'variant_set': 'pypaimon.data.variant_path',
}
diff --git a/paimon-python/pypaimon/data/variant_path.py
b/paimon-python/pypaimon/data/variant_path.py
index 79e251e9c0..fea3291d5a 100644
--- a/paimon-python/pypaimon/data/variant_path.py
+++ b/paimon-python/pypaimon/data/variant_path.py
@@ -76,6 +76,11 @@ _STRUCTURE_MATCH_INDEX_BUDGET = 8 * 1024 * 1024
_ROOT_INSERT_SPLICE_PAYLOAD_BUDGET = 8 * 1024 * 1024
# Bound per-row Python and NumPy temporaries for tiny payloads.
_ROOT_INSERT_SPLICE_MAX_BATCH_ROWS = 64 * 1024
+_FAST_CHILD_SIZES = np.asarray([
+ _PRIMITIVE_FIXED_SIZES.get(type_info, 0)
+ if type_info not in (_DECIMAL4, _DECIMAL8, _DECIMAL16) else 0
+ for type_info in range(64)
+], dtype=np.uint8)
@functools.lru_cache(maxsize=256)
@@ -1579,6 +1584,141 @@ def _rowwise_replace_chunk(
chunk, values, data, data_start, rebuilt_rows)
+def _flat_object_unsigneds(value, start, count, width):
+ """Read an object header array without creating Python ints per field."""
+ if width in (1, 2, 4):
+ return np.frombuffer(
+ value, dtype=np.dtype('<u%d' % width), count=count,
+ offset=start)
+ raw = np.frombuffer(
+ value, dtype=np.uint8, count=count * 3,
+ offset=start).reshape(count, 3)
+ return (raw[:, 0].astype(np.uint32)
+ | (raw[:, 1].astype(np.uint32) << 8)
+ | (raw[:, 2].astype(np.uint32) << 16))
+
+
+def _flat_object_layout(value):
+ """Validate a root object and read its offsets as a NumPy array."""
+ limit = len(value)
+ _require_range(0, 2, limit)
+ type_info = (value[0] >> 2) & 0x3F
+ size_width = _U32_SIZE if ((type_info >> 4) & 0x1) else 1
+ _require_range(1, size_width, limit)
+ size = _read_unsigned(value, 1, size_width)
+ id_width = ((type_info >> 2) & 0x3) + 1
+ offset_width = (type_info & 0x3) + 1
+ id_start = 1 + size_width
+ offset_start = id_start + size * id_width
+ data_start = offset_start + (size + 1) * offset_width
+ _require_range(0, data_start, limit)
+ offsets = _flat_object_unsigneds(
+ value, offset_start, size + 1, offset_width)
+ end_offset = int(offsets[-1])
+ if size:
+ ordered = np.sort(offsets)
+ if (int(ordered[0]) != 0 or int(ordered[-1]) != end_offset
+ or np.any(np.diff(ordered) == 0)):
+ _malformed("invalid object offsets")
+ else:
+ if end_offset != 0:
+ _malformed("invalid object offsets")
+ ordered = offsets
+ _require_range(data_start, end_offset, limit)
+ return size, id_width, id_start, offset_start, data_start, offsets, ordered
+
+
+def _flat_object_get_chunk(chunk, values, parsed):
+ """Look up wide, top-level object fields together instead of per path."""
+ if (len(parsed) < 2
+ or any(len(path) != 1 or path[0][0] != 'key'
+ for _, path, _ in parsed)):
+ return None
+
+ valid_rows = _valid_row_indices(chunk, values, chunk.field(1))
+ if not len(valid_rows):
+ return [pa.nulls(len(chunk), type=data_type)
+ for _, _, data_type in parsed]
+ first_value = values.view(int(valid_rows[0]))
+ if not first_value or (first_value[0] & 0x3) != _OBJECT:
+ return None
+ first_size = _checked_object_layout(
+ first_value, 0, len(first_value))[0]
+ # Keep the vectorized reader for narrow objects or a few requested keys.
+ if first_size * len(parsed) < 1024:
+ return None
+ # For multi-row float batches, the existing reader vectorizes across rows.
+ # Its per-path cost wins over per-row lookup on modest object widths.
+ if (len(valid_rows) >= _SLOW_PATH_ROWS
+ and all(pa.types.is_float32(data_type)
+ or pa.types.is_float64(data_type)
+ for _, _, data_type in parsed)
+ and (first_size < 256 or first_size * len(parsed) < 4096)):
+ return None
+
+ metadata = _BinaryValues(chunk.field(1))
+ decoded = [[None] * len(chunk) for _ in parsed]
+ # Object field IDs may differ across rows, so the key includes the actual
+ # encoded layout as well as the metadata dictionary and requested IDs.
+ slot_cache = LRUCache(maxsize=64)
+ for row in valid_rows:
+ row = int(row)
+ value = values.view(row)
+ if not value or (value[0] & 0x3) != _OBJECT:
+ return None
+ row_metadata = bytes(metadata.view(row))
+ key_ids = _cached_metadata_key_ids(row_metadata)
+ targets = {}
+ for index, (_, path, _) in enumerate(parsed):
+ key_id = key_ids.get(path[0][1])
+ if key_id is not None:
+ targets.setdefault(key_id, []).append(index)
+
+ (size, id_width, id_start, offset_start, data_start,
+ offsets, ordered_offsets) = _flat_object_layout(value)
+ if data_start + int(offsets[-1]) != len(value):
+ _malformed("trailing bytes after root value")
+ layout = (row_metadata, bytes(value[id_start:offset_start]),
+ id_width, tuple(sorted(targets)))
+ slots = slot_cache.get(layout)
+ if slots is None:
+ field_ids = _flat_object_unsigneds(
+ value, id_start, size, id_width)
+ if len(np.unique(field_ids)) != size:
+ _malformed("duplicate object field id")
+ slots = {int(key_id): slot
+ for slot, key_id in enumerate(field_ids)
+ if int(key_id) in targets}
+ slot_cache[layout] = slots
+ if not targets:
+ continue
+ if not slots:
+ continue
+ selected_offsets = offsets[np.fromiter(
+ slots.values(), dtype=np.int64, count=len(slots))]
+ next_indices = np.searchsorted(
+ ordered_offsets, selected_offsets, side='right')
+ for (key_id, _), offset, next_index in zip(
+ slots.items(), selected_offsets, next_indices):
+ child_pos = data_start + int(offset)
+ child_end = data_start + int(ordered_offsets[next_index])
+ if _checked_value_size(value, child_pos, child_end) != (
+ child_end - child_pos):
+ _malformed("child size does not match container offsets")
+ for index in targets[key_id]:
+ data_type = parsed[index][2]
+ if (pa.types.is_float32(data_type)
+ or pa.types.is_float64(data_type)):
+ decoded[index][row] = _decode_floating(
+ value, child_pos, data_type)
+ else:
+ decoded[index][row] = _decode_exact(
+ value, row_metadata, child_pos, data_type)
+
+ return [pa.array(result, type=data_type)
+ for result, (_, _, data_type) in zip(decoded, parsed)]
+
+
def _variant_get(column, paths: Mapping[str, pa.DataType]):
parsed = []
for path, target_type in paths.items():
@@ -1594,12 +1734,14 @@ def _variant_get(column, paths: Mapping[str,
pa.DataType]):
result_chunks = {path: [] for path in paths}
for chunk in chunks:
values = _BinaryValues(chunk.field(0))
- results = _vectorized_get_chunk(
- chunk,
- values,
- parsed_paths,
- [target_type for _, _, target_type in parsed],
- )
+ results = _flat_object_get_chunk(chunk, values, parsed)
+ if results is None:
+ results = _vectorized_get_chunk(
+ chunk,
+ values,
+ parsed_paths,
+ [target_type for _, _, target_type in parsed],
+ )
if results is not None:
for (path, _, _), result in zip(parsed, results):
result_chunks[path].append(result)
@@ -1646,6 +1788,108 @@ def variant_get(column, path, data_type=None):
return _variant_get(column, {path: data_type})[path]
+@_with_metadata_cache
+def variant_to_pylist(column, fields: Sequence[str]):
+ """Decode selected top-level VARIANT object fields to Python values.
+
+ Values use their natural Python types, even when a field has different
+ types in different rows. A missing field is omitted from its row's dict;
+ a VARIANT null is present with value None, and a SQL null row returns None.
+ Field names are literal names, not VARIANT path expressions.
+ """
+ if isinstance(fields, (str, bytes)):
+ raise TypeError("VARIANT fields must be a sequence of field names")
+ try:
+ names = tuple(dict.fromkeys(fields))
+ except (TypeError, ValueError):
+ raise TypeError(
+ "VARIANT fields must be a sequence of field names") from None
+ if any(not isinstance(name, str) for name in names):
+ raise TypeError("VARIANT field names must be strings")
+
+ chunks, _, _ = _variant_chunks(column)
+ result = []
+ slot_cache = LRUCache(maxsize=64)
+ for chunk in chunks:
+ values = _BinaryValues(chunk.field(0))
+ metadata = _BinaryValues(chunk.field(1))
+ valid = set(map(int, _valid_row_indices(
+ chunk, values, chunk.field(1))))
+ for row in range(len(chunk)):
+ if row not in valid:
+ result.append(None)
+ continue
+ value = bytes(values.view(row))
+ row_metadata = bytes(metadata.view(row))
+ if not value or (value[0] & 0x3) != _OBJECT:
+ raise TypeError("VARIANT root must be an object")
+ variant = GenericVariant(value, row_metadata)
+ key_ids = _cached_metadata_key_ids(row_metadata)
+ targets = {key_ids[name]: name for name in names
+ if name in key_ids}
+ (size, id_width, id_start, offset_start, data_start,
+ offsets, ordered_offsets) = _flat_object_layout(value)
+ if data_start + int(offsets[-1]) != len(value):
+ _malformed("trailing bytes after root value")
+ layout = (row_metadata, value[id_start:offset_start],
+ id_width, tuple(sorted(targets)))
+ selected_slots = slot_cache.get(layout)
+ if selected_slots is None:
+ field_ids = _flat_object_unsigneds(
+ value, id_start, size, id_width)
+ if len(np.unique(field_ids)) != size:
+ _malformed("duplicate object field id")
+ selected_slots = tuple(
+ (slot, targets[int(key_id)])
+ for slot, key_id in enumerate(field_ids)
+ if int(key_id) in targets)
+ slot_cache[layout] = selected_slots
+ if not targets:
+ result.append({})
+ continue
+ slots = np.fromiter(
+ (slot for slot, _ in selected_slots),
+ dtype=np.int64, count=len(selected_slots))
+ starts = offsets[slots]
+ if np.any(starts >= offsets[-1]):
+ _malformed("invalid object offset")
+ next_indices = np.searchsorted(
+ ordered_offsets, starts, side='right')
+ ends = ordered_offsets[next_indices]
+ # Encoded offsets may be uint8/uint16. Adding data_start before
+ # widening can wrap or raise for otherwise valid objects.
+ absolute_starts = starts.astype(np.intp, copy=False) + data_start
+ headers = np.frombuffer(value, dtype=np.uint8)[absolute_starts]
+ basic_types = headers & 0x3
+ type_infos = headers >> 2
+ fixed_sizes = _FAST_CHILD_SIZES[type_infos]
+ is_short_string = basic_types == _SHORT_STR
+ is_fast = (is_short_string
+ | ((basic_types == _PRIMITIVE) & (fixed_sizes != 0)))
+ expected_sizes = np.where(
+ is_short_string, type_infos + 1, fixed_sizes)
+ if np.any(is_fast & (ends - starts != expected_sizes)):
+ _malformed("child size does not match container offsets")
+ selected = {}
+ for index, ((_, name), offset, end) in enumerate(zip(
+ selected_slots, starts, ends)):
+ child_pos = data_start + int(offset)
+ if not is_fast[index]:
+ child_end = data_start + int(end)
+ if basic_types[index] in (_OBJECT, _ARRAY):
+ _validate_value_field_ids(
+ value, child_pos, child_end, len(key_ids))
+ elif _checked_value_size(
+ value, child_pos, child_end) != (
+ child_end - child_pos):
+ _malformed(
+ "child size does not match container offsets")
+ selected[name] = variant._to_python_impl(
+ value, row_metadata, child_pos)
+ result.append(selected)
+ return result
+
+
def _paths_overlap(first: _Path, second: _Path) -> bool:
limit = min(len(first), len(second))
return first[:limit] == second[:limit]
diff --git a/paimon-python/pypaimon/tests/variant_path_test.py
b/paimon-python/pypaimon/tests/variant_path_test.py
index b3a569ea68..e1a070e262 100644
--- a/paimon-python/pypaimon/tests/variant_path_test.py
+++ b/paimon-python/pypaimon/tests/variant_path_test.py
@@ -26,12 +26,15 @@ import pyarrow.compute as pc
from pypaimon.data._variant_binary import _primitive_header
from pypaimon.data.generic_variant import _DOUBLE, GenericVariant
from pypaimon.data.variant_path import (
+ _checked_object_layout,
_compile_paths,
_metadata_cache,
_metadata_key_ids,
_path_positions,
_rebuilt_offsets,
+ _vectorized_get_chunk,
variant_get,
+ variant_to_pylist,
variant_replace,
)
from pypaimon.data.variant_shredding import (
@@ -77,8 +80,161 @@ def _typed_object(fields):
GenericVariant(value, metadata)])
+class TestVariantToPylist(unittest.TestCase):
+
+ def test_preserves_mixed_types_missing_null_and_literal_names(self):
+ column = _variants([
+ {'state.x': 1, 'child': {'count': 2}, 'nullable': None},
+ {'state.x': 1.5, 'child': [3, 4]},
+ {},
+ None,
+ ])
+
+ result = variant_to_pylist(
+ column, ['state.x', 'child', 'nullable', 'absent'])
+
+ self.assertEqual(result, [
+ {'state.x': 1, 'child': {'count': 2}, 'nullable': None},
+ {'state.x': 1.5, 'child': [3, 4]},
+ {},
+ None,
+ ])
+
+ def test_selects_from_wide_objects_and_chunked_arrays(self):
+ first = {'field.%03d' % i: float(i) for i in range(128)}
+ second = {'field.%03d' % i: i for i in range(128)}
+ column = pa.chunked_array([
+ _variants([first]), _variants([second])])
+
+ result = variant_to_pylist(
+ column, ['field.000', 'field.127', 'field.000'])
+
+ self.assertEqual(result, [
+ {'field.000': 0.0, 'field.127': 127.0},
+ {'field.000': 0, 'field.127': 127},
+ ])
+
+ def test_selects_from_same_metadata_with_different_slots(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ metadata = GenericVariant.from_python(fields).metadata()
+ key_ids = _metadata_key_ids(metadata)
+ rows = []
+ for names in (list(fields), list(fields)[10:], list(fields)):
+ value = _build_object_value([
+ (key_ids[name], _encode_scalar_to_value_bytes(
+ fields[name], pa.float64()))
+ for name in names
+ ])
+ rows.append(GenericVariant(value, metadata))
+
+ result = variant_to_pylist(
+ GenericVariant.to_arrow_array(rows),
+ ['field.000', 'field.015'])
+
+ self.assertEqual(result, [
+ {'field.000': 0.0, 'field.015': 15.0},
+ {'field.015': 15.0},
+ {'field.000': 0.0, 'field.015': 15.0},
+ ])
+
+ def test_preserves_other_primitive_types(self):
+ timestamp = datetime.datetime(2026, 8, 11, 1, 2, 3, 456000)
+ fields = {
+ 'flag': (True, pa.bool_()),
+ 'text': ('hello', pa.string()),
+ 'binary': (b'abc', pa.binary()),
+ 'decimal': (Decimal('12.30'), pa.decimal128(4, 2)),
+ 'date': (datetime.date(2026, 8, 11), pa.date32()),
+ 'timestamp': (timestamp, pa.timestamp('us')),
+ }
+
+ result = variant_to_pylist(_typed_object(fields), fields)
+
+ self.assertEqual(result, [
+ {name: value for name, (value, _) in fields.items()}])
+
+ def test_rejects_non_object_root_and_invalid_fields(self):
+ with self.assertRaisesRegex(TypeError, "root must be an object"):
+ variant_to_pylist(_variants([[1, 2]]), ['field'])
+ with self.assertRaisesRegex(TypeError, "sequence of field names"):
+ variant_to_pylist(_variants([{}]), 'field')
+ with self.assertRaisesRegex(TypeError, "field names must be strings"):
+ variant_to_pylist(_variants([{}]), [1])
+
+ def test_rejects_truncated_object(self):
+ variant = GenericVariant.from_python({'field': 123})
+ truncated = GenericVariant(
+ variant.value()[:-1], variant.metadata())
+ with self.assertRaisesRegex(ValueError, "MALFORMED_VARIANT"):
+ variant_to_pylist(
+ GenericVariant.to_arrow_array([truncated]), ['field'])
+
+ def test_rejects_truncated_selected_child_before_next_field(self):
+ valid = GenericVariant.from_python({'a': 1.0, 'b': 2.0})
+ key_ids = _metadata_key_ids(valid.metadata())
+ value = _build_object_value([
+ (key_ids['a'], bytes([_primitive_header(_DOUBLE)])),
+ (key_ids['b'], _encode_scalar_to_value_bytes(2.0, pa.float64())),
+ ])
+ column = GenericVariant.to_arrow_array([
+ GenericVariant(value, valid.metadata())])
+
+ with self.assertRaisesRegex(ValueError, "MALFORMED_VARIANT"):
+ variant_to_pylist(column, ['a'])
+ self.assertEqual(variant_to_pylist(column, ['b']), [{'b': 2.0}])
+
+ def test_rejects_truncated_nested_selected_child(self):
+ valid = GenericVariant.from_python(
+ {'a': {'nested': 1.0}, 'b': 2.0})
+ key_ids = _metadata_key_ids(valid.metadata())
+ nested = _build_object_value([
+ (key_ids['nested'], bytes([_primitive_header(_DOUBLE)])),
+ ])
+ value = _build_object_value([
+ (key_ids['a'], nested),
+ (key_ids['b'], _encode_scalar_to_value_bytes(2.0, pa.float64())),
+ ])
+ column = GenericVariant.to_arrow_array([
+ GenericVariant(value, valid.metadata())])
+
+ with self.assertRaisesRegex(ValueError, "MALFORMED_VARIANT"):
+ variant_to_pylist(column, ['a'])
+
+ def test_selected_offsets_cross_encoded_integer_width(self):
+ for count, value in ((100, None), (128, None), (5362, 1.5)):
+ with self.subTest(count=count):
+ name = 'field%04d' % (count - 1)
+ variant = GenericVariant.from_python({
+ 'field%04d' % index: value for index in range(count)
+ })
+ column = GenericVariant.to_arrow_array([variant])
+ self.assertEqual(
+ variant_to_pylist(column, [name]), [{name: value}])
+
+
class TestVariantGet(unittest.TestCase):
+ def test_medium_float_batch_keeps_vectorized_reader(self):
+ column = _variants([
+ {'field%03d' % i: float(i + row) for i in range(128)}
+ for row in range(128)
+ ])
+ paths = {'$.field%03d' % i: pa.float64() for i in range(16)}
+ vectorized_results = []
+
+ def track_vectorized(*args):
+ result = _vectorized_get_chunk(*args)
+ vectorized_results.append(result is not None)
+ return result
+
+ with patch('pypaimon.data.variant_path._vectorized_get_chunk',
+ side_effect=track_vectorized):
+ result = variant_get(column, paths)
+
+ self.assertEqual(vectorized_results, [True])
+ self.assertEqual(result['$.field000'].to_pylist(),
+ [float(row) for row in range(128)])
+
def test_compile_paths_builds_trie_without_prefix_slices(self):
class NoSlicePath(tuple):
def __getitem__(self, item):
@@ -155,6 +311,193 @@ class TestVariantGet(unittest.TestCase):
self.assertEqual(result['$.velocity.x'].to_pylist(), [1.0, 3.0])
self.assertEqual(result['$.velocity.y'].to_pylist(), [-2.0, -4.0])
+ def test_reads_many_flat_fields_without_repeating_vectorized_scan(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(16)}
+ column = _variants([fields, fields])
+
+ with patch(
+ 'pypaimon.data.variant_path._vectorized_get_chunk',
+ side_effect=AssertionError(
+ "wide flat lookup should be shared")):
+ result = variant_get(column, paths)
+
+ for i, path in enumerate(paths):
+ self.assertEqual(result[path].to_pylist(), [float(i), float(i)])
+
+ def test_reads_many_flat_fields_with_exact_primitive_types(self):
+ timestamp = datetime.datetime(2026, 8, 11, 1, 2, 3, 456000)
+ fields = {
+ 'field.%03d' % i: (float(i), pa.float64())
+ for i in range(128)
+ }
+ selected = {
+ 'flag': (True, pa.bool_()),
+ 'count': (123, pa.int64()),
+ 'text': ('hello', pa.string()),
+ 'binary': (b'abc', pa.binary()),
+ 'decimal': (Decimal('12.30'), pa.decimal128(4, 2)),
+ 'date': (datetime.date(2026, 8, 11), pa.date32()),
+ 'timestamp': (timestamp, pa.timestamp('us')),
+ }
+ fields.update(selected)
+ paths = {'$.%s' % name: data_type
+ for name, (_, data_type) in selected.items()}
+ paths['$["field.000"]'] = pa.float64()
+
+ with patch(
+ 'pypaimon.data.variant_path._vectorized_get_chunk',
+ side_effect=AssertionError(
+ "wide flat lookup should be shared")):
+ result = variant_get(_typed_object(fields), paths)
+
+ for name, (expected, _) in selected.items():
+ self.assertEqual(result['$.%s' % name].to_pylist(), [expected])
+ self.assertEqual(result['$["field.000"]'].to_pylist(), [0.0])
+
+ def test_reads_many_flat_fields_with_complex_types(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ fields.update({
+ 'object': {'count': 2, 'flag': True},
+ 'array': [1, 2],
+ 'map': {'left': 1, 'right': 2},
+ })
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(5)}
+ paths.update({
+ '$.object': pa.struct([
+ ('count', pa.int64()), ('flag', pa.bool_())]),
+ '$.array': pa.list_(pa.int64()),
+ '$.map': pa.map_(pa.string(), pa.int64()),
+ })
+
+ with patch(
+ 'pypaimon.data.variant_path._vectorized_get_chunk',
+ side_effect=AssertionError(
+ "wide flat lookup should be shared")):
+ result = variant_get(_variants([fields]), paths)
+
+ self.assertEqual(result['$.object'].to_pylist(),
+ [{'count': 2, 'flag': True}])
+ self.assertEqual(result['$.array'].to_pylist(), [[1, 2]])
+ self.assertEqual(result['$.map'].to_pylist(),
+ [[('left', 1), ('right', 2)]])
+
+ def test_many_flat_fields_keep_missing_null_and_mixed_metadata(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(15)}
+ paths['$["maybe.null"]'] = pa.float64()
+ column = _variants([
+ {**fields, 'maybe.null': None},
+ {**fields, 'field.000': None},
+ fields,
+ None,
+ ])
+
+ result = variant_get(column, paths)
+
+ self.assertEqual(
+ result['$["field.000"]'].to_pylist(),
+ [0.0, None, 0.0, None])
+ self.assertEqual(
+ result['$["maybe.null"]'].to_pylist(),
+ [None, None, None, None])
+ self.assertEqual(
+ result['$["field.014"]'].to_pylist(),
+ [14.0, 14.0, 14.0, None])
+
+ def test_many_flat_fields_allow_nonmonotonic_value_offsets(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ variant = GenericVariant.from_python(fields)
+ value = variant.value()
+ size, id_width, id_start, data_start, offsets, _ = (
+ _checked_object_layout(value, 0, len(value)))
+ offset_width = ((value[0] >> 2) & 0x3) + 1
+ offset_start = id_start + size * id_width
+ children = [value[data_start + offsets[i]:data_start + offsets[i + 1]]
+ for i in range(size)]
+ reordered = bytearray(value[:data_start])
+ for i in range(size):
+ new_offset = sum(len(child) for child in children[i + 1:])
+ start = offset_start + i * offset_width
+ reordered[start:start + offset_width] = new_offset.to_bytes(
+ offset_width, 'little')
+ reordered.extend(b''.join(reversed(children)))
+ column = GenericVariant.to_arrow_array([
+ GenericVariant(bytes(reordered), variant.metadata())])
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(16)}
+
+ result = variant_get(column, paths)
+
+ for i, path in enumerate(paths):
+ self.assertEqual(result[path].to_pylist(), [float(i)])
+
+ def test_many_flat_fields_with_three_byte_offsets(self):
+ fields = {'field.%03d' % i: 'x' * 1024 for i in range(128)}
+ variant = GenericVariant.from_python(fields)
+ offset_width = ((variant.value()[0] >> 2) & 0x3) + 1
+ self.assertEqual(offset_width, 3)
+ paths = {'$["field.%03d"]' % i: pa.string()
+ for i in range(16)}
+
+ result = variant_get(_variants([fields, fields]), paths)
+
+ for path in paths:
+ self.assertEqual(result[path].to_pylist(), ['x' * 1024] * 2)
+
+ def test_many_flat_fields_same_metadata_different_layouts(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ metadata = GenericVariant.from_python(fields).metadata()
+ key_ids = _metadata_key_ids(metadata)
+ rows = []
+ for names in (list(fields), list(fields)[10:], list(fields)):
+ value = _build_object_value([
+ (key_ids[name], _encode_scalar_to_value_bytes(
+ fields[name], pa.float64()))
+ for name in names
+ ])
+ rows.append(GenericVariant(value, metadata))
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(16)}
+
+ result = variant_get(GenericVariant.to_arrow_array(rows), paths)
+
+ self.assertEqual(result['$["field.000"]'].to_pylist(),
+ [0.0, None, 0.0])
+ self.assertEqual(result['$["field.015"]'].to_pylist(),
+ [15.0, 15.0, 15.0])
+
+ def test_many_flat_fields_reject_duplicate_id_and_offset(self):
+ fields = {'field.%03d' % i: float(i) for i in range(128)}
+ variant = GenericVariant.from_python(fields)
+ original = variant.value()
+ size, id_width, id_start, _, _, _ = (
+ _checked_object_layout(original, 0, len(original)))
+ offset_width = ((original[0] >> 2) & 0x3) + 1
+ offset_start = id_start + size * id_width
+ paths = {'$["field.%03d"]' % i: pa.float64()
+ for i in range(16)}
+
+ duplicate_id = bytearray(original)
+ duplicate_id[id_start + id_width:id_start + 2 * id_width] = (
+ duplicate_id[id_start:id_start + id_width])
+ with self.assertRaisesRegex(ValueError, 'duplicate object field id'):
+ variant_get(GenericVariant.to_arrow_array([
+ GenericVariant(bytes(duplicate_id), variant.metadata())
+ ]), paths)
+
+ duplicate_offset = bytearray(original)
+ duplicate_offset[
+ offset_start + offset_width:offset_start + 2 * offset_width
+ ] = duplicate_offset[offset_start:offset_start + offset_width]
+ with self.assertRaisesRegex(ValueError, 'invalid object offsets'):
+ variant_get(GenericVariant.to_arrow_array([
+ GenericVariant(bytes(duplicate_offset), variant.metadata())
+ ]), paths)
+
def test_requires_exact_type(self):
cases = (
(_float_variants([1.25]), pa.float64()),