blob: 1cc031473528f362080bef043e74a37c176d598d [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Python execution for physical shredded columns."""
from typing import Dict, List, Optional, Tuple
import pyarrow as pa
from pypaimon.data.generic_variant import GenericVariant, _Type, _variant_get_type
from pypaimon.data._variant_binary import _OBJECT, _U32_SIZE, _read_unsigned, _primitive_header
from pypaimon.data.variant_shredding import (
VARIANT_ARROW_TYPE,
ObjectField,
VariantSchema,
_build_array_value,
_build_object_value,
_encode_scalar_to_value_bytes,
parse_metadata_dict,
)
_NULL_TYPE_ID = 0
_TRUE_TYPE_ID = 1
_FALSE_TYPE_ID = 2
_NULL_VALUE_BYTES: bytes = bytes([_primitive_header(_NULL_TYPE_ID)])
def _extract_overflow_fields(overflow_bytes: bytes) -> List[Tuple[int, bytes]]:
"""Parse an overflow binary (a variant object) into ``(key_id, value_bytes)`` pairs.
The overflow binary contains fields that were NOT shredded — they remain
encoded as a compact variant object.
"""
if not overflow_bytes:
return []
b = overflow_bytes[0]
basic_type = b & 0x3
if basic_type != _OBJECT:
return []
type_info = (b >> 2) & 0x3F
large_size = bool((type_info >> 4) & 0x1)
size_bytes = _U32_SIZE if large_size else 1
size = _read_unsigned(overflow_bytes, 1, size_bytes)
if size == 0:
return []
id_size = ((type_info >> 2) & 0x3) + 1
offset_size = (type_info & 0x3) + 1
id_start = 1 + size_bytes
offset_start = id_start + size * id_size
data_start = offset_start + (size + 1) * offset_size
# The Parquet variant spec stores field offsets in the same order as the id_table,
# but the DATA section may be laid out in a different order (e.g. GenericVariantBuilder
# sorts the id/offset tables alphabetically while writing data in insertion order).
# We must sort by offset to determine each field's byte boundaries correctly.
#
# Example: variant {"b": 2, "a": 1} written by GenericVariantBuilder
# id_table: [id_a=0, id_b=1] (alphabetical order)
# offset_table: [off_a=2, off_b=0] (id-order: a first, then b)
# data section: [enc(2), enc(1)] (insertion order: b first, then a)
# So pairs = [(id_a=0, off=2), (id_b=1, off=0)]
# Sorted by offset: [(id_b=1, off=0), (id_a=0, off=2)]
# Boundaries: [0, 2, sentinel] → enc(2)=data[0:2], enc(1)=data[2:sentinel]
pairs: List[Tuple[int, int]] = []
for i in range(size):
key_id = _read_unsigned(overflow_bytes, id_start + i * id_size, id_size)
off = _read_unsigned(overflow_bytes, offset_start + i * offset_size, offset_size)
pairs.append((key_id, off))
sentinel = _read_unsigned(overflow_bytes, offset_start + size * offset_size, offset_size)
# Sort by offset so that adjacent entries define contiguous data boundaries.
# Track by original index (always unique) to avoid dict key collisions when
# two fields share the same offset (malformed data).
indexed_pairs = sorted(enumerate(pairs), key=lambda x: x[1][1])
boundaries = [ip[1][1] for ip in indexed_pairs] + [sentinel]
# end_by_orig[i] = end offset for pairs[i]
end_by_orig = [0] * size
for rank, (orig_idx, _) in enumerate(indexed_pairs):
end_by_orig[orig_idx] = boundaries[rank + 1]
fields: List[Tuple[int, bytes]] = []
for orig_idx, (key_id, off) in enumerate(pairs):
end = end_by_orig[orig_idx]
field_bytes = bytes(overflow_bytes[data_start + off:data_start + end])
fields.append((key_id, field_bytes))
return fields
def rebuild_value(
row: dict,
schema: VariantSchema,
key_dict: Dict[str, int],
) -> Optional[bytes]:
"""Reconstruct variant value bytes from a shredded sub-row dict.
Args:
row: Python dict from ``PyArrow StructScalar.as_py()``. Keys are
``'value'`` and/or ``'typed_value'``.
schema: VariantSchema for this level.
key_dict: ``{key_name: key_id}`` parsed from the top-level metadata.
Returns:
Variant value bytes, or ``None`` if the field is absent (both
``typed_value`` and ``value`` are null — "missing from this row").
"""
typed_value = row.get('typed_value') if schema.typed_idx >= 0 else None
overflow = row.get('value') if schema.value_idx >= 0 else None
# if both null → field is absent in this row
if typed_value is None and overflow is None:
return None
# if typed_value is null → use overflow bytes directly
if typed_value is None:
return bytes(overflow)
if schema.scalar_arrow_type is not None:
return _encode_scalar_to_value_bytes(typed_value, schema.scalar_arrow_type)
if schema.object_fields is not None:
return _rebuild_object(typed_value, schema, key_dict, overflow)
if schema.array_schema is not None:
return _rebuild_array(typed_value, schema.array_schema, key_dict)
# No sub-schema for typed_value; fall back to overflow
return bytes(overflow) if overflow is not None else None
def _rebuild_object(
typed_value: dict,
schema: VariantSchema,
key_dict: Dict[str, int],
overflow_bytes: Optional[bytes],
) -> bytes:
"""Rebuild an object variant from shredded object sub-fields."""
fields: List[Tuple[int, bytes]] = []
for obj_field in schema.object_fields:
fname = obj_field.field_name
sub_row = typed_value.get(fname) if isinstance(typed_value, dict) else None
if sub_row is None:
continue
field_value = rebuild_value(sub_row, obj_field.schema, key_dict)
if field_value is None:
continue
key_id = key_dict.get(fname)
if key_id is None:
# Key not found in metadata — data integrity issue, skip
continue
fields.append((key_id, field_value))
# Merge with overflow (fields not shredded)
if overflow_bytes:
fields.extend(_extract_overflow_fields(bytes(overflow_bytes)))
return _build_object_value(fields, key_dict)
def _rebuild_array(
typed_value,
element_schema: VariantSchema,
key_dict: Dict[str, int],
) -> bytes:
"""Rebuild an array variant from a shredded list."""
if typed_value is None:
typed_value = []
element_bytes_list: List[bytes] = []
for element_row in typed_value:
if element_row is None:
element_bytes_list.append(_NULL_VALUE_BYTES)
elif isinstance(element_row, dict):
eb = rebuild_value(element_row, element_schema, key_dict)
element_bytes_list.append(eb if eb is not None else _NULL_VALUE_BYTES)
else:
# Scalar element directly (no surrounding {value, typed_value} struct)
if element_schema.scalar_arrow_type is not None:
eb = _encode_scalar_to_value_bytes(element_row, element_schema.scalar_arrow_type)
else:
eb = _NULL_VALUE_BYTES
element_bytes_list.append(eb)
return _build_array_value(element_bytes_list)
def rebuild(
row: dict,
schema: VariantSchema,
key_dict: Optional[Dict[str, int]] = None,
) -> Tuple[bytes, bytes]:
"""Reconstruct ``(value_bytes, metadata_bytes)`` from a top-level shredded row.
Args:
row: Top-level shredded row dict (contains ``'metadata'``,
``'value'``, and/or ``'typed_value'`` keys).
schema: Top-level VariantSchema (from ``build_variant_schema()``).
key_dict: Optional pre-parsed ``{key_name: key_id}`` mapping. If None,
it is parsed from ``row['metadata']`` automatically.
Returns:
``(value_bytes, metadata_bytes)`` suitable for constructing a standard
VARIANT ``struct<value: binary, metadata: binary>`` row.
"""
raw_metadata = row.get('metadata')
if raw_metadata is None:
raise ValueError("Shredded VARIANT row missing 'metadata' field")
metadata = bytes(raw_metadata)
if key_dict is None:
key_dict = parse_metadata_dict(metadata)
value_bytes = rebuild_value(row, schema, key_dict)
if value_bytes is None:
value_bytes = _NULL_VALUE_BYTES
return value_bytes, metadata
def assemble_shredded_column(column: pa.Array, schema: VariantSchema) -> pa.Array:
"""Convert a shredded VARIANT column to standard ``struct<value, metadata>``.
Args:
column: A PyArrow Array whose type is a shredded VARIANT struct.
schema: VariantSchema built from the column's type.
Returns:
A ``pa.StructArray`` with type ``struct<value: binary, metadata: binary>``.
"""
rows = column.to_pylist()
assembled = []
# cache parsed key dicts keyed by metadata bytes; most files share one metadata
key_dict_cache: Dict[bytes, Dict[str, int]] = {}
for row in rows:
if row is None:
assembled.append(None)
continue
raw_meta = row.get('metadata')
if raw_meta is None:
assembled.append(None)
continue
metadata = bytes(raw_meta)
if metadata not in key_dict_cache:
key_dict_cache[metadata] = parse_metadata_dict(metadata)
value_bytes = rebuild_value(row, schema, key_dict_cache[metadata])
if value_bytes is None:
value_bytes = _NULL_VALUE_BYTES
assembled.append({'value': value_bytes, 'metadata': metadata})
return pa.array(assembled, type=VARIANT_ARROW_TYPE)
def _try_shred_scalar(field_bytes: bytes, metadata: bytes, arrow_type: pa.DataType):
kind = _variant_get_type(field_bytes, 0)
if kind == _Type.DOUBLE:
compatible = pa.types.is_float64(arrow_type)
elif kind == _Type.FLOAT:
compatible = pa.types.is_float32(arrow_type)
elif kind == _Type.LONG:
compatible = pa.types.is_integer(arrow_type)
elif kind == _Type.BOOLEAN:
compatible = pa.types.is_boolean(arrow_type)
elif kind == _Type.STRING:
compatible = pa.types.is_string(arrow_type) or pa.types.is_large_string(arrow_type)
elif kind == _Type.BINARY:
compatible = pa.types.is_binary(arrow_type) or pa.types.is_large_binary(arrow_type)
elif kind == _Type.DATE:
compatible = pa.types.is_date32(arrow_type)
elif kind == _Type.DECIMAL:
compatible = pa.types.is_decimal(arrow_type)
elif kind in (_Type.TIMESTAMP, _Type.TIMESTAMP_NTZ):
compatible = (pa.types.is_timestamp(arrow_type)
and bool(arrow_type.tz) == (kind == _Type.TIMESTAMP))
else:
# JSON null stays in residual; two null columns mean absent.
return None
if not compatible:
return None
value = GenericVariant(field_bytes, metadata).to_python()
if kind == _Type.LONG:
bits = arrow_type.bit_width
lower = 0 if pa.types.is_unsigned_integer(arrow_type) else -(1 << (bits - 1))
if not lower <= value < lower + (1 << bits):
return None
elif kind in (_Type.DECIMAL, _Type.TIMESTAMP, _Type.TIMESTAMP_NTZ):
# Keep Arrow validation for scale, timestamp precision and overflow.
value = pa.scalar(value, type=arrow_type).as_py()
if _encode_scalar_to_value_bytes(value, arrow_type) != field_bytes:
return None
return value
def _decompose_field_bytes(
field_bytes: bytes,
schema: VariantSchema,
metadata: bytes,
) -> dict:
"""Decompose a variant field's value bytes into a ``{value, typed_value}`` sub-struct dict.
This is the write-direction counterpart of ``rebuild_value()``.
Args:
field_bytes: Variant value bytes for a single field extracted from the
original variant binary.
schema: VariantSchema describing how this field should be shredded.
metadata: Top-level metadata bytes (used for key ID lookups in nested objects).
Returns:
A Python dict ``{'value': bytes_or_none, 'typed_value': val_or_dict_or_none}``
suitable for building a PyArrow struct array row.
"""
if schema.scalar_arrow_type is not None:
try:
typed_val = _try_shred_scalar(field_bytes, metadata, schema.scalar_arrow_type)
except Exception:
typed_val = None
return {'value': field_bytes if typed_val is None else None, 'typed_value': typed_val}
if schema.object_fields is not None:
if not field_bytes or (field_bytes[0] & 0x3) != _OBJECT:
return {'value': field_bytes, 'typed_value': None}
all_sub = _extract_overflow_fields(field_bytes)
key_dict = parse_metadata_dict(metadata)
id_to_name = {v: k for k, v in key_dict.items()}
shredded_names = {of.field_name for of in schema.object_fields}
overflow_pairs = [
(kid, fb) for kid, fb in all_sub
if id_to_name.get(kid) not in shredded_names
]
shredded_by_name = {
id_to_name[kid]: fb for kid, fb in all_sub
if kid in id_to_name and id_to_name[kid] in shredded_names
}
typed_value: Dict[str, dict] = {}
for of in schema.object_fields:
fname = of.field_name
if fname in shredded_by_name:
typed_value[fname] = _decompose_field_bytes(
shredded_by_name[fname], of.schema, metadata
)
else:
typed_value[fname] = {'value': None, 'typed_value': 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
return {'value': field_bytes, 'typed_value': None}
def decompose_variant(
gv: 'GenericVariant',
obj_fields: List[ObjectField],
) -> dict:
"""Decompose a ``GenericVariant`` into a shredded row dict for writing.
This is the inverse of ``rebuild()`` / ``rebuild_value()`` — it takes a
fully-encoded variant and splits it into the shredded ``{metadata, value,
typed_value}`` structure that Parquet shredding expects.
Args:
gv: The ``GenericVariant`` to decompose.
obj_fields: List of ``ObjectField`` from ``parse_shredding_schema_option()``,
describing which top-level object keys to shred.
Returns:
A Python dict matching the Arrow type produced by
``shredding_schema_to_arrow_type(obj_fields)``.
"""
metadata = gv.metadata()
value_bytes = gv.value()
# Non-object variants cannot be shredded: put everything in overflow and set
# typed_value to NULL so the Java reader falls through to the overflow path.
if not value_bytes or (value_bytes[0] & 0x3) != _OBJECT:
return {'metadata': metadata, 'value': value_bytes, 'typed_value': None}
all_fields = _extract_overflow_fields(value_bytes)
key_dict = parse_metadata_dict(metadata)
id_to_name = {v: k for k, v in key_dict.items()}
shredded_names = {of.field_name for of in obj_fields}
overflow_pairs = [
(kid, fb) for kid, fb in all_fields
if id_to_name.get(kid) not in shredded_names
]
shredded_by_name = {
id_to_name[kid]: fb for kid, fb in all_fields
if kid in id_to_name and id_to_name[kid] in shredded_names
}
typed_value = {}
for of in obj_fields:
fname = of.field_name
if fname in shredded_by_name:
typed_value[fname] = _decompose_field_bytes(
shredded_by_name[fname], of.schema, metadata
)
else:
typed_value[fname] = {'value': None, 'typed_value': 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}
def shred_variant_column(
column: pa.Array,
obj_fields: List[ObjectField],
target_type: pa.StructType,
) -> pa.Array:
"""Convert a standard VARIANT column to its shredded representation.
Args:
column: A ``pa.Array`` of type ``struct<value: binary, metadata: binary>``
(the standard Paimon VARIANT layout).
obj_fields: ``[ObjectField, ...]`` from ``parse_shredding_schema_option()``.
target_type: The Arrow struct type from ``shredding_schema_to_arrow_type(obj_fields)``.
Returns:
A ``pa.StructArray`` with ``target_type`` suitable for writing to Parquet
in the shredded format.
"""
from pypaimon.data.generic_variant import GenericVariant
rows = column.to_pylist()
result = []
for row in rows:
if row is None:
result.append(None)
else:
gv = GenericVariant.from_arrow_struct(row)
result.append(decompose_variant(gv, obj_fields))
return pa.array(result, type=target_type)