blob: 7feb12c08e32ce50179eed9c4c0f0e001491216b [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.
"""Transport row Blob streams into the core URI copy path."""
from collections.abc import Mapping
from decimal import Decimal
from uuid import uuid4
import pyarrow as pa
from pypaimon.schema.data_types import ArrayType, MapType, PyarrowFieldParser, is_blob_file_field
from pypaimon.table.row.blob import Blob, BlobDescriptor
from pypaimon.write.row_utils import inline_blob_value, value_for_arrow
class _BlobRowReader:
def __init__(self, blob):
self.blob = blob
def new_input_stream(self, uri):
return self.blob.new_input_stream()
class NativeBlobRows:
"""Expose each Python Blob lazily, without opening or materializing it.
The synthetic descriptors only transport live objects for this operation.
Core copies their payloads into normal .blob records; these URIs must never
become stored descriptors. A new reader is kept for each Blob object so
the core cannot reuse an unrelated Python stream.
"""
def __init__(self, fallback=None):
self.readers = {}
self._consumed = set()
self.fallback = fallback
def _supports_uri(self, uri):
# Ordinary descriptors retain the core FileIO/HTTP path. A configured
# application factory owns other URIs and its errors must propagate.
if uri in self.readers:
return True
if self.fallback is None:
return False
if hasattr(self.fallback, '_supports_uri'):
return self.fallback._supports_uri(uri)
return True
def create(self, uri):
if uri in self.readers:
self._consumed.add(uri)
return self.readers[uri]
return self.fallback.create(uri)
def release_consumed(self):
"""Release inputs consumed by core, retaining unplanned partition rows."""
for uri in self._consumed:
self.readers.pop(uri, None)
self._consumed.clear()
def clear(self):
self.readers.clear()
self._consumed.clear()
def to_batch(self, table, rows, names):
"""Transport existing row values; core owns physical input validation."""
descriptor_fields = table.options.blob_descriptor_fields()
view_fields = table.options.blob_view_fields()
fields = []
columns = []
for name in names:
field = table.field_dict[name]
arrow_field = PyarrowFieldParser.from_paimon_field(field)
inline = name in descriptor_fields or name in view_fields
if is_blob_file_field(field) and not inline:
if isinstance(field.type, MapType) and not table.is_primary_key_table:
entries = pa.struct([
pa.field('key', arrow_field.type.key_type),
arrow_field.type.item_field.with_name('value'),
])
arrow_field = pa.field(name, pa.list_(entries), nullable=arrow_field.nullable)
values = [self.encode(row[name], field.type) for row in rows]
if (isinstance(field.type, MapType) and not table.is_primary_key_table
and pa.types.is_decimal(arrow_field.type.value_type[0].type)):
# Preserve arbitrary source scale. The core rounds and checks
# precision before serializing the declared decimal key.
def key_text(key):
if key is None:
return None
if not isinstance(key, Decimal):
raise ValueError('MAP<X, BLOB> DECIMAL key must be a decimal.Decimal.')
return format(key, 'f')
values = [None if value is None else [(key_text(key), item) for key, item in value]
for value in values]
entries = arrow_field.type.value_type
entries = pa.struct([entries[0].with_type(pa.string()), entries[1]])
arrow_field = arrow_field.with_type(pa.list_(entries))
else:
values = [value_for_arrow(inline_blob_value(
row[name], name in descriptor_fields, name in view_fields), field)
for row in rows]
fields.append(arrow_field)
columns.append(pa.array(values, type=arrow_field.type))
return pa.RecordBatch.from_arrays(columns, schema=pa.schema(fields))
def encode(self, value, data_type):
if hasattr(value, 'as_py'):
value = value.as_py()
if value is None:
return None
if isinstance(data_type, ArrayType):
if isinstance(value, (bytes, bytearray, str)):
raise ValueError('ARRAY<BLOB> field value must be a list or tuple.')
return [self.encode(item, data_type.element) for item in value]
if isinstance(data_type, MapType):
if isinstance(value, (bytes, bytearray, str)) or not hasattr(value, '__iter__'):
raise ValueError('MAP<X, BLOB> field value must be a mapping or key/value pairs')
entries = value.items() if isinstance(value, Mapping) else value
result = []
for entry in entries:
if not isinstance(entry, (list, tuple)) or len(entry) != 2:
raise ValueError('MAP<X, BLOB> entries must be key/value pairs')
result.append((entry[0], self.encode(entry[1], data_type.value)))
return result
if isinstance(value, Blob):
if any(value is placeholder for placeholder in (
Blob.PLACE_HOLDER, Blob.ARRAY_PLACE_HOLDER, Blob.MAP_PLACE_HOLDER)):
raise ValueError('Blob placeholders are reserved for unchanged rows.')
uri = 'pypaimon-blob-row://' + uuid4().hex
self.readers[uri] = _BlobRowReader(value)
return BlobDescriptor(uri, 0, -1).serialize()
return value