blob: 804755b511677d83cdbee4510f5453a94a92bee1 [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.
"""Local and distributed MERGE use the same Python action execution."""
import importlib
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.execution.python.write.merge_transform import _build_insert_transform
from pypaimon.write.merge_transform import build_delete_schema, build_update_schema
from pypaimon.write.merge_types import LiteralValue, SourceColumnRef, _NormalizedClause
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
@pytest.mark.parametrize('module', [
'pypaimon.execution.python.write.merge_transform',
'pypaimon.ray.data_evolution_merge_join',
])
def test_matched_actions_consume_first_match_and_preserve_null_fallthrough(module):
pytest.importorskip('datafusion')
if '.ray.' in module:
pytest.importorskip('ray')
execution = importlib.import_module(module)
batch = pa.table({
't._ROW_ID': pa.array([0, 1, 2, 3], type=pa.int64()),
't.value': pa.array([90, 91, 92, 93], type=pa.int32()),
's.value': pa.array([None, 5, 15, 25], type=pa.int32()),
})
calls = []
def fallback(rows):
calls.append(rows.column('t._ROW_ID').to_pylist())
return pa.array([7] * rows.num_rows, type=pa.int32())
clauses = [
_NormalizedClause(spec={}, condition='s.value > 20', delete=True),
_NormalizedClause(spec={'value': SourceColumnRef('value')}, condition='s.value > 10'),
_NormalizedClause(spec={'value': fallback}),
]
update_schema = build_update_schema(pa.schema([('value', pa.int32())]), ['value'], '_ROW_ID')
update = execution._build_matched_transform(
clauses, {}, [], ['value'], '_ROW_ID', update_schema)(batch)
delete = execution._build_matched_delete_transform(
clauses, {}, '_ROW_ID', build_delete_schema('_ROW_ID'))(batch)
assert update.to_pylist() == [
{'_ROW_ID': 2, 'value': 15}, {'_ROW_ID': 0, 'value': 7}, {'_ROW_ID': 1, 'value': 7},
]
assert delete.to_pylist() == [{'_ROW_ID': 3}]
assert calls == [[0, 1]]
def test_insert_actions_choose_first_match_across_arrow_chunks():
pytest.importorskip('datafusion')
batch = pa.table({'s.value': pa.chunked_array([
pa.array([None, 5], type=pa.int32()), pa.array([15, 25], type=pa.int32()),
])})
schema = pa.schema([('value', pa.int32())])
transform = _build_insert_transform([
_NormalizedClause(spec={'value': LiteralValue(20)}, condition='s.value >= 20'),
_NormalizedClause(spec={'value': LiteralValue(10)}, condition='s.value >= 10'),
_NormalizedClause(spec={'value': SourceColumnRef('value')}),
], ['value'], schema)
assert transform(batch).to_pylist() == [
{'value': 20}, {'value': 10}, {'value': None}, {'value': 5},
]
@pytest.mark.parametrize('action', ['update', 'delete'])
@pytest.mark.parametrize('stream', [False, True])
def test_selected_python_merge_keeps_actions_in_python(tmp_path, action, stream):
from pypaimon import CatalogFactory, Schema
from pypaimon.write.merge_types import WhenMatched, WhenNotMatched
schema = pa.schema([('id', pa.int64()), ('value', pa.int32())])
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.actions', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'deletion-vectors.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.actions')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': [1, 2], 'value': [10, 20]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
# Native may be unavailable for the whole MERGE request. Its chosen Python
# executor must not attempt another Native selection for individual actions.
table = table.copy({'write.native.enabled': 'true'})
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
commit = builder.new_commit()
source = pa.table({'id': [1, 3], 'value': [11, 30]}, schema=schema)
matched = WhenMatched.update({'value': SourceColumnRef('value')}) if action == 'update' else WhenMatched.delete()
with patch('pypaimon.write.backend_dispatch.prepare_native_merge_into', return_value=None) as select, \
patch('pypaimon.write.backend_dispatch.prepare_native_write_operation',
side_effect=AssertionError('Python MERGE tried to select Native update')) as select_update, \
patch('pypaimon.execution.native.write.native_update.create_native_row_id_delete',
side_effect=AssertionError('Python MERGE tried to select Native delete')) as select_delete, \
patch('pypaimon.execution.native.write.native_write.create_native_write',
side_effect=AssertionError('Python MERGE tried to select Native insert')) as select_insert:
try:
update = builder.new_update()
arguments = dict(on=['id'], when_matched=[matched], when_not_matched=[WhenNotMatched('*')])
if stream:
messages = update.merge_into(source, commit_identifier=7, **arguments)
commit.commit(messages, 7)
else:
messages = update.merge_into(source, **arguments)
commit.commit(messages)
finally:
commit.close()
select.assert_called_once()
select_update.assert_not_called()
select_delete.assert_not_called()
select_insert.assert_not_called()
read = table.new_read_builder()
actual = read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id').to_pylist()
expected = ([{'id': 1, 'value': 11}] if action == 'update' else []) + [
{'id': 2, 'value': 20}, {'id': 3, 'value': 30}]
assert actual == expected