blob: 94772635941f5e87f6d650209069995e267b1a16 [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.
"""Update commit identifiers use Java long and Rust i64 before execution."""
from types import SimpleNamespace
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.table.data_evolution_merge_into import merge_into
from pypaimon.write.table_update import StreamTableUpdate
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
_OPERATIONS = [
('new_update_by_row_id', '_new_row_id_updater', 0),
('update_by_arrow_with_row_id', '_update_by_arrow_with_row_id', 1),
('upsert_by_arrow_with_key', '_upsert_by_arrow_with_key', 2),
('upsert_by_key', '_upsert_by_key', 2),
('update_by_predicate', '_update_by_predicate', 2),
('delete_by_predicate', '_delete_by_predicate', 1),
('delete_by_row_id', '_delete_by_row_id', 1),
('merge_into', '_merge_into', 4),
]
_INVALID_IDENTIFIERS = [(1 << 63, OverflowError), (-(1 << 63) - 1, OverflowError),
(1.5, TypeError), ('7', TypeError)]
class _IndexValue:
def __init__(self, value):
self.value = value
def __index__(self):
return self.value
def _invoke(update, method, identifier):
if method == 'new_update_by_row_id':
return update.new_update_by_row_id(identifier)
if method == 'update_by_arrow_with_row_id':
return update.update_by_arrow_with_row_id(object(), identifier)
if method == 'upsert_by_arrow_with_key':
return update.upsert_by_arrow_with_key(object(), ['id'], identifier)
if method == 'upsert_by_key':
return update.upsert_by_key(object(), ['id'], identifier)
if method == 'update_by_predicate':
return update.update_by_predicate(None, {'value': 42}, identifier)
if method == 'delete_by_predicate':
return update.delete_by_predicate(None, identifier)
if method == 'delete_by_row_id':
return update.delete_by_row_id([0], identifier)
return update.merge_into(object(), on=['id'], commit_identifier=identifier)
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('identifier, error', _INVALID_IDENTIFIERS)
@pytest.mark.parametrize('method, hook, position', _OPERATIONS)
def test_stream_update_rejects_invalid_identifiers_before_delegation(native, identifier, error, method, hook, position):
table = SimpleNamespace(options=SimpleNamespace(native_write_enabled=lambda: native))
update = StreamTableUpdate(table, 'writer')
with patch.object(update, hook, side_effect=AssertionError('An invalid update reached execution')) as execute:
with pytest.raises(error):
_invoke(update, method, identifier)
execute.assert_not_called()
@pytest.mark.parametrize('identifier', [-(1 << 63), (1 << 63) - 1, _IndexValue(37)])
@pytest.mark.parametrize('method, hook, position', _OPERATIONS)
def test_stream_update_preserves_long_bounds_and_normalizes_index_values(identifier, method, hook, position):
update = StreamTableUpdate(object(), 'writer')
expected = identifier.value if isinstance(identifier, _IndexValue) else identifier
with patch.object(update, hook, return_value=['prepared']) as execute:
assert _invoke(update, method, identifier) == ['prepared']
forwarded = execute.call_args[0][position]
assert type(forwarded) is int
assert forwarded == expected
@pytest.mark.parametrize('identifier, error', _INVALID_IDENTIFIERS)
@pytest.mark.parametrize('operation', ['row-id', 'upsert', 'merge'])
def test_standalone_update_entries_reject_invalid_identifiers_before_loading_execution(identifier, error, operation):
with pytest.raises(error):
if operation == 'row-id':
TableUpdateByRowId(object(), 'writer', identifier)
elif operation == 'upsert':
TableUpsertByKey(object(), 'writer', identifier)
else:
merge_into(object(), object(), on=['id'], commit_user='writer', commit_identifier=identifier)
def test_invalid_identifier_leaves_input_callbacks_files_and_snapshot_untouched(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.records', Schema.from_pyarrow_schema(schema, options={
'bucket': '-1', 'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
}), False)
table = catalog.get_table('default.records')
batch = table.new_batch_write_builder()
writer, commit = batch.new_write(), batch.new_commit()
try:
writer.write_arrow(pa.Table.from_pydict({'id': [1, 2], 'value': [10, 20]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
builder = table.new_stream_write_builder()
update = builder.new_update()
before = set(tmp_path.rglob('*.parquet'))
class UnconsumedRows:
def __iter__(self):
raise AssertionError('Invalid identifier consumed source rows')
def assignment(_):
raise AssertionError('Invalid identifier ran an assignment')
with pytest.raises(OverflowError):
update.upsert_by_key(UnconsumedRows(), ['id'], 1 << 63)
with pytest.raises(TypeError):
update.update_by_predicate(None, {'value': assignment}, 1.5, read_columns=['value'])
assert set(tmp_path.rglob('*.parquet')) == before
assert table.snapshot_manager().get_latest_snapshot().id == 1
# A rejected call must leave the configured update usable for a valid round.
messages = update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0], 'value': [99]}), _IndexValue(37))
commit = builder.new_commit()
try:
commit.commit(messages, _IndexValue(37))
finally:
commit.close()
assert table.snapshot_manager().get_latest_snapshot().commit_identifier == 37
read = table.new_read_builder()
assert read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id').to_pylist() == [
{'id': 1, 'value': 99}, {'id': 2, 'value': 20}]