blob: 6f0f5497c5356d73752dc9df6cd144858fdccd12 [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.
"""Native partial updates retain Java's shredded VARIANT physical layout."""
from pypaimon.execution.python.write.table_update_by_row_id import PythonTableUpdateByRowId
import json
from contextlib import ExitStack, contextmanager
from unittest.mock import patch
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.data.generic_variant import GenericVariant
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.execution.native.write.native_update import NativeTableUpdateByRowId
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.execution.python.write.table_update import PythonTableUpdate
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
pytestmark = pytest.mark.native_plan
def _variants(values):
return GenericVariant.to_arrow_array([
GenericVariant.from_python(value) if value is not None else None for value in values])
def _table(tmp_path, key, enabled, native_write=True):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('payload', _variants([]).type),
('unchanged', _variants([]).type), ('value', pa.int32())])
shredding = json.dumps({'type': 'ROW', 'fields': [
{'name': name, 'type': {'type': 'ROW', 'fields': [
{'name': 'count', 'type': 'BIGINT'}, {'name': 'name', 'type': 'STRING'},
]}} for name in ('payload', 'unchanged')
]})
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': str(native_write).lower(), 'target-file-row-num': '2',
# This Python-only option does not exist in Java. It must not disable
# a configured physical schema or reassembly on either reader.
'variant.shredding.enabled': str(enabled).lower(), key: shredding,
}), False)
table = catalog.get_table('default.t')
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
original = [{'count': i, 'name': 'old', 'overflow': [i, None]} for i in range(4)]
unchanged = [{'count': i + 10, 'name': 'untouched', 'extra': True} for i in range(4)]
data = pa.table({'id': [0, 1, 2, 3], 'payload': _variants(original),
'unchanged': _variants(unchanged), 'value': [10, 11, 12, 13]}, schema=schema)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite) == native_write
try:
# Rolling occurs at batch boundaries. Keep the two incremental calls
# in distinct file groups, as required by the public updater contract.
writer.write_arrow(data.slice(0, 2))
writer.write_arrow(data.slice(2))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table, schema, original, unchanged
def _read(table, native, projection=None):
table = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
builder = table.new_read_builder()
if projection is not None:
builder.with_projection(projection)
splits = builder.new_scan().plan().splits()
read = builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')
))
data = read.to_arrow(splits).sort_by('id')
rows = data.to_pylist()
for row in rows:
for name in ('payload', 'unchanged'):
if name in row and row[name] is not None:
row[name] = GenericVariant.from_arrow_struct(row[name]).to_python()
return rows
@contextmanager
def _native_only():
with ExitStack() as stack:
for cls, method in [(PythonTableUpdateByRowId, '_load_existing_files_info'),
(PythonTableUpdate, '_build_predicate_update_table'),
(PythonTableUpsert, '_upsert_partition')]:
stack.enter_context(patch.object(cls, method, side_effect=AssertionError('Python update fallback')))
yield
def test_python_configured_variant_schema_uses_java_semantics(tmp_path):
table, _, original, unchanged = _table(tmp_path, 'variant.shreddingSchema', False, native_write=False)
for native in (False, True):
rows = _read(table, native)
assert [row['payload'] for row in rows] == original
assert [row['unchanged'] for row in rows] == unchanged
files = table.new_read_builder().new_scan().plan().splits()[0].files
for file in files:
physical = pq.read_schema(file.file_path)
assert 'typed_value' in [field.name for field in physical.field('payload').type]
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('key,enabled', [
('variant.shreddingSchema', True), ('parquet.variant.shreddingSchema', True),
('variant.shreddingSchema', False),
])
@pytest.mark.parametrize('operation', ['row_id', 'incremental', 'predicate', 'upsert', 'ordinary_column'])
def test_native_variant_partial_updates(tmp_path, stream, key, enabled, operation):
table, schema, original, untouched = _table(tmp_path, key, enabled)
ids = {row['id']: row['_ROW_ID'] for row in _read(table, True, ['id', '_ROW_ID'])}
replacement = {'count': 100, 'name': 'new', 'overflow': {'nested': [1, None]}}
changed = _variants([replacement, None])
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update().with_update_type(['payload'])
data = pa.table({'_ROW_ID': [ids[0], ids[2]], 'payload': changed})
with _native_only():
if operation == 'row_id':
messages = (update.update_by_arrow_with_row_id(data, 7) if stream
else update.update_by_arrow_with_row_id(data))
elif operation == 'incremental':
updater = update.new_update_by_row_id(7) if stream else update.new_update_by_row_id()
assert isinstance(updater._execution, NativeTableUpdateByRowId)
updater.update_columns(data.slice(0, 1), ['payload'])
updater.update_columns(data.slice(1), ['payload'])
messages = updater.commit_messages
elif operation == 'predicate':
predicate = PredicateBuilder(table.fields).is_in('id', [0, 2])
def assignment(matched):
return _variants([replacement if index == 0 else None
for index in matched['id'].to_pylist()])
args = (predicate, {'payload': assignment})
messages = (update.update_by_predicate(*args, 7, read_columns=['id']) if stream
else update.update_by_predicate(*args, read_columns=['id']))
elif operation == 'ordinary_column':
update = builder.new_update().with_update_type(['value'])
data = data.drop(['payload']).append_column('value', pa.array([100, 200], type=pa.int32()))
messages = (update.update_by_arrow_with_row_id(data, 7) if stream
else update.update_by_arrow_with_row_id(data))
else:
source = pa.table({'id': [0, 2, 4], 'payload': _variants([replacement, None, [1, 2]]),
'unchanged': _variants([None, None, {'count': 14, 'name': 'untouched'}]),
'value': [999, 999, 14]}, schema=schema)
messages = (update.upsert_by_arrow_with_key(source, ['id'], 7) if stream
else update.upsert_by_arrow_with_key(source, ['id']))
assert messages
for message in messages:
for file in message.new_files:
physical = pq.read_schema(file.file_path)
if 'payload' in physical.names:
names = [field.name for field in physical.field('payload').type]
assert 'typed_value' in names
if file.write_cols == ['value']:
assert physical.names == ['value']
commit = builder.new_commit()
try:
commit.commit(messages, 7) if stream else commit.commit(messages)
finally:
commit.close()
expected_payload = original if operation == 'ordinary_column' else [replacement, original[1], None, original[3]]
expected_values = [100, 11, 200, 13] if operation == 'ordinary_column' else [10, 11, 12, 13]
if operation == 'upsert':
expected_payload = expected_payload + [[1, 2]]
expected_values += [14]
untouched = untouched + [{'count': 14, 'name': 'untouched'}]
for native in (False, True):
rows = _read(table, native)
assert [row['payload'] for row in rows] == expected_payload
assert [row['unchanged'] for row in rows] == untouched
assert [row['value'] for row in rows] == expected_values