blob: f661faebf281ef3d4e2f5212e7654eba5cd286dc [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.
import datetime
import io
import os
import shutil
import struct
import tempfile
import unittest
import zlib
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
from pypaimon.common.file_io import FileIO
from pypaimon.common.options import Options
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.read.reader.concat_batch_reader import BlobFallbackBatchReader
from pypaimon.read.reader.format_blob_reader import BlobRecordIterator, FormatBlobReader
from pypaimon.schema.data_types import ArrayType, AtomicType, DataField, MapType
from pypaimon.table.row.blob import Blob, BlobData, BlobRef, BlobDescriptor, BlobViewStruct, BlobView
from pypaimon.table.row.generic_row import GenericRowDeserializer, GenericRowSerializer, GenericRow
from pypaimon.table.row.row_kind import RowKind
class MockFileIO:
"""Mock FileIO for testing."""
def __init__(self, file_io: FileIO):
self._file_io = file_io
def get_file_size(self, path: str) -> int:
"""Get file size."""
return self._file_io.get_file_size(path)
def new_input_stream(self, path):
"""Create new input stream for reading."""
if not isinstance(path, (str, type(None))):
path = str(path)
return self._file_io.new_input_stream(path)
def _to_url(path):
"""Convert Path to file:// URI string."""
if isinstance(path, Path):
path_str = str(path)
is_absolute = os.path.isabs(path_str) or (len(path_str) >= 2 and path_str[1] == ':')
if is_absolute:
if path_str.startswith('/'):
return f"file://{path_str}"
else:
return f"file:///{path_str}"
else:
return f"file://{path_str}"
return str(path) if path else path
class RowUtilsTest(unittest.TestCase):
def test_blob_validation_only_scans_blob_fields(self):
from pypaimon.write.row_utils import value_for_arrow
class TrackingList(list):
def __init__(self, values):
super().__init__(values)
self.iterations = 0
def __iter__(self):
self.iterations += 1
return super().__iter__()
normal_values = TrackingList([1, 2, 3])
normal_field = DataField(
0,
"values",
ArrayType(True, AtomicType("INT")),
)
self.assertIs(
value_for_arrow(normal_values, normal_field),
normal_values,
)
self.assertEqual(normal_values.iterations, 0)
blob_values = TrackingList([BlobData(b"payload")])
blob_field = DataField(
1,
"payloads",
ArrayType(True, AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "Blob values cannot be converted"):
value_for_arrow(blob_values, blob_field)
self.assertEqual(blob_values.iterations, 1)
class BlobTest(unittest.TestCase):
def setUp(self):
"""Set up test environment with temporary file."""
# Create a temporary directory and file
self.temp_dir = tempfile.mkdtemp()
self.file = os.path.join(self.temp_dir, "test.txt")
# Write test data to the file
with open(self.file, 'wb') as f:
f.write(b"test data")
def tearDown(self):
"""Clean up temporary files."""
try:
if os.path.exists(self.file):
os.remove(self.file)
os.rmdir(self.temp_dir)
except OSError:
pass # Ignore cleanup errors
def test_blob_copy_buffer_size_validation(self):
"""blob.copy-buffer-size must be positive and fit Java's int-sized array."""
from pypaimon.write.blob_format_writer import BlobFormatWriter
from pypaimon.common.options.core_options import CoreOptions
# The internal writer already receives an int-sized value and only rejects non-positive
# buffers.
for bad in [0, -1]:
with self.assertRaises(ValueError):
BlobFormatWriter(io.BytesIO(), copy_buffer_size=bad)
BlobFormatWriter(io.BytesIO(), copy_buffer_size=8).close()
# CoreOptions defaults to 4 KiB, accepts values above the old 256 MiB ceiling,
# and retains only the Java int technical limit for cross-language consistency.
self.assertEqual(CoreOptions(Options({})).blob_copy_buffer_size(), 4096)
self.assertEqual(
CoreOptions(Options({'blob.copy-buffer-size': '256 kb'})).blob_copy_buffer_size(),
256 * 1024)
self.assertEqual(
CoreOptions(Options({'blob.copy-buffer-size': '512 mb'})).blob_copy_buffer_size(),
512 * 1024 * 1024)
self.assertEqual(
CoreOptions(Options({
'blob.copy-buffer-size': f'{(1 << 31) - 1} bytes'
})).blob_copy_buffer_size(),
(1 << 31) - 1)
for bad in ['0 bytes', '2 gb', '3 gb']:
with self.assertRaises(ValueError):
CoreOptions(Options({'blob.copy-buffer-size': bad})).blob_copy_buffer_size()
def test_from_data(self):
"""Test Blob.from_data() method."""
test_data = b"test data"
blob = Blob.from_data(test_data)
# Verify it returns a BlobData instance
self.assertIsInstance(blob, BlobData)
# Verify the data matches
self.assertEqual(blob.to_data(), test_data)
def test_from_local(self):
"""Test Blob.from_local() method."""
blob = Blob.from_local(self.file)
# Verify it returns a BlobRef instance
self.assertIsInstance(blob, BlobRef)
# Verify the data matches
self.assertEqual(blob.to_data(), b"test data")
def test_from_file_with_offset_and_length(self):
"""Test Blob.from_file() method with offset and length."""
file_io = LocalFileIO(self.file if self.file.startswith('file://') else f"file://{self.file}", Options({}))
blob = Blob.from_file(file_io, self.file, 0, 4)
# Verify it returns a BlobRef instance
self.assertIsInstance(blob, BlobRef)
# Verify the data matches (first 4 bytes: "test")
self.assertEqual(blob.to_data(), b"test")
def test_from_http(self):
"""Test Blob.from_http() method."""
uri = "http://example.com/file.txt"
blob = Blob.from_http(uri)
# Verify it returns a BlobRef instance
self.assertIsInstance(blob, BlobRef)
# Verify the descriptor has the correct URI
descriptor = blob.to_descriptor()
self.assertEqual(descriptor.uri, uri)
self.assertEqual(descriptor.offset, 0)
self.assertEqual(descriptor.length, -1)
def test_from_bytes_with_raw_data(self):
raw = b"hello blob"
blob = Blob.from_bytes(raw)
self.assertIsInstance(blob, BlobData)
self.assertEqual(blob.to_data(), raw)
def test_from_bytes_with_none(self):
self.assertIsNone(Blob.from_bytes(None))
def test_from_bytes_with_descriptor(self):
from pypaimon.common.file_io import FileIO
data = b"actual blob content"
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))
file_io = FileIO.get(f"file://{tmp_dir}", {})
blob = Blob.from_bytes(descriptor.serialize(), file_io)
self.assertIsInstance(blob, BlobRef)
self.assertEqual(blob.to_data(), data)
def test_from_bytes_descriptor_uses_explicit_uri_reader_factory(self):
data = b"factory blob content"
descriptor = BlobDescriptor("custom://bucket/blob.bin", 0, len(data))
class RecordingUriReader:
def __init__(self):
self.opened_uris = []
def new_input_stream(self, uri):
self.opened_uris.append(uri)
return io.BytesIO(data)
class RecordingFactory:
def __init__(self, reader):
self.reader = reader
self.created_uris = []
def create(self, uri):
self.created_uris.append(uri)
return self.reader
class FactoryFileIO:
def __init__(self, factory):
self.uri_reader_factory = factory
def new_input_stream(self, path):
raise AssertionError("from_bytes should use the explicit uri_reader_factory")
reader = RecordingUriReader()
factory = RecordingFactory(reader)
blob = Blob.from_bytes(
descriptor.serialize(), FactoryFileIO(factory), uri_reader_factory=factory)
self.assertEqual(blob.to_data(), data)
self.assertEqual(factory.created_uris, [descriptor.uri])
self.assertEqual(reader.opened_uris, [descriptor.uri])
def test_from_bytes_descriptor_can_force_file_io_reader(self):
data = b"file backed blob content"
descriptor = BlobDescriptor("file-backed/blob.bin", 0, len(data))
class FailingFactory:
def create(self, uri):
raise AssertionError("Explicit uri_reader_factory=None should skip factory")
class FileBackedIO:
def __init__(self):
self.uri_reader_factory = FailingFactory()
self.opened_paths = []
def new_input_stream(self, path):
self.opened_paths.append(path)
return io.BytesIO(data)
file_io = FileBackedIO()
blob = Blob.from_bytes(
descriptor.serialize(), file_io, uri_reader_factory=None)
self.assertEqual(blob.to_data(), data)
self.assertEqual(file_io.opened_paths, [descriptor.uri])
def test_from_bytes_descriptor_without_file_io_raises(self):
descriptor = BlobDescriptor("/tmp/fake", 0, 10)
serialized = descriptor.serialize()
with self.assertRaises(ValueError):
Blob.from_bytes(serialized)
def test_from_bytes_invalid_type_raises(self):
with self.assertRaises(TypeError):
Blob.from_bytes(12345)
def test_blob_view_struct_roundtrip(self):
"""Test BlobViewStruct serialization compatibility."""
view_struct = BlobViewStruct("test_db.source_table", 7, 42)
serialized = view_struct.serialize()
self.assertTrue(BlobViewStruct.is_blob_view_struct(serialized))
self.assertFalse(BlobDescriptor.is_blob_descriptor(serialized))
restored = BlobViewStruct.deserialize(serialized)
self.assertEqual(restored, view_struct)
self.assertEqual(restored.identifier.get_full_name(), "test_db.source_table")
self.assertEqual(restored.field_id, 7)
self.assertEqual(restored.row_id, 42)
blob = Blob.from_bytes(view_struct.serialize())
self.assertIsInstance(blob, BlobView)
self.assertFalse(blob.is_resolved())
self.assertEqual(blob.view_struct, view_struct)
def test_blob_fallback_batch_reader_respects_batch_size(self):
created_readers = []
class DescriptorBlobFallbackBatchReader(BlobFallbackBatchReader):
def _resolve_selected_blobs(self, values):
raise AssertionError(
"Descriptor reads should not materialize BLOB data."
)
class FakeBlobReader:
def __init__(self):
self._file_io = None
self.file_path = "fake.blob"
self.blob_lengths = [20, 20, 20, 20, 20]
self.blob_offsets = [0, 100, 200, 300, 400]
self._input_stream = None
self.closed = False
def close(self):
self.closed = True
def supplier():
reader = FakeBlobReader()
created_readers.append(reader)
return reader
data_file = DataFileMeta(
file_name="fake.blob",
file_size=0,
row_count=5,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=0,
max_sequence_number=0,
schema_id=0,
level=0,
extra_files=[],
first_row_id=10,
file_path="fake.blob",
)
reader = DescriptorBlobFallbackBatchReader(
[(data_file, supplier)],
"picture",
pa.large_binary(),
blob_as_descriptor=True,
batch_size=2,
blob_parallelism=4,
)
first = reader.read_arrow_batch()
second = reader.read_arrow_batch()
third = reader.read_arrow_batch()
self.assertIsNone(reader.read_arrow_batch())
self.assertEqual(first.num_rows, 2)
self.assertEqual(second.num_rows, 2)
self.assertEqual(third.num_rows, 1)
offsets = []
for batch in (first, second, third):
offsets.extend(
BlobDescriptor.deserialize(value.as_py()).offset
for value in batch.column("picture")
)
self.assertEqual(offsets, [4, 104, 204, 304, 404])
self.assertEqual(1, len(created_readers))
self.assertFalse(created_readers[0].closed)
reader.close()
self.assertTrue(created_readers[0].closed)
def test_blob_fallback_batch_reader_reuses_version_readers(self):
created_by_file = {}
class FakeBlobReader:
def __init__(self, file_path, blob_lengths, blob_offsets):
self._file_io = None
self.file_path = file_path
self.blob_lengths = blob_lengths
self.blob_offsets = blob_offsets
self._input_stream = None
self.closed = False
def close(self):
self.closed = True
def data_file(name, max_sequence_number):
return DataFileMeta(
file_name=name,
file_size=0,
row_count=5,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=max_sequence_number,
max_sequence_number=max_sequence_number,
schema_id=0,
level=0,
extra_files=[],
first_row_id=0,
file_path=name,
)
def supplier(file_path, blob_lengths, blob_offsets):
def create_reader():
reader = FakeBlobReader(file_path, blob_lengths, blob_offsets)
created_by_file.setdefault(file_path, []).append(reader)
return reader
return create_reader
old_file = data_file("old.blob", 1)
new_file = data_file("new.blob", 2)
reader = BlobFallbackBatchReader(
[
(
old_file,
supplier(
"old.blob",
[20, 20, 20, 20, 20],
[0, 100, 200, 300, 400],
),
),
(
new_file,
supplier(
"new.blob",
[-2, 20, -2, 20, -2],
[-1, 1000, -1, 3000, -1],
),
),
],
"picture",
pa.large_binary(),
blob_as_descriptor=True,
batch_size=2,
)
offsets = []
batch = reader.read_arrow_batch()
while batch is not None:
offsets.extend(
BlobDescriptor.deserialize(value.as_py()).offset
for value in batch.column("picture")
)
batch = reader.read_arrow_batch()
self.assertEqual([4, 1004, 204, 3004, 404], offsets)
self.assertEqual(1, len(created_by_file["old.blob"]))
self.assertEqual(1, len(created_by_file["new.blob"]))
self.assertFalse(created_by_file["old.blob"][0].closed)
self.assertFalse(created_by_file["new.blob"][0].closed)
reader.close()
self.assertTrue(created_by_file["old.blob"][0].closed)
self.assertTrue(created_by_file["new.blob"][0].closed)
def test_blob_fallback_batch_reader_closes_exhausted_readers(self):
class FakeBlobReader:
def __init__(self, file_path, offset):
self._file_io = None
self.file_path = file_path
self.blob_lengths = [20]
self.blob_offsets = [offset]
self._input_stream = None
self.closed = False
def close(self):
self.closed = True
def data_file(name, first_row_id, max_sequence_number):
return DataFileMeta(
file_name=name,
file_size=0,
row_count=1,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=max_sequence_number,
max_sequence_number=max_sequence_number,
schema_id=0,
level=0,
extra_files=[],
first_row_id=first_row_id,
file_path=name,
)
def supplier(file_path, offset):
def create_reader():
reader = FakeBlobReader(file_path, offset)
created_by_file.setdefault(file_path, []).append(reader)
return reader
return create_reader
def descriptor_offsets(batch):
return [
BlobDescriptor.deserialize(value.as_py()).offset
for value in batch.column(0)
]
for batch_size in [1, 1024]:
with self.subTest(batch_size=batch_size):
created_by_file = {}
reader = BlobFallbackBatchReader(
[
(data_file("first.blob", 0, 1), supplier("first.blob", 0)),
(
data_file("second.blob", 10, 2),
supplier("second.blob", 100),
),
(
data_file("third.blob", 20, 3),
supplier("third.blob", 200),
),
],
"picture",
pa.large_binary(),
blob_as_descriptor=True,
batch_size=batch_size,
)
if batch_size == 1:
first = reader.read_arrow_batch()
self.assertEqual([4], descriptor_offsets(first))
self.assertFalse(created_by_file["first.blob"][0].closed)
second = reader.read_arrow_batch()
self.assertEqual([104], descriptor_offsets(second))
self.assertTrue(created_by_file["first.blob"][0].closed)
self.assertFalse(created_by_file["second.blob"][0].closed)
third = reader.read_arrow_batch()
self.assertEqual([204], descriptor_offsets(third))
self.assertTrue(created_by_file["second.blob"][0].closed)
self.assertFalse(created_by_file["third.blob"][0].closed)
else:
batch = reader.read_arrow_batch()
self.assertEqual([4, 104, 204], descriptor_offsets(batch))
self.assertTrue(created_by_file["first.blob"][0].closed)
self.assertTrue(created_by_file["second.blob"][0].closed)
self.assertFalse(created_by_file["third.blob"][0].closed)
self.assertIsNone(reader.read_arrow_batch())
self.assertEqual(1, len(created_by_file["first.blob"]))
self.assertEqual(1, len(created_by_file["second.blob"]))
self.assertEqual(1, len(created_by_file["third.blob"]))
reader.close()
self.assertTrue(created_by_file["third.blob"][0].closed)
def test_blob_fallback_batch_reader_materializes_selected_values_in_parallel(self):
class RecordingFileIO:
def __init__(self):
self.calls = []
def read_blobs_concurrent(self, blobs, parallelism):
descriptors = [blob.to_descriptor() for blob in blobs]
self.calls.append((descriptors, parallelism))
return [
"{}:{}".format(descriptor.uri, descriptor.offset).encode()
for descriptor in descriptors
]
class FakeBlobReader:
def __init__(self, file_io, file_path, blob_lengths, blob_offsets):
self._file_io = file_io
self.file_path = file_path
self.blob_lengths = blob_lengths
self.blob_offsets = blob_offsets
self._input_stream = None
def close(self):
pass
def data_file(name, max_sequence_number):
return DataFileMeta(
file_name=name,
file_size=0,
row_count=3,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=max_sequence_number,
max_sequence_number=max_sequence_number,
schema_id=0,
level=0,
extra_files=[],
first_row_id=0,
file_path=name,
)
file_io = RecordingFileIO()
old_file = data_file("old.blob", 1)
new_file = data_file("new.blob", 2)
reader = BlobFallbackBatchReader(
[
(
old_file,
lambda: FakeBlobReader(
file_io, "old.blob", [20, 20, 20], [0, 100, 200]
),
),
(
new_file,
lambda: FakeBlobReader(
file_io, "new.blob", [-2, 20, -2], [-1, 1000, -1]
),
),
],
"picture",
pa.large_binary(),
batch_size=3,
blob_parallelism=4,
)
batch = reader.read_arrow_batch()
self.assertEqual(
[b"old.blob:4", b"new.blob:1004", b"old.blob:204"],
batch.column("picture").to_pylist(),
)
self.assertEqual(1, len(file_io.calls))
descriptors, parallelism = file_io.calls[0]
self.assertEqual(4, parallelism)
self.assertEqual(
[("old.blob", 4), ("new.blob", 1004), ("old.blob", 204)],
[(descriptor.uri, descriptor.offset) for descriptor in descriptors],
)
def test_blob_fallback_batch_reader_materializes_selected_array_values_in_parallel(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
class RecordingFileIO(LocalFileIO):
def __init__(self, path, options):
super().__init__(path, options)
self.calls = []
def read_blobs_concurrent(self, blobs, parallelism):
self.calls.append((
[blob.to_descriptor() for blob in blobs],
parallelism,
))
return super().read_blobs_concurrent(blobs, parallelism)
field = DataField(
0,
"pictures",
ArrayType(True, AtomicType("BLOB")),
)
file_io = RecordingFileIO(self.temp_dir, Options({}))
def write_blob_file(name, values):
path = os.path.join(self.temp_dir, name)
with open(path, 'wb') as output:
writer = BlobFormatWriter(output)
for value in values:
writer.add_element(
GenericRow([value], [field], RowKind.INSERT)
)
writer.close()
return path
old_path = write_blob_file(
"old-array.blob",
[
[BlobData(b"old-0"), None],
[BlobData(b"old-1")],
[BlobData(b"old-2a"), BlobData(b"old-2b")],
],
)
new_path = write_blob_file(
"new-array.blob",
[
Blob.ARRAY_PLACE_HOLDER,
[BlobData(b"new-1a"), None, BlobData(b"new-1b")],
Blob.ARRAY_PLACE_HOLDER,
],
)
def data_file(path, max_sequence_number):
return DataFileMeta(
file_name=os.path.basename(path),
file_size=os.path.getsize(path),
row_count=3,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=max_sequence_number,
max_sequence_number=max_sequence_number,
schema_id=0,
level=0,
extra_files=[],
first_row_id=0,
file_path=path,
)
def supplier(path):
return lambda: FormatBlobReader(
file_io=file_io,
file_path=path,
read_fields=[field.name],
full_fields=[field],
push_down_predicate=None,
blob_as_descriptor=False,
blob_parallelism=4,
)
reader = BlobFallbackBatchReader(
[
(data_file(old_path, 1), supplier(old_path)),
(data_file(new_path, 2), supplier(new_path)),
],
field.name,
pa.list_(pa.large_binary()),
batch_size=3,
blob_parallelism=4,
)
try:
batch = reader.read_arrow_batch()
self.assertEqual(
[
[b"old-0", None],
[b"new-1a", None, b"new-1b"],
[b"old-2a", b"old-2b"],
],
batch.column(field.name).to_pylist(),
)
self.assertIsNone(reader.read_arrow_batch())
finally:
reader.close()
self.assertEqual(1, len(file_io.calls))
descriptors, parallelism = file_io.calls[0]
self.assertEqual(4, parallelism)
self.assertEqual(5, len(descriptors))
self.assertEqual(
[old_path, new_path, new_path, old_path, old_path],
[descriptor.uri for descriptor in descriptors],
)
def test_blob_fallback_batch_reader_materializes_selected_map_values_in_parallel(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
class RecordingFileIO(LocalFileIO):
def __init__(self, path, options):
super().__init__(path, options)
self.calls = []
def read_blobs_concurrent(self, blobs, parallelism):
self.calls.append((
[blob.to_descriptor() for blob in blobs],
parallelism,
))
return super().read_blobs_concurrent(blobs, parallelism)
field = DataField(
0,
"pictures",
MapType(True, AtomicType("STRING", False), AtomicType("BLOB")),
)
file_io = RecordingFileIO(self.temp_dir, Options({}))
def write_blob_file(name, values):
path = os.path.join(self.temp_dir, name)
with open(path, 'wb') as output:
writer = BlobFormatWriter(output)
for value in values:
writer.add_element(
GenericRow([value], [field], RowKind.INSERT)
)
writer.close()
return path
old_path = write_blob_file(
"old-map.blob",
[
{"a": BlobData(b"old-0"), "null": None},
{"a": BlobData(b"old-1")},
{"a": BlobData(b"old-2a"), "b": BlobData(b"old-2b")},
],
)
new_path = write_blob_file(
"new-map.blob",
[
Blob.MAP_PLACE_HOLDER,
{"a": BlobData(b"new-1a"), "b": None, "c": BlobData(b"new-1c")},
Blob.MAP_PLACE_HOLDER,
],
)
def data_file(path, max_sequence_number):
return DataFileMeta(
file_name=os.path.basename(path),
file_size=os.path.getsize(path),
row_count=3,
min_key=None,
max_key=None,
key_stats=None,
value_stats=None,
min_sequence_number=max_sequence_number,
max_sequence_number=max_sequence_number,
schema_id=0,
level=0,
extra_files=[],
first_row_id=0,
file_path=path,
)
def supplier(path):
return lambda: FormatBlobReader(
file_io=file_io,
file_path=path,
read_fields=[field.name],
full_fields=[field],
push_down_predicate=None,
blob_as_descriptor=False,
blob_parallelism=4,
)
reader = BlobFallbackBatchReader(
[
(data_file(old_path, 1), supplier(old_path)),
(data_file(new_path, 2), supplier(new_path)),
],
field.name,
pa.map_(pa.string(), pa.large_binary()),
batch_size=3,
blob_parallelism=4,
)
self.assertEqual(
[("value", b"body"), ("null", None)],
reader._map_value_for_arrow({
"value": BlobData(b"body"),
"null": None,
}),
)
try:
batch = reader.read_arrow_batch()
self.assertEqual(
[
{"a": b"old-0", "null": None},
{"a": b"new-1a", "b": None, "c": b"new-1c"},
{"a": b"old-2a", "b": b"old-2b"},
],
[dict(value) for value in batch.column(field.name).to_pylist()],
)
self.assertIsNone(reader.read_arrow_batch())
finally:
reader.close()
self.assertEqual(1, len(file_io.calls))
descriptors, parallelism = file_io.calls[0]
self.assertEqual(4, parallelism)
self.assertEqual(5, len(descriptors))
self.assertEqual(
[old_path, new_path, new_path, old_path, old_path],
[descriptor.uri for descriptor in descriptors],
)
def test_blob_data_interface_compliance(self):
"""Test that BlobData properly implements Blob interface."""
test_data = b"interface test data"
blob_data = BlobData(test_data)
# Test that it's a Blob
self.assertIsInstance(blob_data, Blob)
# Test interface methods
self.assertEqual(blob_data.to_data(), test_data)
# Test to_descriptor raises RuntimeError
with self.assertRaises(RuntimeError) as context:
blob_data.to_descriptor()
self.assertIn("Blob data can not convert to descriptor", str(context.exception))
# Test new_input_stream
stream = blob_data.new_input_stream()
self.assertEqual(stream.read(), test_data)
stream.close()
def test_blob_ref_interface_compliance(self):
"""Test that BlobRef properly implements Blob interface."""
blob_ref = Blob.from_local(self.file)
# Test that it's a Blob
self.assertIsInstance(blob_ref, Blob)
# Test interface methods
self.assertEqual(blob_ref.to_data(), b"test data")
# Test to_descriptor returns valid descriptor
descriptor = blob_ref.to_descriptor()
self.assertEqual(descriptor.uri, self.file)
self.assertEqual(descriptor.offset, 0)
self.assertEqual(descriptor.length, -1)
# Test new_input_stream
stream = blob_ref.new_input_stream()
self.assertEqual(stream.read(), b"test data")
stream.close()
def test_blob_equality_and_hashing(self):
"""Test blob equality and hashing behavior."""
# Test BlobData equality
data1 = BlobData(b"same data")
data2 = BlobData(b"same data")
data3 = BlobData(b"different data")
self.assertEqual(data1, data2)
self.assertNotEqual(data1, data3)
self.assertEqual(hash(data1), hash(data2))
# Test BlobRef equality
ref1 = Blob.from_local(self.file)
ref2 = Blob.from_local(self.file)
self.assertEqual(ref1, ref2)
self.assertEqual(hash(ref1), hash(ref2))
def test_blob_factory_methods_return_correct_types(self):
"""Test that all factory methods return the expected types."""
# from_data should return BlobData
blob_data = Blob.from_data(b"test")
self.assertIsInstance(blob_data, BlobData)
self.assertIsInstance(blob_data, Blob)
# from_local should return BlobRef
blob_ref = Blob.from_local(self.file)
self.assertIsInstance(blob_ref, BlobRef)
self.assertIsInstance(blob_ref, Blob)
# from_file should return BlobRef
file_io = LocalFileIO(self.file if self.file.startswith('file://') else f"file://{self.file}", Options({}))
blob_file = Blob.from_file(file_io, self.file, 0, os.path.getsize(self.file))
self.assertIsInstance(blob_file, BlobRef)
self.assertIsInstance(blob_file, Blob)
# from_http should return BlobRef
blob_http = Blob.from_http("http://example.com/test.bin")
self.assertIsInstance(blob_http, BlobRef)
self.assertIsInstance(blob_http, Blob)
def test_blob_data_convenience_methods(self):
# Test from_bytes class method
blob2 = BlobData.from_bytes(b"from bytes")
self.assertEqual(blob2.to_data(), b"from bytes")
def test_generic_row_deserializer_parse_blob(self):
"""Test GenericRowDeserializer._parse_blob method."""
# Create test data with BLOB field
test_blob_data = b"Test BLOB data for parsing"
blob_data = BlobData(test_blob_data)
# Create fields with BLOB type
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "blob_field", AtomicType("BLOB")),
]
# Create and serialize a row with blob data
original_row = GenericRow([42, blob_data], fields, RowKind.INSERT)
serialized_bytes = GenericRowSerializer.to_bytes(original_row)
# Test the full deserialization process (which uses _parse_blob internally)
deserialized_row = GenericRowDeserializer.from_bytes(serialized_bytes, fields)
# Verify the deserialized blob
deserialized_blob = deserialized_row.values[1]
self.assertIsInstance(deserialized_blob, BlobData)
self.assertEqual(deserialized_blob.to_data(), test_blob_data)
# Test with empty blob data
empty_blob = BlobData(b"")
empty_row = GenericRow([1, empty_blob], fields, RowKind.INSERT)
empty_serialized = GenericRowSerializer.to_bytes(empty_row)
empty_deserialized = GenericRowDeserializer.from_bytes(empty_serialized, fields)
empty_deserialized_blob = empty_deserialized.values[1]
self.assertIsInstance(empty_deserialized_blob, BlobData)
self.assertEqual(empty_deserialized_blob.to_data(), b"")
# Test with binary data containing null bytes
binary_blob_data = b"\x00\x01\x02\x03\xff\xfe\xfd"
binary_blob = BlobData(binary_blob_data)
binary_row = GenericRow([99, binary_blob], fields, RowKind.INSERT)
binary_serialized = GenericRowSerializer.to_bytes(binary_row)
binary_deserialized = GenericRowDeserializer.from_bytes(binary_serialized, fields)
binary_deserialized_blob = binary_deserialized.values[1]
self.assertIsInstance(binary_deserialized_blob, BlobData)
self.assertEqual(binary_deserialized_blob.to_data(), binary_blob_data)
def test_generic_row_deserializer_parse_blob_with_multiple_fields(self):
"""Test _parse_blob with multiple BLOB fields in a row."""
# Create test data with multiple BLOB fields
blob1_data = b"First BLOB data"
blob2_data = b"Second BLOB with different content"
blob3_data = b"" # Empty blob
blob1 = BlobData(blob1_data)
blob2 = BlobData(blob2_data)
blob3 = BlobData(blob3_data)
# Create fields with multiple BLOB types
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING")),
DataField(2, "blob1", AtomicType("BLOB")),
DataField(3, "blob2", AtomicType("BLOB")),
DataField(4, "blob3", AtomicType("BLOB")),
]
# Create and serialize a row with multiple blobs
original_row = GenericRow([123, "test_row", blob1, blob2, blob3], fields, RowKind.INSERT)
serialized_bytes = GenericRowSerializer.to_bytes(original_row)
# Deserialize and verify all blobs
deserialized_row = GenericRowDeserializer.from_bytes(serialized_bytes, fields)
# Verify each blob field
self.assertEqual(deserialized_row.values[0], 123)
self.assertEqual(deserialized_row.values[1], "test_row")
deserialized_blob1 = deserialized_row.values[2]
self.assertIsInstance(deserialized_blob1, BlobData)
self.assertEqual(deserialized_blob1.to_data(), blob1_data)
deserialized_blob2 = deserialized_row.values[3]
self.assertIsInstance(deserialized_blob2, BlobData)
self.assertEqual(deserialized_blob2.to_data(), blob2_data)
deserialized_blob3 = deserialized_row.values[4]
self.assertIsInstance(deserialized_blob3, BlobData)
self.assertEqual(deserialized_blob3.to_data(), blob3_data)
def test_generic_row_deserializer_parse_blob_with_null_values(self):
"""Test _parse_blob with null BLOB values."""
# Create fields with BLOB type
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "blob_field", AtomicType("BLOB")),
DataField(2, "name", AtomicType("STRING")),
]
# Create row with null blob (None value)
original_row = GenericRow([456, None, "test_with_null"], fields, RowKind.INSERT)
serialized_bytes = GenericRowSerializer.to_bytes(original_row)
# Deserialize and verify null blob is handled correctly
deserialized_row = GenericRowDeserializer.from_bytes(serialized_bytes, fields)
self.assertEqual(deserialized_row.values[0], 456)
self.assertIsNone(deserialized_row.values[1]) # Null blob should remain None
self.assertEqual(deserialized_row.values[2], "test_with_null")
def test_generic_row_deserializer_parse_blob_large_data(self):
"""Test _parse_blob with large BLOB data."""
# Create large blob data (1MB)
large_blob_data = b"X" * (1024 * 1024) # 1MB of 'X' characters
large_blob = BlobData(large_blob_data)
fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "large_blob", AtomicType("BLOB")),
]
# Create and serialize row with large blob
original_row = GenericRow([789, large_blob], fields, RowKind.INSERT)
serialized_bytes = GenericRowSerializer.to_bytes(original_row)
# Deserialize and verify large blob
deserialized_row = GenericRowDeserializer.from_bytes(serialized_bytes, fields)
deserialized_large_blob = deserialized_row.values[1]
self.assertIsInstance(deserialized_large_blob, BlobData)
self.assertEqual(len(deserialized_large_blob.to_data()), 1024 * 1024)
self.assertEqual(deserialized_large_blob.to_data(), large_blob_data)
def test_blob_descriptor_creation(self):
"""Test BlobDescriptor creation and properties."""
# Test basic creation
descriptor = BlobDescriptor("test://example.uri", 100, 200)
self.assertEqual(descriptor.uri, "test://example.uri")
self.assertEqual(descriptor.offset, 100)
self.assertEqual(descriptor.length, 200)
self.assertEqual(descriptor.version, BlobDescriptor.CURRENT_VERSION)
def test_blob_descriptor_creation_without_version_arg(self):
"""Test BlobDescriptor creation without explicit version argument."""
descriptor = BlobDescriptor("test://example.uri", 50, 150)
self.assertEqual(descriptor.uri, "test://example.uri")
self.assertEqual(descriptor.offset, 50)
self.assertEqual(descriptor.length, 150)
self.assertEqual(descriptor.version, 2)
def test_blob_descriptor_serialization_deserialization(self):
"""Test BlobDescriptor serialization and deserialization."""
# Test with various URIs and parameters
test_cases = [
("file:///path/to/file.bin", 0, -1),
("https://example.com/data.blob", 1024, 2048),
("s3://bucket/key", 0, 1000000),
("test://simple", 42, 84),
]
for uri, offset, length in test_cases:
with self.subTest(uri=uri, offset=offset, length=length):
# Create original descriptor
original = BlobDescriptor(uri, offset, length)
# Serialize
serialized = original.serialize()
self.assertIsInstance(serialized, bytes)
self.assertGreater(len(serialized), 0)
# Deserialize
deserialized = BlobDescriptor.deserialize(serialized)
# Verify equality
self.assertEqual(deserialized, original)
self.assertEqual(deserialized.uri, uri)
self.assertEqual(deserialized.offset, offset)
self.assertEqual(deserialized.length, length)
self.assertEqual(deserialized.version, BlobDescriptor.CURRENT_VERSION)
def test_blob_descriptor_serialization_with_unicode(self):
"""Test BlobDescriptor serialization with Unicode characters."""
# Test with Unicode characters in URI
unicode_uri = "file:///测试/文件.bin"
descriptor = BlobDescriptor(unicode_uri, 0, 100)
# Serialize and deserialize
serialized = descriptor.serialize()
deserialized = BlobDescriptor.deserialize(serialized)
# Verify Unicode is preserved
self.assertEqual(deserialized.uri, unicode_uri)
self.assertEqual(deserialized, descriptor)
def test_blob_descriptor_deserialization_invalid_data(self):
"""Test BlobDescriptor deserialization with invalid data."""
# Test with too short data
with self.assertRaises(ValueError) as context:
BlobDescriptor.deserialize(b"sho")
self.assertIn("too short", str(context.exception))
# Test with unsupported version (> current version)
valid_descriptor = BlobDescriptor("test://uri", 0, 100)
valid_data = bytearray(valid_descriptor.serialize())
valid_data[0] = 3 # Set unsupported version
with self.assertRaises(ValueError) as context:
BlobDescriptor.deserialize(bytes(valid_data))
self.assertIn("less than or equal to 2, but found 3", str(context.exception))
# Test with invalid magic for version 2 descriptor
invalid_magic_data = bytearray(valid_descriptor.serialize())
invalid_magic_data[1:9] = b"\x00" * 8
with self.assertRaises(ValueError) as context:
BlobDescriptor.deserialize(bytes(invalid_magic_data))
self.assertIn("missing magic header", str(context.exception))
# Test with incomplete data (missing URI bytes)
incomplete_data = b'\x01\x00\x00\x00\x10' # Version 1, URI length 16, but no URI bytes
with self.assertRaises(ValueError) as context:
BlobDescriptor.deserialize(incomplete_data)
self.assertIn("URI length exceeds data size", str(context.exception))
def test_blob_descriptor_equality_and_hashing(self):
"""Test BlobDescriptor equality and hashing."""
# Create identical descriptors
desc1 = BlobDescriptor("test://uri", 100, 200)
desc2 = BlobDescriptor("test://uri", 100, 200)
desc3 = BlobDescriptor("test://uri", 100, 201) # Different length
desc4 = BlobDescriptor("test://other", 100, 200) # Different URI
# Test equality
self.assertEqual(desc1, desc2)
self.assertNotEqual(desc1, desc3)
self.assertNotEqual(desc1, desc4)
self.assertNotEqual(desc1, None)
self.assertNotEqual(desc1, "not a descriptor")
# Test hashing
self.assertEqual(hash(desc1), hash(desc2))
# Hash should be different for different descriptors (though not guaranteed)
self.assertNotEqual(hash(desc1), hash(desc3))
self.assertNotEqual(hash(desc1), hash(desc4))
def test_blob_descriptor_string_representation(self):
"""Test BlobDescriptor string representation."""
descriptor = BlobDescriptor("test://example.uri", 42, 84)
str_repr = str(descriptor)
self.assertIn("test://example.uri", str_repr)
self.assertIn("42", str_repr)
self.assertIn("84", str_repr)
self.assertIn("BlobDescriptor", str_repr)
# __repr__ should be the same as __str__
self.assertEqual(str_repr, repr(descriptor))
def test_blob_descriptor_version_handling(self):
"""Test BlobDescriptor version handling."""
# Test current version
descriptor = BlobDescriptor("test://uri", 0, 100)
self.assertEqual(descriptor.version, BlobDescriptor.CURRENT_VERSION)
# Serialize and deserialize should preserve version
serialized = descriptor.serialize()
deserialized = BlobDescriptor.deserialize(serialized)
self.assertEqual(deserialized.version, 2)
# v1 payloads should remain deserializable for compatibility
uri = b"test://uri"
serialized_v1 = (
bytes([1])
+ struct.pack('<I', len(uri))
+ uri
+ struct.pack('<q', 0)
+ struct.pack('<q', 100)
)
deserialized_v1 = BlobDescriptor.deserialize(serialized_v1)
self.assertEqual(deserialized_v1.version, 1)
self.assertEqual(deserialized_v1.uri, "test://uri")
self.assertEqual(deserialized_v1.offset, 0)
self.assertEqual(deserialized_v1.length, 100)
def test_blob_descriptor_edge_cases(self):
"""Test BlobDescriptor with edge cases."""
# Test with empty URI
empty_uri_desc = BlobDescriptor("", 0, 0)
serialized = empty_uri_desc.serialize()
deserialized = BlobDescriptor.deserialize(serialized)
self.assertEqual(deserialized.uri, "")
# Test with very long URI
long_uri = "file://" + "a" * 1000 + "/file.bin"
long_uri_desc = BlobDescriptor(long_uri, 0, 1000000)
serialized = long_uri_desc.serialize()
deserialized = BlobDescriptor.deserialize(serialized)
self.assertEqual(deserialized.uri, long_uri)
# Test with negative values
negative_desc = BlobDescriptor("test://uri", -1, -1)
serialized = negative_desc.serialize()
deserialized = BlobDescriptor.deserialize(serialized)
self.assertEqual(deserialized.offset, -1)
self.assertEqual(deserialized.length, -1)
def test_blob_descriptor_with_blob_ref(self):
"""Test BlobDescriptor integration with BlobRef."""
# Create a descriptor
descriptor = BlobDescriptor(self.file, 0, -1)
# Create BlobRef from descriptor
blob_ref = Blob.from_local(self.file)
# Verify descriptor is preserved
returned_descriptor = blob_ref.to_descriptor()
self.assertEqual(returned_descriptor, descriptor)
# Verify data can be read through BlobRef
data = blob_ref.to_data()
self.assertEqual(data, b"test data")
def test_blob_descriptor_serialization_format(self):
"""Test BlobDescriptor serialization format details."""
descriptor = BlobDescriptor("test", 12345, 67890)
serialized = descriptor.serialize()
# Check that serialized data starts with version byte
self.assertEqual(serialized[0], BlobDescriptor.CURRENT_VERSION)
# Check minimum length (version + magic + uri_length + uri + offset + length)
# 1 + 8 + 4 + len("test") + 8 + 8 = 33 bytes
self.assertEqual(len(serialized), 33)
# Verify round-trip consistency
deserialized = BlobDescriptor.deserialize(serialized)
re_serialized = deserialized.serialize()
self.assertEqual(serialized, re_serialized)
def test_blob_descriptor_detection(self):
import struct
descriptor_v2 = BlobDescriptor("test://uri", 1, 2)
uri = b"test://uri"
descriptor_v1_bytes = (
bytes([1])
+ struct.pack('<I', len(uri))
+ uri
+ struct.pack('<q', 1)
+ struct.pack('<q', 2)
)
random_bytes = b"not-a-descriptor"
fake_v1_prefix = b"\x01not-a-descriptor"
v2_magic_only = bytes([2]) + struct.pack('<Q', BlobDescriptor.MAGIC)
self.assertTrue(BlobDescriptor.is_blob_descriptor(descriptor_v2.serialize()))
# v1 descriptors are supported for deserialization, but detection only checks v2 magic.
self.assertFalse(BlobDescriptor.is_blob_descriptor(descriptor_v1_bytes))
self.assertTrue(BlobDescriptor.is_blob_descriptor(v2_magic_only))
self.assertFalse(BlobDescriptor.is_blob_descriptor(random_bytes))
self.assertFalse(BlobDescriptor.is_blob_descriptor(fake_v1_prefix))
self.assertFalse(BlobDescriptor.is_blob_descriptor(b"tiny"))
class BlobEndToEndTest(unittest.TestCase):
"""End-to-end tests for blob functionality with schema definition, file writing, and reading."""
def setUp(self):
"""Set up test environment."""
self.temp_dir = tempfile.mkdtemp()
self.warehouse = os.path.join(self.temp_dir, 'warehouse')
# Create catalog for table operations
self.catalog = CatalogFactory.create({
'warehouse': self.warehouse
})
self.catalog.create_database('test_db', False)
def tearDown(self):
"""Clean up test environment."""
try:
shutil.rmtree(self.temp_dir)
except OSError:
pass
def test_blob_end_to_end(self):
# Set up file I/O
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_field_name = "blob_field"
# ========== Step 1: Check Type Validation ==========
blob_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
for blob_field in blob_fields:
self.assertIsInstance(blob_field.type, AtomicType)
self.assertEqual(blob_field.type.type, "BLOB")
# ========== Step 2: Write Data ==========
test_data = {blob_field_name: BlobData(b'End-to-end test: PDF header %PDF-1.4\n...')}
blob_files = {}
blob_data = [test_data[blob_field_name].to_data()]
schema = pa.schema([pa.field(blob_field_name, pa.large_binary())])
table = pa.table([blob_data], schema=schema)
blob_file_path = Path(self.temp_dir) / (blob_field_name + ".blob")
blob_file_url = _to_url(blob_file_path)
blob_files[blob_field_name] = blob_file_url
file_io.write_blob(blob_file_url, table)
self.assertTrue(file_io.exists(blob_file_url))
# ========== Step 3: Read Data and Check Data ==========
for field_name, file_path in blob_files.items():
read_fields = blob_fields
reader = FormatBlobReader(
file_io=file_io,
file_path=str(file_path),
read_fields=[field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
# Read data
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch, f"{field_name} batch should not be None")
self.assertEqual(batch.num_rows, 1, f"{field_name} should have 1 row")
# Verify data integrity
read_blob_data = batch.column(0)[0].as_py()
expected_blob_data = test_data[field_name].to_data()
self.assertEqual(read_blob_data, expected_blob_data, f"{field_name} data should match")
reader.close()
def test_blob_read_inline_bytes_reuses_reader_stream(self):
class CountingFileIO:
def __init__(self, delegate):
self._delegate = delegate
self.input_stream_count = 0
def __getattr__(self, name):
return getattr(self._delegate, name)
def new_input_stream(self, path):
self.input_stream_count += 1
return self._delegate.new_input_stream(path)
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_field_name = "blob_field"
blob_data = [b"hello", b"world"]
schema = pa.schema([pa.field(blob_field_name, pa.large_binary())])
table = pa.table([blob_data], schema=schema)
blob_file_path = Path(self.temp_dir) / (blob_field_name + "_inline.blob")
blob_file_url = _to_url(blob_file_path)
file_io.write_blob(blob_file_url, table)
counting_file_io = CountingFileIO(file_io)
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=counting_file_io,
file_path=str(blob_file_path),
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.num_rows, 2)
self.assertEqual(batch.column(0)[0].as_py(), b"hello")
self.assertEqual(batch.column(0)[1].as_py(), b"world")
self.assertEqual(counting_file_io.input_stream_count, 1)
reader.close()
def test_blob_reader_row_indices_pushdown(self):
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_field_name = "blob_field"
blob_data = [f"value_{i}".encode("utf-8") for i in range(6)]
schema = pa.schema([pa.field(blob_field_name, pa.large_binary())])
table = pa.table([blob_data], schema=schema)
blob_file_path = Path(self.temp_dir) / "row_indices.blob"
blob_file_url = _to_url(blob_file_path)
file_io.write_blob(blob_file_url, table)
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False,
batch_size=2,
row_indices=[1, 3, 4],
)
try:
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.column(0).to_pylist(), [blob_data[1], blob_data[3]])
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.column(0).to_pylist(), [blob_data[4]])
self.assertIsNone(reader.read_arrow_batch())
finally:
reader.close()
def test_blob_reader_row_indices_out_of_range(self):
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_field_name = "blob_field"
blob_data = [b"value_0", b"value_1"]
schema = pa.schema([pa.field(blob_field_name, pa.large_binary())])
table = pa.table([blob_data], schema=schema)
blob_file_path = Path(self.temp_dir) / "row_indices_out_of_range.blob"
blob_file_url = _to_url(blob_file_path)
file_io.write_blob(blob_file_url, table)
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
with self.assertRaises(IndexError) as context:
FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False,
row_indices=[0, 2],
)
self.assertIn("Blob row index 2 is out of range", str(context.exception))
def test_blob_complex_types_throw_exception(self):
"""Test that complex types containing BLOB elements throw exceptions during read/write operations."""
from pypaimon.schema.data_types import DataField, AtomicType, ArrayType, MultisetType, MapType
from pypaimon.table.row.blob import BlobData
from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
from pypaimon.table.row.row_kind import RowKind
# Set up file I/O
file_io = LocalFileIO(self.temp_dir, Options({}))
# ========== Test ArrayType(nullable=True, element_type=AtomicType("BLOB")) ==========
array_fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "blob_array", ArrayType(nullable=True, element_type=AtomicType("BLOB"))),
]
# Test serialization throws exception for ArrayType<BLOB>
array_blob_data = [
BlobData(b"Array blob 1"),
BlobData(b"Array blob 2"),
BlobData(b"Array blob 3")
]
array_row = GenericRow([1, array_blob_data], array_fields, RowKind.INSERT)
# GenericRowSerializer should throw exception for complex types
with self.assertRaises(ValueError) as context:
GenericRowSerializer.to_bytes(array_row)
self.assertIn("AtomicType", str(context.exception))
# Note: FileIO.write_blob validation for complex types is tested separately below
# ========== Test MultisetType(nullable=True, element_type=AtomicType("BLOB")) ==========
multiset_fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "blob_multiset", MultisetType(nullable=True, element_type=AtomicType("BLOB"))),
]
# Test serialization throws exception for MultisetType<BLOB>
multiset_blob_data = [
BlobData(b"Multiset blob 1"),
BlobData(b"Multiset blob 2"),
BlobData(b"Multiset blob 1"), # Duplicate allowed in multiset
]
multiset_row = GenericRow([2, multiset_blob_data], multiset_fields, RowKind.INSERT)
# GenericRowSerializer should throw exception for complex types
with self.assertRaises(ValueError) as context:
GenericRowSerializer.to_bytes(multiset_row)
self.assertIn("AtomicType", str(context.exception))
map_fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "blob_map", MapType(
nullable=True, key_type=AtomicType("STRING"), value_type=AtomicType("BLOB")
)),
]
# Test serialization throws exception for MapType<STRING, BLOB>
map_blob_data = {
"document": BlobData(b"Document content"),
"image": BlobData(b"Image data"),
"metadata": BlobData(b"Metadata content")
}
map_row = GenericRow([3, map_blob_data], map_fields, RowKind.INSERT)
# GenericRowSerializer should throw exception for complex types
with self.assertRaises(ValueError) as context:
GenericRowSerializer.to_bytes(map_row)
self.assertIn("AtomicType", str(context.exception))
# ========== Test FileIO.write_blob validation for complex types ==========
# Test that FileIO.write_blob properly validates and rejects complex types
# Create a table with multiple columns (should fail - blob format requires single column)
multi_column_schema = pa.schema([
pa.field("blob1", pa.large_binary()),
pa.field("blob2", pa.large_binary())
])
multi_column_table = pa.table([
[b"blob1_data"],
[b"blob2_data"]
], schema=multi_column_schema)
multi_column_file = Path(self.temp_dir) / "multi_column.blob"
multi_column_url = _to_url(multi_column_file)
# Should throw RuntimeError for multiple columns
with self.assertRaises(RuntimeError) as context:
file_io.write_blob(multi_column_url, multi_column_table)
self.assertIn("single column", str(context.exception))
# Test that FileIO.write_blob supports null values and round-trips correctly
null_schema = pa.schema([pa.field("blob_with_nulls", pa.large_binary())])
null_table = pa.table([[b"data", None]], schema=null_schema)
null_file = Path(self.temp_dir) / "null_data.blob"
null_file_url = _to_url(null_file)
file_io.write_blob(null_file_url, null_table)
null_read_fields = [DataField(0, "blob_with_nulls", AtomicType("BLOB"))]
null_reader = FormatBlobReader(
file_io=file_io,
file_path=str(null_file),
read_fields=["blob_with_nulls"],
full_fields=null_read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
null_batch = null_reader.read_arrow_batch()
self.assertEqual(null_batch.num_rows, 2)
self.assertEqual(null_batch.column(0)[0].as_py(), b"data")
self.assertIsNone(null_batch.column(0)[1].as_py())
null_reader.close()
# ========== Test FormatBlobReader with complex type schema ==========
# Create a valid blob file first
valid_blob_data = [b"Valid blob content"]
valid_schema = pa.schema([pa.field("valid_blob", pa.large_binary())])
valid_table = pa.table([valid_blob_data], schema=valid_schema)
valid_blob_file = Path(self.temp_dir) / "valid_blob.blob"
valid_blob_url = _to_url(valid_blob_file)
file_io.write_blob(valid_blob_url, valid_table)
# Try to read with complex type field definition - this should fail
# because FormatBlobReader tries to create PyArrow schema with complex types
complex_read_fields = [
DataField(0, "valid_blob", ArrayType(nullable=True, element_type=AtomicType("BLOB")))
]
# FormatBlobReader creation should work, but reading should fail due to schema mismatch
reader = FormatBlobReader(
file_io=file_io,
file_path=str(valid_blob_file),
read_fields=["valid_blob"],
full_fields=complex_read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
# Reading should fail because the schema expects complex type but data is atomic
with self.assertRaises(Exception) as context:
reader.read_arrow_batch()
# The error could be ArrowTypeError or other PyArrow-related errors
self.assertTrue(
"ArrowTypeError" in str(type(context.exception)) or
"TypeError" in str(type(context.exception)) or
"ValueError" in str(type(context.exception))
)
reader.close()
def test_blob_advanced_scenarios(self):
"""Test advanced blob scenarios: corruption, truncation, zero-length, large blobs, compression, cross-format."""
from pypaimon.schema.data_types import DataField, AtomicType
from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
# Set up file I/O
file_io = LocalFileIO(self.temp_dir, Options({}))
# ========== Test 1: Corrupted file header test ==========
# Create a valid blob file first
valid_blob_data = [b"Test blob content for corruption test"]
valid_schema = pa.schema([pa.field("test_blob", pa.large_binary())])
valid_table = pa.table([valid_blob_data], schema=valid_schema)
header_test_file = Path(self.temp_dir) / "header_test.blob"
header_test_url = _to_url(header_test_file)
file_io.write_blob(header_test_url, valid_table)
# Read the file and corrupt the header (last 5 bytes: index_length + version)
with open(header_test_file, 'rb') as f:
original_data = f.read()
# Corrupt the version byte (last byte)
corrupted_data = bytearray(original_data)
corrupted_data[-1] = 99 # Invalid version (should be 1)
corrupted_header_file = Path(self.temp_dir) / "corrupted_header.blob"
with open(corrupted_header_file, 'wb') as f:
f.write(corrupted_data)
# Try to read corrupted file - should detect invalid version
fields = [DataField(0, "test_blob", AtomicType("BLOB"))]
# Reading should fail due to invalid version
with self.assertRaises(IOError) as context:
FormatBlobReader(
file_io=file_io,
file_path=str(corrupted_header_file),
read_fields=["test_blob"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False
)
self.assertIn("Unsupported blob file version", str(context.exception))
# ========== Test 2: Truncated blob file (mid-blob) read ==========
# Create a blob file with substantial content
large_content = b"Large blob content: " + b"X" * 1000 + b" End of content"
large_blob_data = [large_content]
large_schema = pa.schema([pa.field("large_blob", pa.large_binary())])
large_table = pa.table([large_blob_data], schema=large_schema)
full_blob_file = Path(self.temp_dir) / "full_blob.blob"
full_blob_url = _to_url(full_blob_file)
file_io.write_blob(full_blob_url, large_table)
# Read the full file and truncate it in the middle
with open(full_blob_file, 'rb') as f:
full_data = f.read()
# Truncate to about 50% of original size (mid-blob)
truncated_size = len(full_data) // 2
truncated_data = full_data[:truncated_size]
truncated_file = Path(self.temp_dir) / "truncated.blob"
with open(truncated_file, 'wb') as f:
f.write(truncated_data)
# Try to read truncated file - should fail gracefully
with self.assertRaises((IOError, OSError)) as context:
FormatBlobReader(
file_io=file_io,
file_path=str(truncated_file),
read_fields=["large_blob"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False
)
# Should detect truncation/incomplete data (either invalid header or invalid version)
self.assertTrue(
"cannot read header" in str(context.exception) or
"Unsupported blob file version" in str(context.exception)
)
# ========== Test 3: Zero-length blob handling ==========
# Create blob with zero-length content
zero_blob_data = [b""] # Empty blob
zero_schema = pa.schema([pa.field("zero_blob", pa.large_binary())])
zero_table = pa.table([zero_blob_data], schema=zero_schema)
zero_blob_file = Path(self.temp_dir) / "zero_length.blob"
zero_blob_url = _to_url(zero_blob_file)
file_io.write_blob(zero_blob_url, zero_table)
# Verify file was created
self.assertTrue(file_io.exists(zero_blob_url))
file_size = file_io.get_file_size(zero_blob_url)
self.assertGreater(file_size, 0) # File should have headers even with empty blob
# Read zero-length blob
zero_fields = [DataField(0, "zero_blob", AtomicType("BLOB"))]
zero_reader = FormatBlobReader(
file_io=file_io,
file_path=str(zero_blob_file),
read_fields=["zero_blob"],
full_fields=zero_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
zero_batch = zero_reader.read_arrow_batch()
self.assertIsNotNone(zero_batch)
self.assertEqual(zero_batch.num_rows, 1)
# Verify empty blob content
read_zero_blob = zero_batch.column(0)[0].as_py()
self.assertEqual(read_zero_blob, b"")
self.assertEqual(len(read_zero_blob), 0)
zero_reader.close()
# ========== Test 4: Large blob (multi-GB range) simulation ==========
# Simulate large blob without actually creating multi-GB data
# Test chunked writing and memory-safe reading patterns
# Create moderately large blob (10MB) to test chunking behavior
chunk_size = 1024 * 1024 # 1MB chunks
large_blob_content = b"LARGE_BLOB_CHUNK:" + b"L" * (chunk_size - 17) # Fill to 1MB
# Simulate multiple chunks
simulated_large_data = [large_blob_content * 10] # 10MB total
large_sim_schema = pa.schema([pa.field("large_sim_blob", pa.large_binary())])
large_sim_table = pa.table([simulated_large_data], schema=large_sim_schema)
large_sim_file = Path(self.temp_dir) / "large_simulation.blob"
large_sim_url = _to_url(large_sim_file)
file_io.write_blob(large_sim_url, large_sim_table)
# Verify large file was written
large_sim_size = file_io.get_file_size(large_sim_url)
self.assertGreater(large_sim_size, 10 * 1024 * 1024) # Should be > 10MB
# Read large blob in memory-safe manner
large_sim_fields = [DataField(0, "large_sim_blob", AtomicType("BLOB"))]
large_sim_reader = FormatBlobReader(
file_io=file_io,
file_path=str(large_sim_file),
read_fields=["large_sim_blob"],
full_fields=large_sim_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
large_sim_batch = large_sim_reader.read_arrow_batch()
self.assertIsNotNone(large_sim_batch)
self.assertEqual(large_sim_batch.num_rows, 1)
# Verify large blob content (check prefix to avoid loading all into memory for comparison)
read_large_blob = large_sim_batch.column(0)[0].as_py()
self.assertTrue(read_large_blob.startswith(b"LARGE_BLOB_CHUNK:"))
self.assertEqual(len(read_large_blob), len(large_blob_content) * 10)
large_sim_reader.close()
# ========== Test 5: Index compression/decompression validation ==========
# Test DeltaVarintCompressor roundtrip
test_indices = [0, 100, 250, 1000, 5000, 10000, 50000]
# Compress indices
compressed_indices = DeltaVarintCompressor.compress(test_indices)
self.assertIsInstance(compressed_indices, bytes)
self.assertGreater(len(compressed_indices), 0)
# Decompress indices
decompressed_indices = DeltaVarintCompressor.decompress(compressed_indices)
self.assertEqual(decompressed_indices, test_indices)
# Test corruption detection in compressed indices
if len(compressed_indices) > 1:
# Corrupt the compressed data
corrupted_indices = bytearray(compressed_indices)
corrupted_indices[-1] = (corrupted_indices[-1] + 1) % 256 # Flip last byte
# Decompression should fail or produce different results
try:
corrupted_result = DeltaVarintCompressor.decompress(bytes(corrupted_indices))
# If decompression succeeds, result should be different
self.assertNotEqual(corrupted_result, test_indices)
except Exception:
pass
# ========== Test 6: Cross-format guard (multi-field tables) ==========
# Test that blob format rejects multi-field tables
multi_field_schema = pa.schema([
pa.field("blob_field", pa.large_binary()),
pa.field("string_field", pa.string()),
pa.field("int_field", pa.int64())
])
multi_field_table = pa.table([
[b"blob_data_1", b"blob_data_2"],
["string_1", "string_2"],
[100, 200]
], schema=multi_field_schema)
multi_field_file = Path(self.temp_dir) / "multi_field.blob"
multi_field_url = _to_url(multi_field_file)
# Should reject multi-field table
with self.assertRaises(RuntimeError) as context:
file_io.write_blob(multi_field_url, multi_field_table)
self.assertIn("single column", str(context.exception))
# Test that blob format rejects non-binary field types
non_binary_schema = pa.schema([pa.field("string_field", pa.string())])
non_binary_table = pa.table([["not_binary_data"]], schema=non_binary_schema)
non_binary_file = Path(self.temp_dir) / "non_binary.blob"
non_binary_url = _to_url(non_binary_file)
# Should reject non-binary field
with self.assertRaises(RuntimeError) as context:
file_io.write_blob(non_binary_url, non_binary_table)
# Should fail due to type conversion issues (non-binary field can't be converted to BLOB)
self.assertTrue(
"large_binary" in str(context.exception) or
"to_paimon_type" in str(context.exception) or
"missing" in str(context.exception) or
"Field must be Blob/BlobData instance" in str(context.exception)
)
# Test that blob format supports tables with null values (round-trip)
null_schema = pa.schema([pa.field("blob_with_null", pa.large_binary())])
null_table = pa.table([[b"data", None, b"more_data"]], schema=null_schema)
null_file = Path(self.temp_dir) / "with_nulls.blob"
null_file_url = _to_url(null_file)
file_io.write_blob(null_file_url, null_table)
null_read_fields = [DataField(0, "blob_with_null", AtomicType("BLOB"))]
null_reader = FormatBlobReader(
file_io=file_io,
file_path=str(null_file),
read_fields=["blob_with_null"],
full_fields=null_read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
null_batch = null_reader.read_arrow_batch()
self.assertEqual(null_batch.num_rows, 3)
self.assertEqual(null_batch.column(0)[0].as_py(), b"data")
self.assertIsNone(null_batch.column(0)[1].as_py())
self.assertEqual(null_batch.column(0)[2].as_py(), b"more_data")
null_reader.close()
def test_blob_write_with_raw_bytes_starting_with_v1_prefix(self):
file_io = LocalFileIO(self.temp_dir, Options({}))
raw_bytes = b"\x01not-a-blob-descriptor-payload"
blob_file_path = Path(self.temp_dir) / "raw_prefix_bytes.blob"
blob_file_url = _to_url(blob_file_path)
schema = pa.schema([pa.field("payload", pa.large_binary())])
table = pa.table([[raw_bytes]], schema=schema)
# Should be treated as plain bytes instead of descriptor bytes.
file_io.write_blob(blob_file_url, table)
fields = [DataField(0, "payload", AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=["payload"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False
)
try:
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.column(0)[0].as_py(), raw_bytes)
finally:
reader.close()
def test_blob_end_to_end_with_descriptor(self):
# Set up file I/O
file_io = LocalFileIO(self.temp_dir, Options({}))
# ========== Step 1: Write data to local file ==========
# Create test data and write it to a local file
test_content = b'This is test blob content stored in an external file for descriptor testing.'
# Write the test content to a local file
local_data_file = Path(self.temp_dir) / "external_blob"
with open(local_data_file, 'wb') as f:
f.write(test_content)
# Verify the file was created and has the correct content
self.assertTrue(local_data_file.exists())
with open(local_data_file, 'rb') as f:
written_content = f.read()
self.assertEqual(written_content, test_content)
# ========== Step 2: Use this file as blob descriptor ==========
# Create a BlobDescriptor pointing to the local file
blob_descriptor = BlobDescriptor(
uri=str(local_data_file),
offset=0,
length=len(test_content)
)
# Serialize the descriptor to bytes (this is what would be stored in the blob column)
descriptor_bytes = blob_descriptor.serialize()
self.assertIsInstance(descriptor_bytes, bytes)
self.assertGreater(len(descriptor_bytes), 0)
# Create PyArrow table with the serialized descriptor
blob_field_name = "blob_descriptor_field"
schema = pa.schema([pa.field(blob_field_name, pa.large_binary())])
table = pa.table([[descriptor_bytes]], schema=schema)
# Write the blob file. The write path adaptively handles descriptor bytes.
blob_file_path = Path(self.temp_dir) / "descriptor_blob.blob"
blob_file_url = _to_url(blob_file_path)
file_io.write_blob(blob_file_url, table)
# Verify the blob file was created
self.assertTrue(file_io.exists(blob_file_url))
file_size = file_io.get_file_size(blob_file_url)
self.assertGreater(file_size, 0)
# ========== Step 3: Read data and check ==========
# Define schema for reading
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=True
)
# Read with blob_as_descriptor=True (read output as descriptor bytes)
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.num_rows, 1)
self.assertEqual(batch.num_columns, 1)
read_blob_bytes = batch.column(0)[0].as_py()
self.assertIsInstance(read_blob_bytes, bytes)
# Deserialize the returned descriptor
returned_descriptor = BlobDescriptor.deserialize(read_blob_bytes)
# The returned descriptor should point to the blob file (simplified implementation)
# because the current implementation creates a descriptor pointing to the blob file location
self.assertEqual(returned_descriptor.uri, str(blob_file_path))
self.assertGreater(returned_descriptor.offset, 0) # Should have some offset in the blob file
reader.close()
reader_content = FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
batch_content = reader_content.read_arrow_batch()
self.assertIsNotNone(batch_content)
self.assertEqual(batch_content.num_rows, 1)
read_content_bytes = batch_content.column(0)[0].as_py()
self.assertIsInstance(read_content_bytes, bytes)
# With blob_as_descriptor=False, we should get the actual blob content
self.assertEqual(read_content_bytes, test_content)
reader_content.close()
def test_null_blob_write(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
output = io.BytesIO()
writer = BlobFormatWriter(output)
row_with_null = GenericRow(
[None],
[DataField(0, "blob_field", AtomicType("BLOB"))],
RowKind.INSERT
)
writer.add_element(row_with_null)
self.assertEqual(writer.lengths, [-1])
self.assertEqual(writer.position, 0)
@staticmethod
def _write_blob_record_with_crc_backend(backend):
from pypaimon.write import blob_format_writer
output = io.BytesIO()
payload = b'blob-crc-payload' * 1024
with patch.object(blob_format_writer, 'crc_backend', backend):
writer = blob_format_writer.BlobFormatWriter(output)
writer.add_blob('blob_field', BlobData(payload))
return output.getvalue(), payload
def test_blob_crc_fallback_matches_zlib(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
record, payload = self._write_blob_record_with_crc_backend(zlib)
expected_crc = zlib.crc32(
struct.pack('<I', BlobFormatWriter.MAGIC_NUMBER))
expected_crc = zlib.crc32(payload, expected_crc) & 0xffffffff
actual_crc = struct.unpack('<I', record[-4:])[0]
self.assertEqual(expected_crc, actual_crc)
def test_blob_crc_isal_matches_zlib(self):
try:
from isal import isal_zlib
except ImportError:
self.skipTest('isal is not available on this platform')
zlib_record, _ = self._write_blob_record_with_crc_backend(zlib)
isal_record, _ = self._write_blob_record_with_crc_backend(isal_zlib)
self.assertEqual(zlib_record, isal_record)
def test_null_blob_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "null_blob.blob")
output = open(blob_file_path, 'wb')
writer = BlobFormatWriter(output)
fields = [DataField(0, "blob_field", AtomicType("BLOB"))]
writer.add_element(GenericRow([BlobData(b"hello")], fields, RowKind.INSERT))
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([BlobData(b"world")], fields, RowKind.INSERT))
writer.close()
blob_field_name = "blob_field"
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.num_rows, 3)
self.assertEqual(batch.column(0)[0].as_py(), b"hello")
self.assertIsNone(batch.column(0)[1].as_py())
self.assertEqual(batch.column(0)[2].as_py(), b"world")
reader.close()
def test_null_blob_read_as_descriptor(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "null_desc.blob")
output = open(blob_file_path, 'wb')
writer = BlobFormatWriter(output)
fields = [DataField(0, "blob_field", AtomicType("BLOB"))]
writer.add_element(GenericRow([BlobData(b"hello")], fields, RowKind.INSERT))
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([BlobData(b"world")], fields, RowKind.INSERT))
writer.close()
blob_field_name = "blob_field"
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=True
)
batch = reader.read_arrow_batch()
self.assertIsNotNone(batch)
self.assertEqual(batch.num_rows, 3)
desc0 = BlobDescriptor.deserialize(batch.column(0)[0].as_py())
self.assertEqual(desc0.uri, blob_file_path)
self.assertIsNone(batch.column(0)[1].as_py())
desc2 = BlobDescriptor.deserialize(batch.column(0)[2].as_py())
self.assertEqual(desc2.uri, blob_file_path)
reader.close()
def test_placeholder_blob_write_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "placeholder_blob.blob")
output = open(blob_file_path, 'wb')
writer = BlobFormatWriter(output)
fields = [DataField(0, "blob_field", AtomicType("BLOB"))]
writer.add_element(GenericRow([BlobData(b"hello")], fields, RowKind.INSERT))
writer.add_element(GenericRow([Blob.PLACE_HOLDER], fields, RowKind.INSERT))
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([BlobData(b"world")], fields, RowKind.INSERT))
self.assertEqual(
writer.lengths[1:3],
[BlobFormatWriter.PLACE_HOLDER_LENGTH, BlobFormatWriter.NULL_LENGTH])
writer.close()
with open(blob_file_path, 'rb') as blob_file:
blob_file.seek(-1, os.SEEK_END)
self.assertEqual(blob_file.read(1), struct.pack('<B', BlobFormatWriter.VERSION))
blob_field_name = "blob_field"
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=False
)
self.assertEqual(reader.blob_lengths[1], BlobFormatWriter.PLACE_HOLDER_LENGTH)
iterator = BlobRecordIterator(
file_io, blob_file_path, reader.blob_lengths, reader.blob_offsets, blob_field_name)
self.assertEqual(next(iterator).values[0].to_data(), b"hello")
self.assertIs(next(iterator).values[0], Blob.PLACE_HOLDER)
self.assertIsNone(next(iterator).values[0])
self.assertEqual(next(iterator).values[0].to_data(), b"world")
with self.assertRaisesRegex(RuntimeError, "Blob placeholder is not supported"):
reader.read_arrow_batch()
reader.close()
def test_placeholder_blob_read_as_descriptor(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "placeholder_desc.blob")
output = open(blob_file_path, 'wb')
writer = BlobFormatWriter(output)
fields = [DataField(0, "blob_field", AtomicType("BLOB"))]
writer.add_element(GenericRow([Blob.PLACE_HOLDER], fields, RowKind.INSERT))
writer.add_element(GenericRow([BlobData(b"world")], fields, RowKind.INSERT))
writer.close()
blob_field_name = "blob_field"
read_fields = [DataField(0, blob_field_name, AtomicType("BLOB"))]
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=[blob_field_name],
full_fields=read_fields,
push_down_predicate=None,
blob_as_descriptor=True
)
with self.assertRaisesRegex(RuntimeError, "Blob placeholder is not supported"):
reader.read_arrow_batch()
reader.close()
def test_array_blob_write_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "array_blob.blob")
fields = [
DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)
]
with open(blob_file_path, 'wb') as output:
writer = BlobFormatWriter(output)
writer.add_element(GenericRow(
[[BlobData(b"hello"), None, BlobData(b"world")]],
fields,
RowKind.INSERT,
))
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([[]], fields, RowKind.INSERT))
writer.add_element(GenericRow(
[[BlobData(b"last")]],
fields,
RowKind.INSERT,
))
writer.close()
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
batch = reader.read_arrow_batch()
self.assertEqual(batch.column(0).to_pylist(), [
[b"hello", None, b"world"],
None,
[],
[b"last"],
])
reader.close()
def test_array_blob_parallelism_uses_concurrent_resolver(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "array_blob_parallel.blob")
fields = [DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)]
with open(blob_file_path, 'wb') as output:
writer = BlobFormatWriter(output)
writer.add_element(GenericRow(
[[BlobData(b"a"), None, BlobData(b"bc")]],
fields,
RowKind.INSERT,
))
writer.add_element(GenericRow(
[[BlobData(b"def")]],
fields,
RowKind.INSERT,
))
writer.close()
calls = []
range_reads = []
original_read = file_io.read_blobs_concurrent
original_range_read = file_io.read_file_range
def read_blobs_concurrent(blobs, parallelism):
calls.append((list(blobs), parallelism))
return original_read(blobs, parallelism)
def read_file_range(path, offset, length):
range_reads.append((path, offset, length))
return original_range_read(path, offset, length)
file_io.read_blobs_concurrent = read_blobs_concurrent
file_io.read_file_range = read_file_range
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
blob_parallelism=4,
)
batch = reader.read_arrow_batch()
self.assertEqual(
batch.column(0).to_pylist(),
[[b"a", None, b"bc"], [b"def"]],
)
self.assertEqual(len(calls), 1)
self.assertEqual(len(calls[0][0]), 3)
self.assertEqual(calls[0][1], 4)
self.assertEqual(len(range_reads), 1)
reader.close()
def test_array_blob_serial_read_uses_single_payload_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "array_blob_serial.blob")
fields = [DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)]
with open(blob_file_path, 'wb') as output:
writer = BlobFormatWriter(output)
writer.add_element(GenericRow(
[[BlobData(b"a"), BlobData(b"bc"), BlobData(b"def")]],
fields,
RowKind.INSERT,
))
writer.close()
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
blob_lengths = list(reader.blob_lengths)
blob_offsets = list(reader.blob_offsets)
reader.close()
class TrackingStream:
def __init__(self, path):
self.stream = open(path, 'rb')
self.read_sizes = []
def seek(self, position, whence=0):
return self.stream.seek(position, whence)
def read(self, size=-1):
self.read_sizes.append(size)
return self.stream.read(size)
def close(self):
self.stream.close()
stream = TrackingStream(blob_file_path)
iterator = BlobRecordIterator(
file_io,
blob_file_path,
blob_lengths,
blob_offsets,
fields[0],
stream,
)
blobs = next(iterator).values[0]
self.assertEqual([blob.to_data() for blob in blobs], [b"a", b"bc", b"def"])
self.assertEqual(stream.read_sizes[-1], 6)
stream.close()
def test_file_io_write_array_blob(self):
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = Path(self.temp_dir) / "array_blob_file_io.blob"
blob_file_url = _to_url(blob_file_path)
arrow_type = pa.list_(pa.large_binary())
table = pa.table(
[pa.array([[b"one", None], None, [b"two"]], type=arrow_type)],
schema=pa.schema([pa.field("blob_array", arrow_type)]),
)
file_io.write_blob(blob_file_url, table)
fields = [
DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)
]
reader = FormatBlobReader(
file_io=file_io,
file_path=str(blob_file_path),
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
batch = reader.read_arrow_batch()
self.assertEqual(batch.column(0).to_pylist(), [
[b"one", None],
None,
[b"two"],
])
reader.close()
def test_array_blob_read_as_descriptor(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "array_blob_desc.blob")
fields = [
DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)
]
with open(blob_file_path, 'wb') as output:
writer = BlobFormatWriter(output)
writer.add_element(GenericRow(
[[BlobData(b"hello"), None, BlobData(b"world")]],
fields,
RowKind.INSERT,
))
writer.close()
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=True,
)
batch = reader.read_arrow_batch()
values = batch.column(0)[0].as_py()
self.assertEqual(len(values), 3)
desc0 = BlobDescriptor.deserialize(values[0])
self.assertEqual(desc0.uri, blob_file_path)
self.assertEqual(desc0.length, len(b"hello"))
self.assertIsNone(values[1])
desc2 = BlobDescriptor.deserialize(values[2])
self.assertEqual(desc2.uri, blob_file_path)
self.assertEqual(desc2.length, len(b"world"))
reader.close()
def test_array_blob_placeholder_write_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "array_placeholder.blob")
fields = [
DataField(
0,
"blob_array",
ArrayType(True, AtomicType("BLOB")),
)
]
with open(blob_file_path, 'wb') as output:
writer = BlobFormatWriter(output)
writer.add_element(GenericRow(
[[BlobData(b"hello")]],
fields,
RowKind.INSERT,
))
writer.add_element(GenericRow(
[Blob.ARRAY_PLACE_HOLDER],
fields,
RowKind.INSERT,
))
writer.close()
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_array"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
iterator = BlobRecordIterator(
file_io,
blob_file_path,
reader.blob_lengths,
reader.blob_offsets,
fields[0],
)
self.assertEqual(next(iterator).values[0][0].to_data(), b"hello")
self.assertIs(next(iterator).values[0], Blob.ARRAY_PLACE_HOLDER)
with self.assertRaisesRegex(RuntimeError, "Blob placeholder is not supported"):
reader.read_arrow_batch()
reader.close()
def test_reject_malformed_array_blob_payloads(self):
cases = [
(
self._array_blob_payload(b"a", [1], version=0),
"Unsupported ARRAY<BLOB> payload version",
),
(
self._array_blob_payload(b"a", [1], element_count=0x80000000),
"Invalid ARRAY<BLOB> element count",
),
(
self._array_blob_payload(b"a", [1], index_length=100),
"Invalid ARRAY<BLOB> element index length",
),
(
self._array_blob_payload(b"", [], index=b"\x80"),
"Invalid ARRAY<BLOB> element index",
),
(
self._array_blob_payload(b"a", [-2]),
"Invalid ARRAY<BLOB> element length",
),
(
self._array_blob_payload(b"a", [2]),
"element lengths exceed the payload data length",
),
]
field = DataField(0, "blob_array", ArrayType(True, AtomicType("BLOB")))
for payload, expected_message in cases:
with self.subTest(expected_message=expected_message):
iterator = BlobRecordIterator(
None,
"unused",
[],
[],
field,
input_stream=io.BytesIO(payload),
)
with self.assertRaisesRegex(ValueError, expected_message):
iterator._read_blob_array(0, len(payload))
@staticmethod
def _array_blob_payload(
data, element_lengths, version=BlobRecordIterator.ARRAY_VERSION,
element_count=None, index_length=None, index=None,
):
if index is None:
index = DeltaVarintCompressor.compress(element_lengths)
if element_count is None:
element_count = len(element_lengths)
if index_length is None:
index_length = len(index)
return (
struct.pack(
'<IBI',
BlobRecordIterator.ARRAY_MAGIC_NUMBER,
version,
element_count,
)
+ data
+ index
+ struct.pack('<I', index_length)
)
def test_map_blob_golden_bytes(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
blob_file_path = os.path.join(self.temp_dir, "map_golden.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
entries = [
(None, None),
("", BlobData(b"")),
("inline", BlobData(b"data")),
("descriptor", BlobData(b"descriptor")),
]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.add_element(GenericRow([{}], fields, RowKind.INSERT))
writer.add_element(GenericRow([entries], fields, RowKind.INSERT))
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([Blob.MAP_PLACE_HOLDER], fields, RowKind.INSERT))
writer.close()
with open(blob_file_path, 'rb') as blob_file:
self.assertEqual(
blob_file.read().hex(),
"cf114e584243424d010000000000000000000000002100000000000000"
"8360591ecf114e584243424d0104000000696e6c696e65646573637269"
"70746f726461746164657363726970746f7201020c080102080c040000"
"000400000047000000000000006c9f5981424c8f01010500000001",
)
def test_read_java_map_blob_golden_bytes(self):
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "java_map_golden.blob")
java_golden_bytes = bytes.fromhex(
"cf114e584243424d010000000000000000000000002100000000000000"
"8360591ecf114e584243424d0104000000696e6c696e65646573637269"
"70746f726461746164657363726970746f7201020c080102080c040000"
"000400000047000000000000006c9f5981424c8f01010500000001"
)
with open(blob_file_path, 'wb') as blob_file:
blob_file.write(java_golden_bytes)
field = DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=[field.name],
full_fields=[field],
push_down_predicate=None,
blob_as_descriptor=False,
)
try:
self.assertEqual(reader.blob_lengths, [33, 71, -1, -2])
iterator = BlobRecordIterator(
file_io,
blob_file_path,
reader.blob_lengths,
reader.blob_offsets,
field,
input_stream=reader._input_stream,
)
self.assertEqual(next(iterator).values[0], {})
blob_map = next(iterator).values[0]
self.assertEqual(list(blob_map), [None, "", "inline", "descriptor"])
self.assertIsNone(blob_map[None])
self.assertEqual(blob_map[""].to_data(), b"")
self.assertEqual(blob_map["inline"].to_data(), b"data")
self.assertEqual(blob_map["descriptor"].to_data(), b"descriptor")
self.assertIsNone(next(iterator).values[0])
self.assertIs(next(iterator).values[0], Blob.MAP_PLACE_HOLDER)
with self.assertRaises(StopIteration):
next(iterator)
finally:
reader.close()
def test_map_blob_write_read(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "map_blob.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.write_value(
{"alpha": b"a", "null": None, "empty": b""},
fields,
)
writer.add_element(GenericRow([None], fields, RowKind.INSERT))
writer.add_element(GenericRow([{}], fields, RowKind.INSERT))
writer.add_element(GenericRow(
[{"omega": BlobData(b"last")}],
fields,
RowKind.INSERT,
))
writer.close()
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
self.assertEqual(
[("value", b"body"), ("null", None)],
reader._map_value_for_arrow(
{"value": BlobData(b"body"), "null": None},
"blob_map",
0,
[],
),
)
values = reader.read_arrow_batch().column(0).to_pylist()
self.assertEqual(dict(values[0]), {"alpha": b"a", "null": None, "empty": b""})
self.assertIsNone(values[1])
self.assertEqual(values[2], [])
self.assertEqual(dict(values[3]), {"omega": b"last"})
reader.close()
selected_reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
row_indices=[3, 0],
)
selected = selected_reader.read_arrow_batch().column(0).to_pylist()
self.assertEqual(dict(selected[0]), {"omega": b"last"})
self.assertEqual(
dict(selected[1]),
{"alpha": b"a", "null": None, "empty": b""},
)
selected_reader.close()
def test_map_blob_descriptor_and_parallel_reads(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "map_blob_descriptor.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.add_element(GenericRow(
[[("a", BlobData(b"x")), ("b", BlobData(b"yz")), ("n", None)]],
fields,
RowKind.INSERT,
))
writer.close()
descriptor_reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=True,
)
descriptors = dict(descriptor_reader.read_arrow_batch().column(0)[0].as_py())
descriptor_a = BlobDescriptor.deserialize(descriptors["a"])
descriptor_b = BlobDescriptor.deserialize(descriptors["b"])
self.assertEqual((descriptor_a.offset, descriptor_a.length), (16, 1))
self.assertEqual((descriptor_b.offset, descriptor_b.length), (17, 2))
self.assertIsNone(descriptors["n"])
descriptor_reader.close()
calls = []
original_read = file_io.read_blobs_concurrent
def read_blobs_concurrent(blobs, parallelism):
calls.append((list(blobs), parallelism))
return original_read(blobs, parallelism)
file_io.read_blobs_concurrent = read_blobs_concurrent
parallel_reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
blob_parallelism=4,
)
blobs_to_resolve = []
self.assertEqual(
[("a", None), ("n", None), ("b", None)],
parallel_reader._map_value_for_arrow(
{
"a": BlobData(b"x"),
"n": None,
"b": BlobData(b"yz"),
},
"blob_map",
0,
blobs_to_resolve,
),
)
self.assertEqual([0, 2], [target[3] for target in blobs_to_resolve])
values = dict(parallel_reader.read_arrow_batch().column(0)[0].as_py())
self.assertEqual(values, {"a": b"x", "b": b"yz", "n": None})
self.assertEqual(len(calls), 1)
self.assertEqual(len(calls[0][0]), 2)
self.assertEqual(calls[0][1], 4)
parallel_reader.close()
def test_map_blob_consumer_descriptors_and_flush(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
class TrackingOutput:
def __init__(self, path):
self.output = open(path, 'wb')
self.flush_count = 0
def write(self, data):
return self.output.write(data)
def flush(self):
self.flush_count += 1
return self.output.flush()
def close(self):
return self.output.close()
blob_file_path = os.path.join(self.temp_dir, "map_blob_consumer.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
descriptors = []
def consumer(field_name, descriptor):
descriptors.append((field_name, descriptor))
return descriptor.length == 2
output = TrackingOutput(blob_file_path)
writer = BlobFormatWriter(output, consumer, blob_file_path)
writer.add_element(GenericRow(
[[("a", BlobData(b"x")), ("b", BlobData(b"yz")), (None, None)]],
fields,
RowKind.INSERT,
))
self.assertEqual(output.flush_count, 1)
self.assertEqual(len(descriptors), 2)
self.assertEqual([item[0] for item in descriptors], ["blob_map", "blob_map"])
self.assertEqual(
[(item[1].offset, item[1].length) for item in descriptors],
[(15, 1), (16, 2)],
)
writer.close()
def test_map_blob_write_value_from_serialized_descriptor(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
source_uri = "custom://bucket/source.bin"
source_data = b"prefix-selected-suffix"
selected_data = b"selected"
source_descriptor = BlobDescriptor(
source_uri,
len(b"prefix-"),
len(selected_data),
)
class TrackingStream(io.BytesIO):
def __init__(self, data):
super().__init__(data)
self.close_count = 0
def close(self):
self.close_count += 1
super().close()
class TrackingUriReader:
def __init__(self):
self.opened_uris = []
self.streams = []
def new_input_stream(self, uri):
self.opened_uris.append(uri)
stream = TrackingStream(source_data)
self.streams.append(stream)
return stream
class TrackingUriReaderFactory:
def __init__(self, uri_reader):
self.uri_reader = uri_reader
self.created_uris = []
def create(self, uri):
self.created_uris.append(uri)
return self.uri_reader
output_path = os.path.join(self.temp_dir, "map_descriptor_input.blob")
field = DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)
consumed_descriptors = []
def consumer(field_name, descriptor):
consumed_descriptors.append((field_name, descriptor))
return False
uri_reader = TrackingUriReader()
uri_reader_factory = TrackingUriReaderFactory(uri_reader)
writer = BlobFormatWriter(
open(output_path, 'wb'),
blob_consumer=consumer,
file_path=output_path,
)
writer.write_value(
{"slice": source_descriptor.serialize()},
[field],
uri_reader_factory=uri_reader_factory,
)
writer.close()
self.assertEqual(uri_reader_factory.created_uris, [source_uri])
self.assertEqual(uri_reader.opened_uris, [source_uri])
self.assertEqual(len(uri_reader.streams), 1)
self.assertTrue(uri_reader.streams[0].closed)
self.assertEqual(uri_reader.streams[0].close_count, 1)
self.assertEqual(len(consumed_descriptors), 1)
output_descriptor = consumed_descriptors[0][1]
self.assertEqual(consumed_descriptors[0][0], field.name)
self.assertEqual(output_descriptor.uri, output_path)
self.assertEqual(output_descriptor.offset, 18)
self.assertEqual(output_descriptor.length, len(selected_data))
file_io = LocalFileIO(self.temp_dir, Options({}))
reader = FormatBlobReader(
file_io=file_io,
file_path=output_path,
read_fields=[field.name],
full_fields=[field],
push_down_predicate=None,
blob_as_descriptor=False,
)
try:
value = dict(reader.read_arrow_batch().column(0)[0].as_py())
self.assertEqual(value, {"slice": selected_data})
finally:
reader.close()
def test_map_blob_placeholder_and_null_key(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "map_blob_null_key.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.add_element(GenericRow(
[[(None, BlobData(b"null-key"))]],
fields,
RowKind.INSERT,
))
writer.add_element(GenericRow(
[Blob.MAP_PLACE_HOLDER],
fields,
RowKind.INSERT,
))
lengths = list(writer.lengths)
writer.close()
iterator = BlobRecordIterator(
file_io,
blob_file_path,
lengths,
[0, lengths[0]],
fields[0],
)
result = next(iterator).values[0]
self.assertEqual(result[None].to_data(), b"null-key")
self.assertIs(next(iterator).values[0], Blob.MAP_PLACE_HOLDER)
null_key_reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
row_indices=[0],
)
with self.assertRaisesRegex(ValueError, "null keys cannot be converted"):
null_key_reader.read_arrow_batch()
null_key_reader.close()
placeholder_reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
row_indices=[1],
)
with self.assertRaisesRegex(RuntimeError, "Blob placeholder is not supported"):
placeholder_reader.read_arrow_batch()
placeholder_reader.close()
def test_duplicate_map_blob_key_last_wins(self):
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
blob_file_path = os.path.join(self.temp_dir, "map_blob_duplicate.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.add_element(GenericRow(
[[
("duplicate", BlobData(b"first")),
("duplicate", BlobData(b"second")),
("tail", BlobData(b"third")),
]],
fields,
RowKind.INSERT,
))
record_length = writer.lengths[0]
writer.close()
for blob_as_descriptor in (False, True):
iterator = BlobRecordIterator(
file_io,
blob_file_path,
[record_length],
[0],
fields[0],
blob_as_descriptor=blob_as_descriptor,
)
result = next(iterator).values[0]
self.assertEqual(list(result), ["duplicate", "tail"])
self.assertEqual(result["duplicate"].to_data(), b"second")
self.assertEqual(result["tail"].to_data(), b"third")
def test_map_blob_key_types_and_rejections(self):
from pypaimon.common.map_blob_key_serializer import create_map_blob_key_serializer
from pypaimon.write.blob_format_writer import BlobFormatWriter
file_io = LocalFileIO(self.temp_dir, Options({}))
cases = [
(AtomicType("TINYINT"), -128),
(AtomicType("SMALLINT"), -32768),
(AtomicType("INT"), -2147483648),
(AtomicType("INTEGER"), 0x01020304),
(AtomicType("BIGINT"), -9223372036854775808),
(AtomicType("BOOLEAN"), True),
(AtomicType("DECIMAL(10, 2)"), Decimal("12.34")),
(
AtomicType("DECIMAL(20, 2)"),
Decimal("123456789012345678.90"),
),
(AtomicType("DATE"), datetime.date(1969, 12, 31)),
(AtomicType("TIME(3)"), datetime.time(12, 34, 56, 789000)),
(AtomicType("BINARY(4)"), bytes([0, 255])),
(AtomicType("VARBINARY(8)"), b""),
(AtomicType("BYTES"), b"bytes"),
(AtomicType("STRING"), "string"),
(AtomicType("CHAR(3)"), "abc"),
(AtomicType("VARCHAR(10)"), "varchar"),
]
serialized_keys = [
b"\x80",
b"\x00\x80",
b"\x00\x00\x00\x80",
b"\x04\x03\x02\x01",
b"\x00\x00\x00\x00\x00\x00\x00\x80",
b"\x01",
b"\xd2\x04\x00\x00\x00\x00\x00\x00",
b"\x00\xab\x54\xa9\x8c\xeb\x1f\x0a\xd2",
b"\xff\xff\xff\xff",
b"\x95\x2c\xb3\x02",
b"\x00\xff",
b"",
b"bytes",
b"string",
b"abc",
b"varchar",
]
for index, (key_type, key) in enumerate(cases):
with self.subTest(key_type=key_type):
blob_file_path = os.path.join(self.temp_dir, f"map_key_{index}.blob")
fields = [DataField(
0,
"blob_map",
MapType(True, key_type, AtomicType("BLOB")),
)]
writer = BlobFormatWriter(open(blob_file_path, 'wb'))
writer.add_element(GenericRow(
[[(key, BlobData(b"value"))]],
fields,
RowKind.INSERT,
))
record_length = writer.lengths[0]
writer.close()
iterator = BlobRecordIterator(
file_io,
blob_file_path,
[record_length],
[0],
fields[0],
)
result = next(iterator).values[0]
self.assertEqual(result[key].to_data(), b"value")
with open(blob_file_path, 'rb') as blob_file:
blob_file.seek(BlobRecordIterator.MAGIC_NUMBER_SIZE
+ BlobRecordIterator.MAP_HEADER_SIZE)
self.assertEqual(
blob_file.read(len(serialized_keys[index])),
serialized_keys[index],
)
reader = FormatBlobReader(
file_io=file_io,
file_path=blob_file_path,
read_fields=["blob_map"],
full_fields=fields,
push_down_predicate=None,
blob_as_descriptor=False,
)
try:
value = dict(reader.read_arrow_batch().column(0)[0].as_py())
self.assertEqual(value, {key: b"value"})
finally:
reader.close()
time_serializer = create_map_blob_key_serializer(AtomicType("TIME(9)"))
serialized_time = time_serializer.serialize(datetime.time(12, 34, 56, 789999))
self.assertEqual(serialized_time, b"\x95\x2c\xb3\x02")
self.assertEqual(
time_serializer.deserialize(serialized_time),
datetime.time(12, 34, 56, 789000),
)
output = io.BytesIO()
unsupported_key_writer = BlobFormatWriter(output)
unsupported_key_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("FLOAT"), AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "Unsupported key type"):
unsupported_key_writer.add_element(GenericRow(
[{1.0: BlobData(b"value")}],
[unsupported_key_field],
RowKind.INSERT,
))
invalid_value_writer = BlobFormatWriter(io.BytesIO())
invalid_value_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("STRING")),
)
with self.assertRaisesRegex(ValueError, "value type must be BLOB"):
invalid_value_writer.add_element(GenericRow(
[{"key": "value"}],
[invalid_value_field],
RowKind.INSERT,
))
invalid_key_writer = BlobFormatWriter(io.BytesIO())
int_key_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("INT"), AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "key must be an integer"):
invalid_key_writer.add_element(GenericRow(
[{"not-an-int": BlobData(b"value")}],
[int_key_field],
RowKind.INSERT,
))
invalid_binary_key_writer = BlobFormatWriter(io.BytesIO())
binary_key_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("BINARY(4)"), AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "key must be bytes"):
invalid_binary_key_writer.add_element(GenericRow(
[{"not-bytes": BlobData(b"value")}],
[binary_key_field],
RowKind.INSERT,
))
invalid_time_key_writer = BlobFormatWriter(io.BytesIO())
time_key_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("TIME(3)"), AtomicType("BLOB")),
)
with self.assertRaisesRegex(ValueError, "key must be a datetime.time"):
invalid_time_key_writer.add_element(GenericRow(
[{"not-a-time": BlobData(b"value")}],
[time_key_field],
RowKind.INSERT,
))
def test_reject_malformed_map_blob_payloads(self):
string_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("STRING"), AtomicType("BLOB")),
)
int_field = DataField(
0,
"blob_map",
MapType(True, AtomicType("INT"), AtomicType("BLOB")),
)
cases = [
(
self._map_blob_payload(b"", b"", [], [], magic=0),
string_field,
"Invalid MAP<X, BLOB> payload magic number",
),
(
self._map_blob_payload(b"", b"", [], [], version=0),
string_field,
"Unsupported MAP<X, BLOB> payload version",
),
(
self._map_blob_payload(b"", b"", [], [], entry_count=0x80000000),
string_field,
"Invalid MAP<X, BLOB> entry count",
),
(
self._map_blob_payload(b"", b"", [], [], key_index_length=100),
string_field,
"Invalid MAP<X, BLOB> key index length",
),
(
self._map_blob_payload(b"", b"", [], [], value_index_length=100),
string_field,
"Invalid MAP<X, BLOB> value index length",
),
(
self._map_blob_payload(
b"", b"", [], [], key_index=b"\x80", value_index=b""
),
string_field,
"Invalid MAP<X, BLOB> key index",
),
(
self._map_blob_payload(
b"", b"", [], [], key_index=b"", value_index=b"\x80"
),
string_field,
"Invalid MAP<X, BLOB> value index",
),
(
self._map_blob_payload(b"", b"", [-2], [-1]),
string_field,
"Invalid MAP<X, BLOB> key length",
),
(
self._map_blob_payload(b"abc", b"", [3], [-1]),
int_field,
"Invalid MAP<X, BLOB> fixed-width key length",
),
(
self._map_blob_payload(b"a", b"", [2], [-1]),
string_field,
"key lengths exceed the payload data length",
),
(
self._map_blob_payload(b"a", b"", [1], [-2]),
string_field,
"Invalid MAP<X, BLOB> value length",
),
(
self._map_blob_payload(b"a", b"b", [1], [2]),
string_field,
"value lengths exceed the payload data length",
),
(
self._map_blob_payload(b"a", b"b", [1], [0]),
string_field,
"key/value lengths do not match the payload data length",
),
(
self._map_blob_payload(
b"", b"", [], [],
entry_count=2,
key_index=DeltaVarintCompressor.compress([128]),
value_index=DeltaVarintCompressor.compress([128]),
),
string_field,
"entry count does not match key index length",
),
]
for payload, field, expected_message in cases:
with self.subTest(expected_message=expected_message):
iterator = BlobRecordIterator(
None,
"unused",
[],
[],
field,
input_stream=io.BytesIO(payload),
)
with self.assertRaisesRegex(ValueError, expected_message):
iterator._read_blob_map(0, len(payload))
@staticmethod
def _map_blob_payload(
key_data,
value_data,
key_lengths,
value_lengths,
magic=BlobRecordIterator.MAP_MAGIC_NUMBER,
version=BlobRecordIterator.MAP_VERSION,
entry_count=None,
key_index=None,
value_index=None,
key_index_length=None,
value_index_length=None,
):
if key_index is None:
key_index = DeltaVarintCompressor.compress(key_lengths)
if value_index is None:
value_index = DeltaVarintCompressor.compress(value_lengths)
if entry_count is None:
entry_count = len(key_lengths)
if key_index_length is None:
key_index_length = len(key_index)
if value_index_length is None:
value_index_length = len(value_index)
return (
struct.pack('<IBI', magic, version, entry_count)
+ key_data
+ value_data
+ key_index
+ value_index
+ struct.pack('<II', key_index_length, value_index_length)
)
class BlobParallelismTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.catalog = CatalogFactory.create({'warehouse': os.path.join(self.temp_dir, 'wh')})
self.catalog.create_database('default', True)
pa_schema = pa.schema([('id', pa.int32()), ('img', pa.large_binary())])
self.catalog.create_table('default.bp_test', Schema.from_pyarrow_schema(
pa_schema, options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}), False)
self.payloads = [os.urandom(512) for _ in range(20)]
t = self.catalog.get_table('default.bp_test')
w = t.new_batch_write_builder().new_write()
w.write_arrow(pa.Table.from_pydict(
{'id': list(range(20)), 'img': self.payloads}, schema=pa_schema))
t.new_batch_write_builder().new_commit().commit(w.prepare_commit())
w.close()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_to_arrow_blob_parallelism(self):
t = self.catalog.get_table('default.bp_test')
rb = t.new_read_builder()
splits = rb.new_scan().plan().splits()
serial = rb.new_read().to_arrow(splits)
parallel = rb.new_read().to_arrow(splits, blob_parallelism=4)
self.assertEqual(serial.num_rows, parallel.num_rows)
for i in range(serial.num_rows):
self.assertEqual(serial['img'][i].as_py(), parallel['img'][i].as_py())
def test_to_arrow_batch_reader_blob_parallelism(self):
t = self.catalog.get_table('default.bp_test')
rb = t.new_read_builder()
splits = rb.new_scan().plan().splits()
serial = rb.new_read().to_arrow(splits)
batches = []
for batch in rb.new_read().to_arrow_batch_reader(splits, blob_parallelism=4):
batches.append(batch)
parallel = pa.Table.from_batches(batches)
self.assertEqual(serial.num_rows, parallel.num_rows)
for i in range(serial.num_rows):
self.assertEqual(serial['img'][i].as_py(), parallel['img'][i].as_py())
def test_blob_parallelism_with_projection(self):
t = self.catalog.get_table('default.bp_test')
rb = t.new_read_builder()
rb = rb.with_projection(['id', 'img'])
splits = rb.new_scan().plan().splits()
result = rb.new_read().to_arrow(splits, blob_parallelism=4)
self.assertEqual(result.column_names, ['id', 'img'])
got = dict(zip(result['id'].to_pylist(), result['img'].to_pylist()))
for i in range(20):
self.assertEqual(got[i], self.payloads[i])
def test_blob_fallback_parallelism_end_to_end(self):
t = self.catalog.get_table('default.bp_test')
row_id_builder = t.new_read_builder().with_projection(['id', '_ROW_ID'])
row_id_result = row_id_builder.new_read().to_arrow(
row_id_builder.new_scan().plan().splits())
row_ids_by_id = dict(zip(
row_id_result['id'].to_pylist(),
row_id_result['_ROW_ID'].to_pylist(),
))
updated_payload = os.urandom(512)
update_builder = t.new_batch_write_builder()
table_update = update_builder.new_update().with_update_type(['img'])
update_messages = table_update.update_by_arrow_with_row_id(
pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids_by_id[1]], type=pa.int64()),
'img': pa.array([updated_payload], type=pa.large_binary()),
}))
update_builder.new_commit().commit(update_messages)
update_blob_files = [
file
for message in update_messages
for file in message.new_files
if file.file_name.endswith('.blob')
]
self.assertEqual(1, len(update_blob_files))
blob_reader = FormatBlobReader(
t.file_io,
update_blob_files[0].file_path,
['img'],
t.fields,
None,
False,
)
try:
self.assertIn(
FormatBlobReader.PLACE_HOLDER_LENGTH,
blob_reader.blob_lengths,
)
finally:
blob_reader.close()
rb = t.new_read_builder().with_projection(['id', 'img'])
splits = rb.new_scan().plan().splits()
serial = rb.new_read().to_arrow(splits, blob_parallelism=1)
resolve_calls = []
original_resolve = BlobFallbackBatchReader._resolve_selected_blobs
def tracking_resolve(reader, values):
resolve_calls.append(len(values))
return original_resolve(reader, values)
with patch.object(
BlobFallbackBatchReader,
'_resolve_selected_blobs',
tracking_resolve,
):
parallel = rb.new_read().to_arrow(splits, blob_parallelism=4)
self.assertEqual(20, serial.num_rows)
self.assertEqual(serial.to_pydict(), parallel.to_pydict())
self.assertEqual(updated_payload, parallel['img'][1].as_py())
self.assertGreater(len(resolve_calls), 0)
class CapBlobParallelismTest(unittest.TestCase):
"""Peak blob threads on the parallel path (workers * blob_parallelism)
must stay within TableRead._MAX_TOTAL_BLOB_WORKERS."""
def test_cap(self):
from pypaimon.read.table_read import TableRead
cap = TableRead._MAX_TOTAL_BLOB_WORKERS
f = TableRead._cap_blob_parallelism
self.assertEqual(f(1, 1), 1) # serial blobs, untouched
self.assertEqual(f(16, 1), 1) # B<=1 untouched
self.assertEqual(f(4, 8), 8) # 32 <= cap, untouched
self.assertEqual(f(16, 16), cap // 16) # 256 -> shrink to cap/workers
self.assertEqual(f(cap, 2), 1) # workers==cap -> 1
self.assertEqual(f(cap + 100, 2), 1) # workers>cap -> floor to 1
for w in (2, 4, 8, 16, 32, 64):
self.assertLessEqual(w * f(w, 999), cap)
class CoalesceRangesTest(unittest.TestCase):
"""read_ranges_coalesced merges same-file adjacent reads into fewer requests
(JingsongLi's IO-merging suggestion) while returning identical bytes."""
def test_coalesce_ranges_grouping(self):
from pypaimon.common.file_io import _coalesce_ranges
items = [(0, "a", 0, 10), (1, "a", 10, 10), (2, "a", 1000, 10), (3, "b", 0, 5)]
# a:[0,20) merged, a:[1000,1010) split by gap, b:[0,5) separate file
spans = _coalesce_ranges(items, max_gap=100, max_span=1 << 30)
self.assertEqual(len(spans), 3)
self.assertEqual(sorted(i for _, _, _, mem in spans for i, _, _ in mem), [0, 1, 2, 3])
# max_span forces a split even when contiguous
self.assertEqual(len(_coalesce_ranges(
[(0, "a", 0, 10), (1, "a", 10, 10)], max_gap=100, max_span=15)), 2)
def test_read_ranges_coalesced(self):
from pypaimon.common.file_io import FileIO
data = bytes(range(256)) * 4 # 1024 bytes
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "f.bin")
with open(path, 'wb') as f:
f.write(data)
fio = FileIO.get(f"file://{tmp_dir}", {})
ranges = [(path, 0, 10), (path, 10, 10), None, (path, 500, 20),
(path, 100, -1), (path, None, None)]
got = fio.read_ranges_coalesced(ranges, parallelism=4)
self.assertIsInstance(got[0], bytes)
self.assertIsInstance(got[1], bytes)
self.assertEqual(got[0], data[0:10])
self.assertEqual(got[1], data[10:20]) # contiguous with got[0], merged
self.assertIsNone(got[2])
self.assertEqual(got[3], data[500:520])
self.assertEqual(got[4], data[100:]) # length -1 => read to EOF
self.assertIsNone(got[5]) # None offset/length => skipped
def test_read_ranges_coalesced_views(self):
from pypaimon.common.file_io import FileIO
data = bytes(range(256)) * 4
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "f.bin")
with open(path, 'wb') as output:
output.write(data)
file_io = FileIO.get(f"file://{tmp_dir}", {})
ranges = [(path, 0, 10), (path, 10, 10), None,
(path, 500, 20), (path, 100, -1)]
got = file_io.read_ranges_coalesced_views(
ranges, parallelism=4, max_gap=100)
self.assertIsInstance(got[0], memoryview)
self.assertIsInstance(got[1], memoryview)
self.assertEqual(bytes(got[0]), data[0:10])
self.assertEqual(bytes(got[1]), data[10:20])
self.assertIs(got[0].obj, got[1].obj)
self.assertIsNone(got[2])
self.assertEqual(bytes(got[3]), data[500:520])
self.assertIsNot(got[0].obj, got[3].obj)
self.assertEqual(bytes(got[4]), data[100:])
array = pa.array(got, type=pa.binary())
self.assertEqual(array.to_pylist(), [
data[0:10], data[10:20], None, data[500:520], data[100:],
])
def test_sparse_views_preserve_coalesced_read(self):
from pypaimon.common.file_io import FileIO
data = bytes(range(256)) * 4
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "f.bin")
with open(path, 'wb') as output:
output.write(data)
file_io = FileIO.get(f"file://{tmp_dir}", {})
reads = []
original_read = file_io.read_file_range
def read_file_range(file_path, offset, length):
reads.append((file_path, offset, length))
return original_read(file_path, offset, length)
file_io.read_file_range = read_file_range
got = file_io.read_ranges_coalesced_views(
[(path, 0, 10), (path, 1000, 10)],
parallelism=4,
max_gap=1000,
max_span=1 << 20,
)
self.assertEqual(reads, [(path, 0, 1010)])
self.assertEqual(bytes(got[0]), data[0:10])
self.assertEqual(bytes(got[1]), data[1000:1010])
self.assertIsNot(got[0].obj, got[1].obj)
reads.clear()
shared = file_io.read_ranges_coalesced_views(
[(path, 0, 10), (path, 1000, 10)],
parallelism=4,
max_gap=1000,
max_span=1 << 20,
max_retained_amplification=0,
)
self.assertEqual(reads, [(path, 0, 1010)])
self.assertIs(shared[0].obj, shared[1].obj)
class ReadFileRangeTest(unittest.TestCase):
"""read_file_range must accept length == -1 (read to EOF) -- the valid
unknown-length BlobDescriptor case -- not pass -1 into pread."""
def test_negative_length_reads_to_eof(self):
from pypaimon.common.file_io import FileIO
data = bytes(range(64))
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "blob.bin")
with open(path, 'wb') as f:
f.write(data)
file_io = FileIO.get(f"file://{tmp_dir}", {})
# -1: offset to EOF; positive: exact range (pread)
self.assertEqual(file_io.read_file_range(path, 0, -1), data)
self.assertEqual(file_io.read_file_range(path, 10, -1), data[10:])
self.assertEqual(file_io.read_file_range(path, 10, 8), data[10:18])
def test_descriptor_negative_length_roundtrip(self):
from pypaimon.common.file_io import FileIO
data = b"actual blob content"
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "blob.bin")
with open(path, 'wb') as f:
f.write(data)
file_io = FileIO.get(f"file://{tmp_dir}", {})
blob = Blob.from_bytes(BlobDescriptor(path, 0, -1).serialize(), file_io)
self.assertIsInstance(blob, BlobRef)
self.assertEqual(blob.to_data(), data)
class OffsetInputStreamTest(unittest.TestCase):
def setUp(self):
self.test_data = bytes(range(20))
self.wrapped = io.BytesIO(self.test_data)
def test_constructor(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
self.assertEqual(stream.tell(), 0)
stream.close()
def test_get_pos_and_seek(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(3)
self.assertEqual(stream.tell(), 3)
stream.seek(10)
self.assertEqual(stream.tell(), 10)
stream.seek(0)
self.assertEqual(stream.tell(), 0)
stream.close()
def test_read_single_byte(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
data = stream.read(1)
self.assertEqual(data[0], self.test_data[5])
self.assertEqual(stream.tell(), 1)
stream.seek(9)
data = stream.read(1)
self.assertEqual(data[0], self.test_data[14])
self.assertEqual(stream.tell(), 10)
stream.close()
def test_read_single_byte_at_end(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(10)
data = stream.read(1)
self.assertEqual(data, b'')
stream.close()
def test_read_byte_array(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
data = stream.read(5)
self.assertEqual(data, self.test_data[5:10])
self.assertEqual(stream.tell(), 5)
stream.close()
def test_read_byte_array_hitting_end(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(7)
data = stream.read(5)
self.assertEqual(len(data), 3)
self.assertEqual(data, self.test_data[12:15])
self.assertEqual(stream.tell(), 10)
stream.close()
def test_read_byte_array_at_end(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(10)
data = stream.read(5)
self.assertEqual(data, b'')
stream.close()
def test_read_with_unlimited_length(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, -1)
data = stream.read(10)
self.assertEqual(data, self.test_data[5:15])
self.assertEqual(stream.tell(), 10)
stream.close()
def test_seek_set_negative_raises(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
with self.assertRaises(ValueError):
stream.seek(-1, io.SEEK_SET)
stream.close()
def test_seek_cur_underflow_clamps_to_zero(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(2)
stream.seek(-5, io.SEEK_CUR)
self.assertEqual(stream.tell(), 0)
data = stream.read(1)
self.assertEqual(data[0], self.test_data[5])
stream.close()
def test_seek_end_underflow_clamps_to_zero(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, 10)
stream.seek(-20, io.SEEK_END)
self.assertEqual(stream.tell(), 0)
data = stream.read(1)
self.assertEqual(data[0], self.test_data[5])
stream.close()
def test_seek_end_underflow_unlimited_length(self):
from pypaimon.table.row.blob import OffsetInputStream
stream = OffsetInputStream(io.BytesIO(self.test_data), 5, -1)
stream.seek(-30, io.SEEK_END)
self.assertEqual(stream.tell(), 0)
data = stream.read(2)
self.assertEqual(data, self.test_data[5:7])
stream.close()
def test_seek_end_with_real_file(self):
from pypaimon.table.row.blob import OffsetInputStream
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(self.test_data)
tmp_path = tmp.name
try:
f = open(tmp_path, 'rb')
stream = OffsetInputStream(f, 5, 10)
stream.seek(0, io.SEEK_END)
self.assertEqual(stream.tell(), 10)
stream.seek(-3, io.SEEK_END)
self.assertEqual(stream.tell(), 7)
data = stream.read(3)
self.assertEqual(data, self.test_data[12:15])
stream.close()
f = open(tmp_path, 'rb')
stream = OffsetInputStream(f, 5, -1)
stream.seek(-30, io.SEEK_END)
self.assertEqual(stream.tell(), 0)
data = stream.read(2)
self.assertEqual(data, self.test_data[5:7])
stream.close()
finally:
os.remove(tmp_path)
if __name__ == '__main__':
unittest.main()