blob: 2bdf38689e847c49e0fcb340a586ce62594c0702 [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.
from typing import List, Optional, Tuple
import pyarrow as pa
import pyarrow.compute as pc
from pyarrow import RecordBatch
from pypaimon.common.file_io import FileIO
from pypaimon.read.partition_info import PartitionInfo
from pypaimon.read.reader.format_blob_reader import FormatBlobReader
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
MapType, PyarrowFieldParser, RowType)
from pypaimon.table.special_fields import SpecialFields
def _is_character_string_type(data_type) -> bool:
if not isinstance(data_type, AtomicType):
return False
t = data_type.type.upper()
return t == 'STRING' or t.startswith('VARCHAR') or t.startswith('CHAR')
def _unslice(array):
"""Re-materialize a sliced array so offsets/buffers start at zero.
The list/map rebuilds below read ``offsets``/raw buffers directly; on a
sliced array those still point into the parent storage, which either
errors (list rebuild with a null mask) or silently misaligns rows (map
rebuild from raw buffers)."""
if array.offset == 0:
return array
return pa.concat_arrays([array])
def _to_string_values(array, data_type) -> list:
"""Render *array* as a list of per-row strings (None for NULL rows)."""
if isinstance(data_type, (RowType, ArrayType, MapType)):
return _constructed_to_string_array(array, data_type).to_pylist()
return array.cast(pa.string(), safe=False).to_pylist()
def _constructed_to_string_array(array, file_type):
"""Render a struct/list/map array in the engine's string form:
ROW -> ``{v1, v2}``, ARRAY -> ``[e1, e2]``, MAP -> ``{k1 -> v1, k2 -> v2}``.
Sub-values are rendered recursively; a NULL sub-value renders as the
literal ``null`` while a NULL container row stays NULL."""
array = _unslice(array)
valid = pc.is_valid(array).to_pylist()
out = []
if isinstance(file_type, RowType):
children = [
_to_string_values(array.field(i), sub.type)
for i, sub in enumerate(file_type.fields)
]
for i in range(len(array)):
if not valid[i]:
out.append(None)
continue
vals = [c[i] if c[i] is not None else 'null' for c in children]
out.append('{' + ', '.join(vals) + '}')
elif isinstance(file_type, ArrayType):
values = _to_string_values(array.values, file_type.element)
offsets = array.offsets.to_pylist()
for i in range(len(array)):
if not valid[i]:
out.append(None)
continue
elems = [v if v is not None else 'null'
for v in values[offsets[i]:offsets[i + 1]]]
out.append('[' + ', '.join(elems) + ']')
elif isinstance(file_type, MapType):
keys = _to_string_values(array.keys, file_type.key)
items = _to_string_values(array.items, file_type.value)
offsets = array.offsets.to_pylist()
for i in range(len(array)):
if not valid[i]:
out.append(None)
continue
entries = [
'{} -> {}'.format(
keys[j] if keys[j] is not None else 'null',
items[j] if items[j] is not None else 'null')
for j in range(offsets[i], offsets[i + 1])
]
out.append('{' + ', '.join(entries) + '}')
else:
raise ValueError(
'Unsupported constructed type for string rendering: {}'.format(file_type))
return pa.array(out, type=pa.string())
class _RowIdRangeCursor:
"""Generate selected row offsets batch by batch from compact ranges."""
def __init__(self, ranges: List[Tuple[int, int]]):
self._ranges = ranges
self._range_index = 0
self._next_offset = ranges[0][0] if ranges else None
def take(self, count: int) -> List[int]:
offsets = []
while len(offsets) < count:
if self._range_index >= len(self._ranges):
raise ValueError(
"Row range offsets were exhausted before the reader")
lower, upper = self._ranges[self._range_index]
current = max(lower, self._next_offset)
take_count = min(count - len(offsets), upper - current + 1)
offsets.extend(range(current, current + take_count))
current += take_count
if current > upper:
self._range_index += 1
self._next_offset = (
self._ranges[self._range_index][0]
if self._range_index < len(self._ranges) else None)
else:
self._next_offset = current
return offsets
class DataFileBatchReader(RecordBatchReader):
"""
Reads record batch from files of different formats
"""
def __init__(self, format_reader: RecordBatchReader, index_mapping: List[int], partition_info: PartitionInfo,
system_primary_key: Optional[List[str]], fields: List[DataField],
max_sequence_number: int,
first_row_id: int,
row_tracking_enabled: bool,
system_fields: dict,
file_io: Optional[FileIO] = None,
row_id_offsets: Optional[List[int]] = None,
file_data_fields: Optional[List[DataField]] = None,
target_data_fields: Optional[List[DataField]] = None,
row_id_offset_ranges: Optional[List[Tuple[int, int]]] = None):
if (row_id_offsets is not None
and row_id_offset_ranges is not None):
raise ValueError(
"row_id_offsets and row_id_offset_ranges cannot both "
"be provided")
self.format_reader = format_reader
self.index_mapping = index_mapping
self.partition_info = partition_info
self.system_primary_key = system_primary_key
self.schema_map = {field.name: field for field in PyarrowFieldParser.from_paimon_schema(fields)}
self.row_tracking_enabled = row_tracking_enabled
self.first_row_id = first_row_id
self.row_id_offsets = row_id_offsets
self._row_id_cursor = 0
self._row_id_range_cursor = (
_RowIdRangeCursor(row_id_offset_ranges)
if row_id_offset_ranges is not None else None)
self.max_sequence_number = max_sequence_number
self.system_fields = system_fields
self.file_io = file_io
# Per-file field-id normalization: map the physically-read columns
# (the file's own field order/names) onto the latest read target by
# field id, padding missing ids with NULL and recursing into nested
# ROW / ARRAY<ROW> / MAP<.,ROW> sub-fields the same way. ``None`` when
# there is no evolution to reconcile -- the common path stays zero-copy.
self._normalize_plan = self._build_normalize_plan(file_data_fields, target_data_fields)
@staticmethod
def _build_normalize_plan(file_data_fields, target_data_fields):
"""Build a per-file field-id alignment plan.
Returns a list of ``(pos, file_field, target_field)`` -- one per target
field, in target order -- where ``pos`` is the column index in the
physically-read batch carrying ``target_field`` (matched by field id),
or -1 if the file does not contain that id (pad NULL). Returns ``None``
when the file already matches the target exactly (no evolution), so the
caller stays zero-copy.
"""
if file_data_fields is None or target_data_fields is None:
return None
# Recursive equality covers nested sub-field changes too: any rename /
# add / drop / type change at any depth makes the file != target.
if file_data_fields == target_data_fields:
return None
file_id_to_pos = {f.id: i for i, f in enumerate(file_data_fields)}
plan = []
for target in target_data_fields:
pos = file_id_to_pos.get(target.id, -1)
file_field = file_data_fields[pos] if pos >= 0 else None
plan.append((pos, file_field, target))
return plan
def _normalize_batch(self, record_batch: RecordBatch) -> RecordBatch:
"""Reorder/pad the physically-read batch onto the latest read target by
field id, relabel columns to the latest names, and align nested ROW
sub-fields by id. Missing ids become typed all-NULL columns."""
if self._normalize_plan is None:
return record_batch
num_rows = record_batch.num_rows
arrays = []
names = []
for pos, file_field, target_field in self._normalize_plan:
target_pa_type = PyarrowFieldParser.from_paimon_type(target_field.type)
if pos < 0:
arrays.append(pa.nulls(num_rows, type=target_pa_type))
else:
arrays.append(self._align_array_by_id(
record_batch.column(pos), file_field.type, target_field.type))
names.append(target_field.name)
return pa.RecordBatch.from_arrays(arrays, names=names)
def _align_array_by_id(self, array, file_type, target_type):
"""Return *array* converted to *target_type*, matching ROW sub-fields by
field id (reorder, pad missing with NULL, follow renames, cast changed
types) recursively, transparently through ARRAY/MAP wrappers."""
if isinstance(target_type, RowType) and isinstance(file_type, RowType):
n = len(array)
file_id_to_pos = {f.id: i for i, f in enumerate(file_type.fields)}
children = []
pa_fields = []
for tsub in target_type.fields:
p = file_id_to_pos.get(tsub.id, -1)
if p < 0:
child = pa.nulls(n, type=PyarrowFieldParser.from_paimon_type(tsub.type))
else:
child = self._align_array_by_id(
array.field(p), file_type.fields[p].type, tsub.type)
children.append(child)
pa_fields.append(pa.field(tsub.name, child.type, nullable=tsub.type.nullable))
# Preserve the struct's own null mask; child values under a null
# struct are irrelevant.
return pa.StructArray.from_arrays(
children, fields=pa_fields, mask=pc.is_null(array))
if isinstance(target_type, ArrayType) and isinstance(file_type, ArrayType):
array = _unslice(array)
aligned_values = self._align_array_by_id(
array.values, file_type.element, target_type.element)
return pa.ListArray.from_arrays(
array.offsets, aligned_values, mask=pc.is_null(array))
if isinstance(target_type, MapType) and isinstance(file_type, MapType):
array = _unslice(array)
aligned_items = self._align_array_by_id(
array.items, file_type.value, target_type.value)
# MapArray.from_arrays cannot carry a null mask (a null map would
# collapse to an empty one), so rebuild from buffers, reusing the
# original validity/offset buffers and only swapping the value child.
target_pa = PyarrowFieldParser.from_paimon_type(target_type)
entries = pa.StructArray.from_arrays(
[array.keys, aligned_items],
fields=[target_pa.key_field, target_pa.item_field])
return pa.Array.from_buffers(
target_pa, len(array), array.buffers()[:2], children=[entries])
# A constructed type changed to a character string: pyarrow cannot
# cast struct/list/map to utf8 directly, so render the engine's
# string form instead.
if (isinstance(file_type, (RowType, ArrayType, MapType))
and _is_character_string_type(target_type)):
return _constructed_to_string_array(array, file_type)
# 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 array
def read_arrow_batch(self, start_idx=None, end_idx=None) -> Optional[RecordBatch]:
if isinstance(self.format_reader, FormatBlobReader):
record_batch = self.format_reader.read_arrow_batch(start_idx, end_idx)
else:
record_batch = self.format_reader.read_arrow_batch()
if record_batch is None:
return None
record_batch = self._normalize_batch(record_batch)
if self.partition_info is None and self.index_mapping is None:
# A file written under an older schema (e.g. before an INT -> BIGINT
# promotion or a DECIMAL precision change) yields columns in the
# data file's original types. Without reordering or partition padding
# to rebuild the batch, those old types would otherwise leak through
# here -- returning a type that depends on whether this read happens
# to span newer-schema files, and failing to concatenate when it
# does. Align them to the current read schema, mirroring the rebuild
# path below.
record_batch = self._align_batch_to_read_schema(
record_batch.schema.names, record_batch.columns)
if self.row_tracking_enabled and self.system_fields:
record_batch = self._assign_row_tracking(record_batch)
return record_batch
inter_arrays = []
inter_names = []
num_rows = record_batch.num_rows
if self.partition_info is not None:
for i in range(self.partition_info.size()):
if self.partition_info.is_partition_row(i):
partition_value, partition_field = self.partition_info.get_partition_value(i)
const_array = pa.repeat(partition_value, num_rows)
inter_arrays.append(const_array)
inter_names.append(partition_field.name)
else:
real_index = self.partition_info.get_real_index(i)
if real_index < record_batch.num_columns:
inter_arrays.append(record_batch.column(real_index))
inter_names.append(record_batch.schema.field(real_index).name)
else:
inter_arrays = record_batch.columns
inter_names = record_batch.schema.names
if self.index_mapping is not None:
mapped_arrays = []
mapped_names = []
for i, real_index in enumerate(self.index_mapping):
if 0 <= real_index < len(inter_arrays):
mapped_arrays.append(inter_arrays[real_index])
mapped_names.append(inter_names[real_index])
else:
null_array = pa.nulls(num_rows)
mapped_arrays.append(null_array)
mapped_names.append(f"null_col_{i}")
if self.system_primary_key:
for i in range(len(self.system_primary_key)):
if not mapped_names[i].startswith("_KEY_"):
mapped_names[i] = f"_KEY_{mapped_names[i]}"
inter_arrays = mapped_arrays
inter_names = mapped_names
# Rebuild the batch typed by the read schema (carries 'not null' and
# aligns old-schema column types).
record_batch = self._align_batch_to_read_schema(inter_names, inter_arrays)
# Handle row tracking fields
if self.row_tracking_enabled and self.system_fields:
record_batch = self._assign_row_tracking(record_batch)
return record_batch
def _align_batch_to_read_schema(self, names: List[str], arrays: list) -> RecordBatch:
"""Build a record batch for ``names``/``arrays`` typed by the read schema.
Each known field is cast to the current read schema's type, which also
carries the 'not null' property; unknown columns keep the array's own
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.
"""
out_arrays = []
out_fields = []
for name, array in zip(names, arrays):
target_field = self.schema_map.get(name)
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)
out_arrays.append(array)
out_fields.append(target_field)
return pa.RecordBatch.from_arrays(out_arrays, schema=pa.schema(out_fields))
def _assign_row_tracking(self, record_batch: RecordBatch) -> RecordBatch:
"""Assign row tracking meta fields (_ROW_ID and _SEQUENCE_NUMBER)."""
arrays = list(record_batch.columns)
# Handle _ROW_ID field
if SpecialFields.ROW_ID.name in self.system_fields.keys():
idx = self.system_fields[SpecialFields.ROW_ID.name]
if self.first_row_id is None:
arrays[idx] = pa.nulls(record_batch.num_rows, type=pa.int64())
elif self.row_id_offsets is not None:
end = self._row_id_cursor + record_batch.num_rows
row_ids = [self.first_row_id + o for o in self.row_id_offsets[self._row_id_cursor:end]]
arrays[idx] = pa.array(row_ids, type=pa.int64())
self._row_id_cursor = end
elif self._row_id_range_cursor is not None:
row_ids = [
self.first_row_id + offset
for offset in self._row_id_range_cursor.take(
record_batch.num_rows)
]
arrays[idx] = pa.array(row_ids, type=pa.int64())
else:
row_id_range = range(self.first_row_id, self.first_row_id + record_batch.num_rows)
arrays[idx] = pa.array(row_id_range, type=pa.int64())
self.first_row_id += record_batch.num_rows
# Handle _SEQUENCE_NUMBER field
if SpecialFields.SEQUENCE_NUMBER.name in self.system_fields.keys():
idx = self.system_fields[SpecialFields.SEQUENCE_NUMBER.name]
# Create a new array that fills with max_sequence_number
arrays[idx] = pa.repeat(self.max_sequence_number, record_batch.num_rows)
names = record_batch.schema.names
table = None
for i, name in enumerate(names):
field = pa.field(
name, arrays[i].type,
nullable=record_batch.schema.field(name).nullable
)
if table is None:
table = pa.table({name: arrays[i]}, schema=pa.schema([field]))
else:
table = table.append_column(field, arrays[i])
return table.to_batches()[0]
def close(self) -> None:
self.format_reader.close()