blob: 9a5251026db18d98cf46ea18eb7ce0d3d7f23718 [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.
"""MERGE conditions use the independently selected source read schema."""
from contextlib import ExitStack
import importlib.util
from unittest.mock import Mock, patch
import pyarrow as pa
import pyarrow.compute as pc
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.table.data_evolution_merge_into import WhenMatched, WhenNotMatched, source_col
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
_HAS_PYTHON_DATAFUSION = importlib.util.find_spec('datafusion') is not None
_ENGINES = [pytest.param(False, marks=pytest.mark.skipif(
not _HAS_PYTHON_DATAFUSION, reason='Python MERGE conditions require the datafusion extra')),
pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
_SCHEMA = pa.schema([('key', pa.int64()), ('value', pa.int32())])
def _append(table, key, value):
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_arrow(pa.table({'key': [key], 'value': [value]}, schema=_SCHEMA))
builder.new_commit().commit(writer.prepare_commit())
finally:
writer.close()
def _new_table(catalog, name, evolution=True):
options = {
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false', 'row-tracking.enabled': 'true',
}
if evolution:
options['data-evolution.enabled'] = 'true'
catalog.create_table(name, Schema.from_pyarrow_schema(_SCHEMA, options=options), False)
return catalog.get_table(name)
def _rows(table, metadata=()):
builder = table.new_read_builder().with_projection(list(dict.fromkeys(table.field_names + list(metadata))))
return builder.new_read().to_arrow(builder.new_scan().plan_all().splits()).to_pylist()
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('historical', [False, True], ids=['current-source', 'historical-source'])
@pytest.mark.parametrize('metadata', ['_ROW_ID', '_SEQUENCE_NUMBER'])
@pytest.mark.parametrize('action', ['matched', 'insert'])
def test_table_source_metadata_conditions_keep_selected_view_and_callable_rows(
native_rest_catalog, native, stream, historical, metadata, action):
source = _new_table(native_rest_catalog, 'default.metadata_source', evolution=False)
for key, value in [(9, 91), (8, 82), (7, 73)]:
_append(source, key, value)
source_rows = sorted(_rows(source, ['_ROW_ID', '_SEQUENCE_NUMBER']), key=lambda row: row['_ROW_ID'])
first, second, third = [row[metadata] for row in source_rows]
assert len({first, second, third}) == 3
target = _new_table(native_rest_catalog, 'default.metadata_target')
_append(target, first, 10)
if action == 'matched':
_append(target, second, 20)
source = source.copy({'scan.snapshot-id': '1'}) if historical else source.copy({})
target = target.copy({'write.native.enabled': str(native).lower()})
called = []
def assignment(batch):
called.append(batch.column('s.' + metadata).to_pylist())
return pc.add(batch.column('s.value'), pa.scalar(100, type=pa.int32()))
condition = 's.{} = {}'.format(metadata, second)
if action == 'matched':
matched = [WhenMatched.update({'value': assignment}, condition=condition),
WhenMatched.update({'value': source_col('value')})]
not_matched = []
else:
matched = [WhenMatched.update({'value': source_col('value')})]
not_matched = [WhenNotMatched({'value': assignment}, condition=condition),
WhenNotMatched({'value': source_col('value')})]
builder = target.new_stream_write_builder() if stream else target.new_batch_write_builder()
with ExitStack() as stack:
if native:
stack.enter_context(patch('pypaimon.execution.python.write.merge_into._normalize_source',
side_effect=AssertionError('Python source materialization')))
stack.enter_context(patch('pypaimon.execution.python.write.merge_into._build_tables',
side_effect=AssertionError('Python MERGE matching')))
stack.enter_context(patch('pypaimon.write.merge_condition._load_datafusion',
side_effect=AssertionError('Native conditions loaded Python DataFusion')))
messages = builder.new_update().merge_into(
source, on={'key': metadata}, when_matched=matched, when_not_matched=not_matched,
**({'commit_identifier': 71} if stream else {}))
assert called == ([] if historical else [[second]])
committer = builder.new_commit()
try:
committer.commit(messages, 71) if stream else committer.commit(messages)
finally:
committer.close()
expected = [{'key': first, 'value': 91, '_ROW_ID': 0}]
if action == 'matched':
expected.append({'key': second, 'value': 20 if historical else 182, '_ROW_ID': 1})
elif not historical:
expected.extend([{'key': second, 'value': 182, '_ROW_ID': 1},
{'key': third, 'value': 73, '_ROW_ID': 2}])
assert sorted(_rows(target, ['_ROW_ID']), key=lambda row: row['_ROW_ID']) == expected
@pytest.mark.parametrize('table_source', [False, True], ids=['arrow-source', 'table-source'])
def test_native_condition_transport_does_not_load_python_datafusion(native_rest_catalog, table_source):
target = _new_table(native_rest_catalog, 'default.no_python_engine').copy({'write.native.enabled': 'true'})
source = (_new_table(native_rest_catalog, 'default.metadata_source') if table_source else
pa.table({'key': [0], 'value': [91]}, schema=_SCHEMA))
source_key = '_ROW_ID' if table_source else 'key'
native_table, native_source = Mock(), Mock()
core = native_table.new_batch_write_builder.return_value.with_commit_user.return_value.new_update.return_value
core.merge_into.return_value = []
with patch('pypaimon.execution.native.write.native_merge_into._native_row_id_table', return_value=native_table), \
patch(
'pypaimon.execution.native.write.native_merge_into.create_native_write_table',
return_value=native_source), \
patch('pypaimon.write.merge_condition._load_datafusion',
side_effect=AssertionError('Loaded optional Python execution engine')) as python_engine:
messages = target.new_batch_write_builder().new_update().merge_into(
source, on={'key': source_key}, when_matched=[WhenMatched.update(
{'value': source_col('value')}, condition='s.{} = 0'.format(source_key))])
assert messages == []
python_engine.assert_not_called()
core.merge_into.assert_called_once()
args, kwargs = core.merge_into.call_args
assert args[0] is (native_source if table_source else source)
assert kwargs['when_matched'][0]['condition'] == {'sql': '"s.{}" = 0'.format(source_key)}
def test_python_merge_conditions_still_require_the_python_execution_engine(native_rest_catalog):
target = _new_table(native_rest_catalog, 'default.python_engine_required')
source = pa.table({'key': [0], 'value': [91]}, schema=_SCHEMA)
with patch('pypaimon.write.merge_condition._load_datafusion',
side_effect=ImportError('Install pypaimon[datafusion]')) as python_engine, \
patch('pypaimon.execution.python.write.merge_into._build_tables') as matching:
with pytest.raises(ImportError, match=r'pypaimon\[datafusion\]'):
target.new_batch_write_builder().new_update().merge_into(
source, on=['key'], when_matched=[WhenMatched.update(
{'value': source_col('value')}, condition='s.key = 0')])
python_engine.assert_called_once()
matching.assert_not_called()