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 c75ebb8fe1 [python] Keep BlobViewStruct bytes when blob-as-descriptor
is true. (#10057)
c75ebb8fe1 is described below
commit c75ebb8fe189a9910a029a927d28a98e0880d6f4
Author: Wenchao Wu <[email protected]>
AuthorDate: Tue Sep 22 10:45:01 2026 +0800
[python] Keep BlobViewStruct bytes when blob-as-descriptor is true. (#10057)
---
.../read/reader/blob_descriptor_convert_reader.py | 17 +-
paimon-python/pypaimon/read/table_read.py | 53 +++-
paimon-python/pypaimon/tests/blob_table_test.py | 230 +++++++++++++++++
paimon-python/pypaimon/tests/blob_test.py | 285 +++++++++++++++++++++
paimon-python/pypaimon/utils/blob_view_lookup.py | 17 ++
5 files changed, 595 insertions(+), 7 deletions(-)
diff --git
a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
index f7bc46612f..1d671f470a 100644
--- a/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
+++ b/paimon-python/pypaimon/read/reader/blob_descriptor_convert_reader.py
@@ -31,9 +31,12 @@ class BlobInlineConvertReader(RecordBatchReader):
Processing is split into two clear stages:
Stage 1 (BlobView resolution): If view fields exist, use a lightweight
prescan reader (only projecting view columns) to collect
- BlobViewStructs and bulk-preload their descriptors, then replace
- view field values with descriptor bytes so Stage 2 can
- materialize payloads with the originating table FileIO.
+ BlobViewStructs and bulk-preload their descriptors. When
+ blob-as-descriptor=false, replace view field values with
+ descriptor bytes so Stage 2 can materialize payloads with the
+ originating table FileIO. When blob-as-descriptor=true, leave
+ BlobViewStruct bytes in place so get_blob() keeps that
+ association; Arrow output serializes descriptors later.
Stage 2 (BlobDescriptor resolution): Controlled by blob-as-descriptor
option.
If false, resolve BlobDescriptor bytes from descriptor fields
into real blob data bytes. BlobView fields are already resolved
@@ -81,9 +84,13 @@ class BlobInlineConvertReader(RecordBatchReader):
batch = self._inner.read_arrow_batch()
if batch is None:
return None
- # Resolve view fields using the preloaded lookup.
+ # Resolve view fields using the preloaded lookup. When
+ # blob-as-descriptor=true, leave BlobViewStruct bytes in place so
+ # get_blob() keeps the originating table's FileIO. Arrow paths
+ # serialize descriptors later.
view_blobs = {}
- if self._view_fields and self._blob_view_lookup is not None:
+ if (self._view_fields and self._blob_view_lookup is not None
+ and not self._blob_as_descriptor):
batch, view_blobs = self._resolve_view_fields(batch,
self._blob_view_lookup)
# Resolve BlobDescriptor -> real bytes (if blob-as-descriptor=false)
return self._resolve_descriptor_fields(batch, view_blobs)
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 1b3a4585d3..fb2926a467 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -168,6 +168,16 @@ class TableRead:
self._predicate_extra_fields =
self._predicate_fields_outside_read_type()
self._scan_read_type = self.read_type + self._predicate_extra_fields
self._output_column_names = [f.name for f in self.read_type]
+ # Output positions in ``read_type``, not ``_scan_read_type``. Predicate
+ # extras are appended for the inner scan and projected away before
+ # ``_serialize_blob_view_row_tuple``; prepending them would make these
+ # indices rewrite the wrong columns.
+ blob_view_fields = self.table.options.blob_view_fields()
+ self._blob_view_output_indices = tuple(
+ i for i, field in enumerate(self.read_type)
+ if field.name in blob_view_fields
+ )
+ self._blob_as_descriptor =
bool(self.table.options.blob_as_descriptor())
self._deferred_blob_fields = (
deferred_blob_field_names(
self.table,
@@ -813,6 +823,8 @@ class TableRead:
continue
if remaining is not None and batch.num_rows >
remaining:
batch = batch.slice(0, remaining)
+ batch = self._serialize_blob_views_as_descriptors(
+ batch, reader)
batch = self._project_batch_to_output(batch)
if self.include_row_kind:
if "_row_kind" not in batch.schema.names:
@@ -833,7 +845,8 @@ class TableRead:
for row in iter(row_iterator.next, None):
if not isinstance(row, OffsetRow):
raise TypeError(f"Expected OffsetRow, but got
{type(row).__name__}")
- row_tuple_chunk.append(row.row_tuple[row.offset:
row.offset + row.arity])
+ row_tuple_chunk.append(
+ self._serialize_blob_view_row_tuple(row,
reader))
if self.include_row_kind:
row_kind_chunk.append(row.get_row_kind().to_string())
@@ -1045,6 +1058,8 @@ class TableRead:
break
if allowed < batch.num_rows:
batch = batch.slice(0, allowed)
+ batch = self._serialize_blob_views_as_descriptors(
+ batch, reader)
batch = self._project_batch_to_output(batch)
if self.include_row_kind:
if "_row_kind" not in batch.schema.names:
@@ -1068,7 +1083,7 @@ class TableRead:
stop = True
break
row_tuple_chunk.append(
- row.row_tuple[row.offset: row.offset + row.arity])
+ self._serialize_blob_view_row_tuple(row, reader))
if self.include_row_kind:
row_kind_chunk.append(row.get_row_kind().to_string())
@@ -1530,6 +1545,40 @@ class TableRead:
limit=effective_limit,
)
+ def _serialize_blob_views_as_descriptors(self, batch: pyarrow.RecordBatch,
reader) -> pyarrow.RecordBatch:
+ if not self._blob_as_descriptor or not self._blob_view_output_indices:
+ return batch
+ lookup = getattr(reader, 'blob_view_lookup', None)
+ if lookup is None:
+ return batch
+ for index in self._blob_view_output_indices:
+ field_name = self.read_type[index].name
+ if field_name not in batch.schema.names:
+ continue
+ converted = [
+ lookup.serialize_view_field_value(value)
+ for value in batch.column(field_name).to_pylist()
+ ]
+ column_idx = batch.schema.names.index(field_name)
+ batch = batch.set_column(
+ column_idx,
+ pyarrow.field(field_name, pyarrow.large_binary(),
nullable=True),
+ pyarrow.array(converted, type=pyarrow.large_binary()),
+ )
+ return batch
+
+ def _serialize_blob_view_row_tuple(self, row: OffsetRow, reader) -> tuple:
+ values = row.row_tuple[row.offset: row.offset + row.arity]
+ if not self._blob_as_descriptor or not self._blob_view_output_indices:
+ return values
+ lookup = getattr(reader, 'blob_view_lookup', None)
+ if lookup is None:
+ return values
+ converted = list(values)
+ for index in self._blob_view_output_indices:
+ converted[index] =
lookup.serialize_view_field_value(converted[index])
+ return tuple(converted)
+
def _project_batch_to_output(self, batch: pyarrow.RecordBatch) ->
pyarrow.RecordBatch:
if not self._needs_output_projection():
return batch
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index 9adb80e1ac..6b4133242c 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -5593,6 +5593,236 @@ class DedicatedFormatWriterTest(unittest.TestCase):
self.assertEqual(
result.column('picture').to_pylist(), [b'raw-payload-9'])
+ def test_blob_view_as_descriptor_get_blob_uses_upstream_file_io(self):
+ """blob-as-descriptor=true still must read source .blob with source
FileIO.
+
+ Stage 1 retains each BlobViewStruct so get_blob().to_data() can select
+ the source table token instead of falling back to the target table
token.
+ """
+ from pypaimon import Schema
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.table.row.blob import BlobViewStruct
+
+ class GuardedFileIO:
+ def __init__(self, wrapped, forbidden_uris):
+ self._wrapped = wrapped
+ self._forbidden_uris = forbidden_uris
+ self.forbidden_reads = []
+
+ def new_input_stream(self, path):
+ path = str(path)
+ if path in self._forbidden_uris:
+ self.forbidden_reads.append(path)
+ raise AssertionError(
+ "Downstream file_io must not read upstream blob
{}.".format(path)
+ )
+ return self._wrapped.new_input_stream(path)
+
+ def __getattr__(self, name):
+ return getattr(self._wrapped, name)
+
+ source_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ source = Schema.from_pyarrow_schema(
+ source_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_desc_guard_source', source, False)
+ source_table = self.catalog.get_table(
+ 'test_db.blob_view_desc_guard_source')
+ payloads = [b'desc-guard-source-0', b'desc-guard-source-1']
+
+ write_builder = source_table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': [1, 2],
+ 'picture': payloads,
+ }, schema=source_schema))
+ source_commit_messages = writer.prepare_commit()
+ write_builder.new_commit().commit(source_commit_messages)
+ writer.close()
+
+ source_blob_paths = {
+ str(f.file_path)
+ for msg in source_commit_messages
+ for f in msg.new_files
+ if f.file_name.endswith('.blob')
+ }
+ self.assertGreater(len(source_blob_paths), 0)
+
+ picture_field_id = next(
+ field.id for field in source_table.table_schema.fields
+ if field.name == 'picture'
+ )
+ view_values = [
+ BlobViewStruct(
+ 'test_db.blob_view_desc_guard_source', picture_field_id, 0
+ ).serialize(),
+ BlobViewStruct(
+ 'test_db.blob_view_desc_guard_source', picture_field_id, 1
+ ).serialize(),
+ ]
+
+ target_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ target = Schema.from_pyarrow_schema(
+ target_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'blob-view-field': 'picture',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_desc_guard_target', target, False)
+ target_table = self.catalog.get_table(
+ 'test_db.blob_view_desc_guard_target')
+
+ target_write_builder = target_table.new_batch_write_builder()
+ target_writer = target_write_builder.new_write()
+ target_writer.write_arrow(pa.Table.from_pydict({
+ 'id': [10, 11],
+ 'picture': view_values,
+ }, schema=target_schema))
+ target_write_builder.new_commit().commit(
+ target_writer.prepare_commit())
+ target_writer.close()
+
+ descriptor_table = target_table.copy({
+ CoreOptions.BLOB_AS_DESCRIPTOR.key(): 'true'
+ })
+ original_file_io = descriptor_table.file_io
+ guarded_file_io = GuardedFileIO(original_file_io, source_blob_paths)
+ descriptor_table.file_io = guarded_file_io
+ try:
+ read_builder = descriptor_table.new_read_builder()
+ data = []
+ for row in read_builder.new_read().to_iterator(
+ read_builder.new_scan().plan().splits()):
+ data.append(row.get_blob(1).to_data())
+ finally:
+ descriptor_table.file_io = original_file_io
+
+ self.assertEqual(sorted(data), sorted(payloads))
+ self.assertEqual(guarded_file_io.forbidden_reads, [])
+
+ def
test_blob_view_as_descriptor_projection_with_predicate_extra_field(self):
+ """Arrow serialize uses read_type positions, not the widened scan type.
+
+ Projection puts picture first and drops grp; the filter still needs
+ grp internally. _blob_view_output_indices must be (0,) — picture in
+ the output schema — so a later change that indexes _scan_read_type
+ or the table field order cannot silently rewrite the wrong column.
+ """
+ from pypaimon import Schema
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
+
+ source_schema = pa.schema([
+ ('id', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ source = Schema.from_pyarrow_schema(
+ source_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_proj_pred_source', source, False)
+ source_table = self.catalog.get_table(
+ 'test_db.blob_view_proj_pred_source')
+ payloads = [b'proj-pred-source-1', b'proj-pred-source-2']
+
+ write_builder = source_table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': [1, 2],
+ 'picture': payloads,
+ }, schema=source_schema))
+ write_builder.new_commit().commit(writer.prepare_commit())
+ writer.close()
+
+ picture_field_id = next(
+ field.id for field in source_table.table_schema.fields
+ if field.name == 'picture'
+ )
+ view_values = [
+ BlobViewStruct(
+ 'test_db.blob_view_proj_pred_source', picture_field_id, 0
+ ).serialize(),
+ BlobViewStruct(
+ 'test_db.blob_view_proj_pred_source', picture_field_id, 1
+ ).serialize(),
+ ]
+
+ target_schema = pa.schema([
+ ('id', pa.int32()),
+ ('grp', pa.int32()),
+ ('picture', pa.large_binary()),
+ ])
+ target = Schema.from_pyarrow_schema(
+ target_schema,
+ options={
+ 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true',
+ 'blob-view-field': 'picture',
+ }
+ )
+ self.catalog.create_table(
+ 'test_db.blob_view_proj_pred_target', target, False)
+ target_table = self.catalog.get_table(
+ 'test_db.blob_view_proj_pred_target')
+
+ target_write_builder = target_table.new_batch_write_builder()
+ target_writer = target_write_builder.new_write()
+ target_writer.write_arrow(pa.Table.from_pydict({
+ 'id': [10, 11],
+ 'grp': [1, 2],
+ 'picture': view_values,
+ }, schema=target_schema))
+ target_write_builder.new_commit().commit(
+ target_writer.prepare_commit())
+ target_writer.close()
+
+ descriptor_table = target_table.copy({
+ CoreOptions.BLOB_AS_DESCRIPTOR.key(): 'true'
+ })
+ predicate =
descriptor_table.new_read_builder().new_predicate_builder().equal(
+ 'grp', 2)
+ read_builder = descriptor_table.new_read_builder().with_projection(
+ ['picture', 'id']).with_filter(predicate)
+ table_read = read_builder.new_read()
+
+ self.assertEqual(
+ [field.name for field in table_read.read_type],
+ ['picture', 'id'])
+ self.assertEqual(
+ [field.name for field in table_read._scan_read_type],
+ ['picture', 'id', 'grp'])
+ self.assertEqual(table_read._blob_view_output_indices, (0,))
+ self.assertTrue(table_read._blob_as_descriptor)
+
+ result = table_read.to_arrow(read_builder.new_scan().plan().splits())
+ self.assertEqual(list(result.column_names), ['picture', 'id'])
+ self.assertEqual(result.num_rows, 1)
+ self.assertEqual(result.column('id').to_pylist(), [11])
+ picture_bytes = result.column('picture').to_pylist()[0]
+ self.assertTrue(
+ BlobDescriptor.is_blob_descriptor(picture_bytes),
+ "Projected view column must be descriptor bytes, not
BlobViewStruct",
+ )
+ self.assertFalse(BlobViewStruct.is_blob_view_struct(picture_bytes))
+
class GetBlobTest(unittest.TestCase):
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 736c7ddd97..dee9d21488 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -2867,6 +2867,291 @@ class BlobTest(unittest.TestCase):
return TokenFileIO()
+ def test_offset_row_get_blob_view_keeps_per_table_uri_reader(self):
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.uri_reader import FileUriReader, UriReaderFactory
+ from pypaimon.table.row.offset_row import OffsetRow
+ from pypaimon.utils.blob_view_lookup import BlobViewLookup
+
+ shared_uri = "s3://shared/blob"
+ data_a = b"AAAA"
+ data_b = b"BBBB"
+ descriptor = BlobDescriptor(shared_uri, 0, len(data_a))
+ view_a = BlobViewStruct(Identifier.from_string("db.src_a"), 1, 0)
+ view_b = BlobViewStruct(Identifier.from_string("db.src_b"), 1, 0)
+
+ class TokenFileIO:
+ def __init__(self, payload):
+ self._payload = payload
+ self.opened = []
+
+ def new_input_stream(self, path):
+ self.opened.append(path)
+ return io.BytesIO(self._payload)
+
+ file_io_a = TokenFileIO(data_a)
+ file_io_b = TokenFileIO(data_b)
+ lookup = BlobViewLookup(object())
+ lookup._uri_reader_cache["db.src_a"] = FileUriReader(file_io_a)
+ lookup._uri_reader_cache["db.src_b"] = FileUriReader(file_io_b)
+ lookup._uri_reader_factory_cache["db.src_a"] = (
+ UriReaderFactory.from_file_io(file_io_a))
+ lookup._uri_reader_factory_cache["db.src_b"] = (
+ UriReaderFactory.from_file_io(file_io_b))
+ lookup._store_chunk_results(
+ {view_a: descriptor, view_b: descriptor}, set())
+
+ target_file_io = TokenFileIO(b"target-must-not-be-used")
+ row_a = OffsetRow(
+ (view_a.serialize(),), 0, 1,
+ file_io=target_file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ blob_view_lookup=lookup,
+ )
+ row_b = OffsetRow(
+ (view_b.serialize(),), 0, 1,
+ file_io=target_file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ blob_view_lookup=lookup,
+ )
+ self.assertEqual(row_a.get_blob(0).to_data(), data_a)
+ self.assertEqual(row_b.get_blob(0).to_data(), data_b)
+ self.assertEqual(file_io_a.opened, [shared_uri])
+ self.assertEqual(file_io_b.opened, [shared_uri])
+ self.assertEqual(target_file_io.opened, [])
+
+ def test_table_read_serializes_only_configured_blob_view_fields(self):
+ from types import SimpleNamespace
+
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.table_read import TableRead
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+ from pypaimon.utils.blob_view_lookup import BlobViewLookup
+
+ view_struct = BlobViewStruct(Identifier.from_string("db.source"), 1, 0)
+ view_bytes = view_struct.serialize()
+ descriptor = BlobDescriptor("s3://source/blob", 0, 4)
+ lookup = BlobViewLookup(object())
+ lookup._store_chunk_results({view_struct: descriptor}, set())
+
+ table_read = TableRead.__new__(TableRead)
+ table_read.table = SimpleNamespace(options=CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-view-field": "picture",
+ })))
+ table_read.read_type = [
+ DataField(0, "id", AtomicType("INT")),
+ DataField(1, "picture", AtomicType("BYTES")),
+ ]
+ table_read._blob_as_descriptor = True
+ table_read._blob_view_output_indices = (1,)
+ reader = SimpleNamespace(blob_view_lookup=lookup)
+ row = OffsetRow((view_bytes, view_bytes), 0, 2)
+
+ converted = table_read._serialize_blob_view_row_tuple(row, reader)
+
+ self.assertEqual(converted[0], view_bytes)
+ self.assertEqual(converted[1], descriptor.serialize())
+
+ table_read._blob_view_output_indices = ()
+ skipped = table_read._serialize_blob_view_row_tuple(
+ row, SimpleNamespace())
+ self.assertEqual(skipped, (view_bytes, view_bytes))
+
+ table_read._blob_view_output_indices = (1,)
+ table_read._blob_as_descriptor = False
+ not_descriptor = table_read._serialize_blob_view_row_tuple(row, reader)
+ self.assertEqual(not_descriptor, (view_bytes, view_bytes))
+
+ def test_offset_row_get_blob_v1_resolved_blob_view_field(self):
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.field_indices import
descriptor_field_indices_for_table
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"resolved blob-view payload"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ uri = blob_path.encode('utf-8')
+ serialized_v1 = (
+ bytes([1])
+ + struct.pack('<I', len(uri))
+ + uri
+ + struct.pack('<q', 0)
+ + struct.pack('<q', len(data))
+ )
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-view-field": "picture",
+ }))
+
+ fields = [DataField(0, "picture", AtomicType("BYTES"))]
+ descriptor_indices = descriptor_field_indices_for_table(_Table(),
fields)
+ file_io = FileIO.get(f"file://{tmp_dir}", {})
+ row = OffsetRow(
+ (serialized_v1,), 0, 1, file_io=file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=descriptor_indices)
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+
+ def test_offset_row_get_blob_resolves_null_blob_view(self):
+ from unittest.mock import MagicMock
+
+ from pypaimon.table.row.offset_row import OffsetRow
+ from pypaimon.table.row.blob import BlobViewStruct
+ from pypaimon.common.identifier import Identifier
+
+ view_struct = BlobViewStruct(Identifier.from_string("db.source"), 1,
42)
+ lookup = MagicMock()
+ lookup.resolve_to_null.return_value = True
+ row = OffsetRow(
+ (view_struct.serialize(),), 0, 1,
+ blob_field_indices=[0],
+ blob_view_lookup=lookup,
+ )
+ self.assertIsNone(row.get_blob(0))
+ lookup.resolve_to_null.assert_called_once()
+
+ def test_offset_row_get_blob_view_struct_without_view_field_indices(self):
+ from unittest.mock import MagicMock
+
+ from pypaimon.common.options import Options
+ from pypaimon.common.options.core_options import CoreOptions
+ from pypaimon.read.reader.field_indices import
descriptor_field_indices_for_table
+ from pypaimon.schema.data_types import AtomicType, DataField
+ from pypaimon.common.uri_reader import FileUriReader
+ from pypaimon.table.row.blob import BlobRef, BlobViewStruct
+ from pypaimon.table.row.offset_row import OffsetRow
+ from pypaimon.common.identifier import Identifier
+
+ data = b"resolved via view struct"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "blob.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ descriptor = BlobDescriptor(blob_path, 0, len(data))
+
+ class _Table:
+ options = CoreOptions(Options({
+ "blob-as-descriptor": "true",
+ "blob-view-field": "picture",
+ }))
+
+ fields = [DataField(0, "picture", AtomicType("BYTES"))]
+ descriptor_indices = descriptor_field_indices_for_table(_Table(),
fields)
+ view_struct = BlobViewStruct(Identifier.from_string("db.source"),
1, 42)
+ lookup = MagicMock()
+ lookup.resolve_to_null.return_value = False
+ lookup.resolve_blob.return_value = BlobRef(
+ FileUriReader(FileIO.get(f"file://{tmp_dir}", {})), descriptor)
+
+ row = OffsetRow(
+ (view_struct.serialize(),), 0, 1,
+ blob_field_indices=[0],
+ descriptor_field_indices=descriptor_indices,
+ blob_view_lookup=lookup,
+ )
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ lookup.resolve_blob.assert_called_once_with(view_struct)
+
+ def
test_to_iterator_adapters_refresh_blob_view_lookup_after_first_read(self):
+ """Merge to_iterator rebuilds OffsetRow after wrap; lookup is filled on
+ the first convert read, so adapters must copy it then, not at wrap
time.
+ """
+ from unittest.mock import MagicMock
+
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.uri_reader import FileUriReader
+ from pypaimon.read.reader.auth_masking_reader import (
+ BatchToRecordReaderAdapter, RecordReaderToBatchAdapter)
+ from pypaimon.read.reader.iface.record_iterator import RecordIterator
+ from pypaimon.read.reader.iface.record_reader import RecordReader
+ from pypaimon.read.reader.limited_record_reader import
LimitedRecordReader
+ from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+ from pypaimon.table.row.offset_row import OffsetRow
+
+ data = b"upstream blob via refreshed lookup"
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ blob_path = os.path.join(tmp_dir, "upstream.bin")
+ with open(blob_path, 'wb') as f:
+ f.write(data)
+ view_struct = BlobViewStruct(Identifier.from_string("db.src"), 1,
0)
+ source_file_io = FileIO.get(f"file://{tmp_dir}", {})
+ target_file_io = MagicMock()
+ target_file_io.new_input_stream.side_effect = AssertionError(
+ "target FileIO must not read upstream blob")
+ lookup = MagicMock()
+ lookup.resolve_to_null.return_value = False
+ lookup.resolve_blob.return_value = BlobRef(
+ FileUriReader(source_file_io),
+ BlobDescriptor(blob_path, 0, len(data)),
+ )
+
+ class _OnceIterator(RecordIterator):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+
+ def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._row
+
+ class _ConvertLikeReader(RecordReader):
+ def __init__(self, row):
+ self._row = row
+ self._done = False
+ self.blob_view_lookup = None
+ self.file_io = target_file_io
+ self.blob_field_indices = {0}
+ self.descriptor_field_indices = {0}
+
+ def read_batch(self):
+ if self._done:
+ return None
+ self._done = True
+ self.blob_view_lookup = lookup
+ return _OnceIterator(self._row)
+
+ def close(self):
+ pass
+
+ fields = [DataField(0, "picture", AtomicType("BLOB"))]
+ schema = PyarrowFieldParser.from_paimon_schema(fields)
+ inner = _ConvertLikeReader(OffsetRow(
+ (view_struct.serialize(),), 0, 1,
+ file_io=target_file_io,
+ blob_field_indices=[0],
+ descriptor_field_indices=[0],
+ ))
+ limited = LimitedRecordReader(inner, 10)
+ self.assertIsNone(limited.blob_view_lookup)
+ batch_reader = RecordReaderToBatchAdapter(limited, schema)
+ self.assertIsNone(batch_reader.blob_view_lookup)
+ wrapped = BatchToRecordReaderAdapter(batch_reader)
+ self.assertIsNone(wrapped.blob_view_lookup)
+ row = wrapped.read_batch().next()
+ blob = row.get_blob(0)
+ self.assertIsInstance(blob, BlobRef)
+ self.assertEqual(blob.to_data(), data)
+ lookup.resolve_blob.assert_called_once_with(view_struct)
+ target_file_io.new_input_stream.assert_not_called()
+ wrapped.close()
+
class BlobEndToEndTest(unittest.TestCase):
"""End-to-end tests for blob functionality with schema definition, file
writing, and reading."""
diff --git a/paimon-python/pypaimon/utils/blob_view_lookup.py
b/paimon-python/pypaimon/utils/blob_view_lookup.py
index 37d6df5c7e..e24eec5a67 100644
--- a/paimon-python/pypaimon/utils/blob_view_lookup.py
+++ b/paimon-python/pypaimon/utils/blob_view_lookup.py
@@ -132,6 +132,23 @@ class BlobViewLookup:
)
return uri_reader
+ def serialize_view_field_value(self, value):
+ """Replace BlobViewStruct bytes with descriptor bytes for Arrow
output."""
+ if value is None:
+ return None
+ if hasattr(value, 'as_py'):
+ value = value.as_py()
+ if isinstance(value, str):
+ value = value.encode('utf-8')
+ if isinstance(value, bytearray):
+ value = bytes(value)
+ if not (isinstance(value, bytes) and
BlobViewStruct.is_blob_view_struct(value)):
+ return value
+ view_struct = BlobViewStruct.deserialize(value)
+ if self.resolve_to_null(view_struct):
+ return None
+ return self.resolve_descriptor(view_struct).serialize()
+
def _store_chunk_results(self, descriptors, null_values):
self._descriptor_cache.update(descriptors)
self._null_value_cache.update(null_values)