Skip to content

Commit c0ff23d

Browse files
committed
[python] Support creating primary-key BLOB tables and writing inline BLOB fields on PK tables
1 parent f3f66ea commit c0ff23d

6 files changed

Lines changed: 593 additions & 117 deletions

File tree

‎paimon-python/pypaimon/schema/schema_manager.py‎

Lines changed: 109 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -437,35 +437,125 @@ def _validate_blob_fields(
437437
)
438438
)
439439

440+
if video_fields and primary_keys:
441+
raise ValueError(
442+
f"'{CoreOptions.VIDEO_FRAME_FIELD.key()}' only supports append-only tables."
443+
)
444+
440445
if blob_field_names:
441-
required_options = {
442-
CoreOptions.ROW_TRACKING_ENABLED.key(): 'true',
443-
CoreOptions.DATA_EVOLUTION_ENABLED.key(): 'true'
444-
}
446+
if blob_field_names.intersection(partition_keys):
447+
raise ValueError(
448+
"The BLOB, ARRAY<BLOB> or MAP<X, BLOB> type column can not be "
449+
"part of partition keys."
450+
)
445451

446-
missing_options = []
447-
for key, expected_value in required_options.items():
448-
if key not in options or options[key] != expected_value:
449-
missing_options.append(f"{key}='{expected_value}'")
452+
if not primary_keys:
453+
required_options = {
454+
CoreOptions.ROW_TRACKING_ENABLED.key(): 'true',
455+
CoreOptions.DATA_EVOLUTION_ENABLED.key(): 'true'
456+
}
457+
458+
missing_options = []
459+
for key, expected_value in required_options.items():
460+
if key not in options or options[key] != expected_value:
461+
missing_options.append(f"{key}='{expected_value}'")
462+
463+
if missing_options:
464+
raise ValueError(
465+
f"Schema contains BLOB, ARRAY<BLOB> or MAP<X, BLOB> type but is "
466+
f"missing required options: "
467+
f"{', '.join(missing_options)}. "
468+
f"Please add these options to the schema."
469+
)
470+
else:
471+
_validate_primary_key_blob_fields(
472+
fields,
473+
options,
474+
core_options,
475+
primary_keys,
476+
partition_keys,
477+
blob_field_names,
478+
all_inline_fields,
479+
)
480+
481+
482+
def _validate_primary_key_blob_fields(
483+
fields: List[DataField],
484+
options: dict,
485+
core_options: CoreOptions,
486+
primary_keys: List[str],
487+
partition_keys: List[str],
488+
blob_field_names: set,
489+
all_inline_fields: set,
490+
):
491+
"""Mirror Java SchemaValidation#validatePrimaryKeyBlobKeyConfiguration and
492+
validatePrimaryKeyBlobConfiguration."""
493+
from pypaimon.common.options.core_options import ChangelogProducer, MergeEngine
494+
495+
managed_blob_fields = [
496+
field.name
497+
for field in fields
498+
if field.name in blob_field_names and field.name not in all_inline_fields
499+
]
500+
configured_bucket_key = options.get(CoreOptions.BUCKET_KEY.key())
501+
if configured_bucket_key and configured_bucket_key.strip():
502+
bucket_keys = [
503+
key.strip() for key in configured_bucket_key.split(',') if key.strip()
504+
]
505+
else:
506+
bucket_keys = [pk for pk in primary_keys if pk not in partition_keys]
450507

451-
if missing_options:
508+
if managed_blob_fields:
509+
pk_blob_fields = [f for f in managed_blob_fields if f in primary_keys]
510+
if pk_blob_fields:
452511
raise ValueError(
453-
f"Schema contains BLOB, ARRAY<BLOB> or MAP<X, BLOB> type but is "
454-
f"missing required options: "
455-
f"{', '.join(missing_options)}. "
456-
f"Please add these options to the schema."
512+
f"Managed BLOB fields cannot be primary keys: {pk_blob_fields}."
457513
)
458-
459-
if primary_keys:
514+
bucket_blob_fields = [f for f in managed_blob_fields if f in bucket_keys]
515+
if bucket_blob_fields:
460516
raise ValueError(
461-
"BLOB, ARRAY<BLOB> or MAP<X, BLOB> type is not supported with primary key."
517+
f"Managed BLOB fields cannot be bucket keys: {bucket_blob_fields}."
518+
)
519+
sequence_fields = core_options.sequence_field()
520+
sequence_blob_fields = [f for f in managed_blob_fields if f in sequence_fields]
521+
if sequence_blob_fields:
522+
raise ValueError(
523+
f"Managed BLOB fields cannot be sequence fields: {sequence_blob_fields}."
462524
)
463525

464-
if blob_field_names.intersection(partition_keys):
526+
if core_options.merge_engine() not in (
527+
MergeEngine.DEDUPLICATE,
528+
MergeEngine.PARTIAL_UPDATE,
529+
MergeEngine.FIRST_ROW,
530+
):
465531
raise ValueError(
466-
"The BLOB, ARRAY<BLOB> or MAP<X, BLOB> type column can not be "
467-
"part of partition keys."
532+
"Primary-key managed BLOB tables only support the deduplicate, "
533+
"partial-update or first-row merge engine."
534+
)
535+
if core_options.changelog_producer() != ChangelogProducer.NONE:
536+
raise ValueError(
537+
"Primary-key managed BLOB tables only support changelog-producer 'none'."
468538
)
539+
if core_options.data_file_external_paths() is not None:
540+
raise ValueError(
541+
f"Primary-key managed BLOB tables do not support "
542+
f"'{CoreOptions.DATA_FILE_EXTERNAL_PATHS.key()}'."
543+
)
544+
if str(options.get('pk-clustering-override', 'false')).lower() == 'true':
545+
raise ValueError(
546+
"Primary-key managed BLOB tables do not support 'pk-clustering-override'."
547+
)
548+
549+
if blob_field_names.intersection(primary_keys):
550+
raise ValueError(
551+
"The BLOB, ARRAY<BLOB> or MAP<X, BLOB> type column can not be "
552+
"part of primary keys."
553+
)
554+
if blob_field_names.intersection(bucket_keys):
555+
raise ValueError(
556+
"The BLOB, ARRAY<BLOB> or MAP<X, BLOB> type column can not be "
557+
"part of bucket keys."
558+
)
469559

470560

471561
def _validate_options(options: dict):

‎paimon-python/pypaimon/tests/blob_table_test.py‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5811,6 +5811,219 @@ def test_blob_view_as_descriptor_projection_with_predicate_extra_field(self):
58115811
)
58125812
self.assertFalse(BlobViewStruct.is_blob_view_struct(picture_bytes))
58135813

5814+
def test_primary_key_blob_descriptor_field_write_and_upsert(self):
5815+
from pypaimon import Schema
5816+
from pypaimon.table.row.blob import Blob, BlobDescriptor
5817+
from pypaimon.table.row.generic_row import GenericRow
5818+
5819+
blob_file = os.path.join(self.temp_dir, 'pk_descriptor_payloads.bin')
5820+
with open(blob_file, 'wb') as f:
5821+
f.write(b'payload-v1payload-v2payload-row-3')
5822+
5823+
desc_v1 = BlobDescriptor(blob_file, 0, 10)
5824+
desc_v2 = BlobDescriptor(blob_file, 10, 10)
5825+
desc_row3 = BlobDescriptor(blob_file, 20, 13)
5826+
5827+
pa_schema = pa.schema([
5828+
pa.field('id', pa.int32(), nullable=False),
5829+
pa.field('name', pa.string()),
5830+
pa.field('picture', pa.large_binary()),
5831+
])
5832+
schema = Schema.from_pyarrow_schema(
5833+
pa_schema,
5834+
primary_keys=['id'],
5835+
options={
5836+
'bucket': '1',
5837+
'blob-descriptor-field': 'picture',
5838+
},
5839+
)
5840+
self.catalog.create_table('test_db.pk_blob_descriptor_table', schema, False)
5841+
table = self.catalog.get_table('test_db.pk_blob_descriptor_table')
5842+
5843+
# 1. Invalid descriptor payload is rejected by the PK writer
5844+
write_builder = table.new_batch_write_builder()
5845+
bad_writer = write_builder.new_write()
5846+
with self.assertRaisesRegex(
5847+
ValueError,
5848+
"blob-descriptor-field requires blob field value to be a serialized BlobDescriptor.",
5849+
):
5850+
bad_writer.write_arrow(pa.Table.from_pydict({
5851+
'id': [1],
5852+
'name': ['bad'],
5853+
'picture': [b'not-a-descriptor'],
5854+
}, schema=pa_schema))
5855+
bad_writer.close()
5856+
5857+
# 2. Initial commit via write_arrow + write_row
5858+
writer1 = write_builder.new_write()
5859+
writer1.write_arrow(pa.Table.from_pydict({
5860+
'id': [1, 2],
5861+
'name': ['alice-v1', 'bob'],
5862+
'picture': [desc_v1.serialize(), None],
5863+
}, schema=pa_schema))
5864+
writer1.write_row(GenericRow(
5865+
[
5866+
3,
5867+
'charlie',
5868+
Blob.from_descriptor_bytes(
5869+
desc_row3.serialize(), file_io=table.file_io
5870+
),
5871+
],
5872+
table.fields,
5873+
))
5874+
write_builder.new_commit().commit(writer1.prepare_commit())
5875+
writer1.close()
5876+
5877+
# 3. Second commit upserting id=1 with desc_v2
5878+
writer2 = write_builder.new_write()
5879+
writer2.write_arrow(pa.Table.from_pydict({
5880+
'id': [1],
5881+
'name': ['alice-v2'],
5882+
'picture': [desc_v2.serialize()],
5883+
}, schema=pa_schema))
5884+
write_builder.new_commit().commit(writer2.prepare_commit())
5885+
writer2.close()
5886+
5887+
read_builder = table.new_read_builder()
5888+
result = read_builder.new_read().to_arrow(
5889+
read_builder.new_scan().plan().splits()
5890+
).sort_by('id')
5891+
self.assertEqual(result.column('id').to_pylist(), [1, 2, 3])
5892+
self.assertEqual(
5893+
result.column('name').to_pylist(),
5894+
['alice-v2', 'bob', 'charlie'],
5895+
)
5896+
self.assertEqual(
5897+
result.column('picture').to_pylist(),
5898+
[b'payload-v2', None, b'payload-row-3'],
5899+
)
5900+
5901+
def test_primary_key_blob_view_field_write_and_upsert(self):
5902+
from pypaimon import Schema
5903+
from pypaimon.table.row.blob import Blob, BlobViewStruct
5904+
from pypaimon.table.row.generic_row import GenericRow
5905+
5906+
source_schema = pa.schema([
5907+
('id', pa.int32()),
5908+
('picture', pa.large_binary()),
5909+
])
5910+
source = Schema.from_pyarrow_schema(
5911+
source_schema,
5912+
options={
5913+
'row-tracking.enabled': 'true',
5914+
'data-evolution.enabled': 'true',
5915+
},
5916+
)
5917+
self.catalog.create_table('test_db.pk_blob_view_source', source, False)
5918+
source_table = self.catalog.get_table('test_db.pk_blob_view_source')
5919+
5920+
src_wb = source_table.new_batch_write_builder()
5921+
src_writer = src_wb.new_write()
5922+
src_writer.write_arrow(pa.Table.from_pydict({
5923+
'id': [10, 20, 30],
5924+
'picture': [b'upstream-0', b'upstream-1', b'upstream-2'],
5925+
}, schema=source_schema))
5926+
src_wb.new_commit().commit(src_writer.prepare_commit())
5927+
src_writer.close()
5928+
5929+
target_schema = pa.schema([
5930+
pa.field('id', pa.int32(), nullable=False),
5931+
pa.field('label', pa.string()),
5932+
pa.field('picture', pa.large_binary()),
5933+
])
5934+
target = Schema.from_pyarrow_schema(
5935+
target_schema,
5936+
primary_keys=['id'],
5937+
options={
5938+
'bucket': '1',
5939+
'blob-view-field': 'picture',
5940+
},
5941+
)
5942+
self.catalog.create_table('test_db.pk_blob_view_target', target, False)
5943+
target_table = self.catalog.get_table('test_db.pk_blob_view_target')
5944+
5945+
view_0 = BlobViewStruct.of('test_db.pk_blob_view_source', source_table, 'picture', 0)
5946+
view_1 = BlobViewStruct.of('test_db.pk_blob_view_source', source_table, 'picture', 1)
5947+
view_2 = BlobViewStruct.of('test_db.pk_blob_view_source', source_table, 'picture', 2)
5948+
5949+
# 1. Invalid BlobViewStruct bytes are rejected by the PK writer
5950+
target_wb = target_table.new_batch_write_builder()
5951+
bad_writer = target_wb.new_write()
5952+
with self.assertRaisesRegex(
5953+
ValueError,
5954+
"blob-view-field requires blob field value to be a serialized BlobViewStruct.",
5955+
):
5956+
bad_writer.write_arrow(pa.Table.from_pydict({
5957+
'id': [1],
5958+
'label': ['bad'],
5959+
'picture': [b'invalid-view-bytes'],
5960+
}, schema=target_schema))
5961+
bad_writer.close()
5962+
5963+
# 2. First commit via write_arrow + write_row
5964+
writer1 = target_wb.new_write()
5965+
writer1.write_arrow(pa.Table.from_pydict({
5966+
'id': [1, 2],
5967+
'label': ['row1-v1', 'row2'],
5968+
'picture': [view_0.serialize(), None],
5969+
}, schema=target_schema))
5970+
writer1.write_row(GenericRow(
5971+
[3, 'row3', Blob.from_view(view_2)],
5972+
target_table.fields,
5973+
))
5974+
target_wb.new_commit().commit(writer1.prepare_commit())
5975+
writer1.close()
5976+
5977+
# 3. Second commit upserting id=1 to point to upstream row 1
5978+
writer2 = target_wb.new_write()
5979+
writer2.write_arrow(pa.Table.from_pydict({
5980+
'id': [1],
5981+
'label': ['row1-v2'],
5982+
'picture': [view_1.serialize()],
5983+
}, schema=target_schema))
5984+
target_wb.new_commit().commit(writer2.prepare_commit())
5985+
writer2.close()
5986+
5987+
read_builder = target_table.new_read_builder()
5988+
result = read_builder.new_read().to_arrow(
5989+
read_builder.new_scan().plan().splits()
5990+
).sort_by('id')
5991+
self.assertEqual(result.column('id').to_pylist(), [1, 2, 3])
5992+
self.assertEqual(
5993+
result.column('label').to_pylist(),
5994+
['row1-v2', 'row2', 'row3'],
5995+
)
5996+
self.assertEqual(
5997+
result.column('picture').to_pylist(),
5998+
[b'upstream-1', None, b'upstream-2'],
5999+
)
6000+
6001+
def test_primary_key_managed_blob_requires_native_writer(self):
6002+
from pypaimon import Schema
6003+
6004+
pa_schema = pa.schema([
6005+
pa.field('id', pa.int32(), nullable=False),
6006+
pa.field('picture', pa.large_binary()),
6007+
])
6008+
schema = Schema.from_pyarrow_schema(
6009+
pa_schema,
6010+
primary_keys=['id'],
6011+
options={'bucket': '1'},
6012+
)
6013+
self.catalog.create_table('test_db.pk_managed_blob_table', schema, False)
6014+
table = self.catalog.get_table('test_db.pk_managed_blob_table')
6015+
6016+
writer = table.new_batch_write_builder().new_write()
6017+
with self.assertRaisesRegex(
6018+
NotImplementedError,
6019+
"Primary-key Blob writes require the native writer",
6020+
):
6021+
writer.write_arrow(pa.Table.from_pydict({
6022+
'id': [1],
6023+
'picture': [b'raw-blob-bytes'],
6024+
}, schema=pa_schema))
6025+
writer.close()
6026+
58146027

58156028
class GetBlobTest(unittest.TestCase):
58166029

0 commit comments

Comments
 (0)