blob: a15262d501231eed0624ce2121ad43b473a18778 [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.
"""Timestamp-window table sources must be planned and read by Rust core."""
import json
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.table.data_evolution_merge_into import WhenMatched, WhenNotMatched, source_col
from pypaimon.tests.native_merge_into_test import _SCHEMA, _table, _rows, _write_rows
pytestmark = pytest.mark.native_plan
def _set_time(table, timestamp):
snapshot = table.snapshot_manager().get_latest_snapshot()
path = table.snapshot_manager().get_snapshot_path(snapshot.id)
with open(path) as reader:
data = json.load(reader)
data['timeMillis'] = timestamp
with open(path, 'w') as writer:
json.dump(data, writer)
def _source(tmp_path, mode='append', options=None, schema=None):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('db', True)
config = {'write.native.enabled': 'false', 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}
if mode == 'pk':
config['bucket'] = '1'
if mode == 'evolution':
config.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true'})
config.update(options or {})
catalog.create_table('db.source', Schema.from_pyarrow_schema(
schema or _SCHEMA, primary_keys=['id'] if mode == 'pk' else [], options=config), False)
return catalog.get_table('db.source')
def _merge(target, source, stream, native, value_column='value'):
target = target.copy({'write.native.enabled': str(native).lower()})
builder = target.new_stream_write_builder() if stream else target.new_batch_write_builder()
kwargs = {'commit_identifier': 57} if stream else {}
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(source, 'snapshot_manager',
side_effect=AssertionError('Python source snapshot resolution')))
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')))
messages = builder.new_update().merge_into(
source, on=['id'], when_matched=[WhenMatched.update({'value': source_col(value_column)})],
when_not_matched=[WhenNotMatched({
'id': source_col('id'), 'value': source_col(value_column), 'name': source_col('name')})], **kwargs)
commit = builder.new_commit()
commit.commit(messages, 57) if stream else commit.commit(messages)
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('mode', ['append', 'pk', 'evolution'])
@pytest.mark.parametrize('window,expected', [
('100,200', [dict(id=1, value=11, name='old'), dict(id=2, value=22, name='insert')]),
('0,100', [dict(id=1, value=-1, name='old')]),
('200,300', [dict(id=1, value=10, name='old'), dict(id=3, value=33, name='later')]),
('100,150', [dict(id=1, value=10, name='old')]),
('100,100', [dict(id=1, value=10, name='old')]),
('350,500', [dict(id=1, value=10, name='old')]),
('0,50', [dict(id=1, value=10, name='old')]),
])
def test_timestamp_window_source_merge(tmp_path, native, stream, mode, window, expected):
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
source = _source(tmp_path / 'source', mode)
for timestamp, values in [
(100, [dict(id=1, value=-1, name='base')]),
(200, [dict(id=1, value=11, name='new'), dict(id=2, value=22, name='insert')]),
(300, [dict(id=3, value=33, name='later')])]:
_write_rows(source, _SCHEMA, values)
_set_time(source, timestamp)
source = source.copy({'scan.mode': 'incremental', 'incremental-between-timestamp': window})
_merge(target, source, stream, native)
assert _rows(target) == expected
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_incremental_source_uses_selected_branch(tmp_path, native, stream):
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
source = _source(tmp_path / 'source')
_write_rows(source, _SCHEMA, [dict(id=1, value=-1, name='base')])
_set_time(source, 100)
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
catalog.create_tag(source.identifier, 'base')
catalog.create_branch(source.identifier, 'dev', tag_name='base')
branch = catalog.get_table('db.source$branch_dev')
_write_rows(branch, _SCHEMA, [dict(id=1, value=11, name='branch'), dict(id=2, value=22, name='branch-insert')])
_set_time(branch, 200)
_write_rows(source, _SCHEMA, [dict(id=1, value=99, name='main'), dict(id=3, value=33, name='main-insert')])
_set_time(source, 200)
_merge(target, branch.copy({'incremental-between-timestamp': '100,200'}), stream, native)
assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, value=22, name='branch-insert')]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('mode', ['append', 'pk'])
def test_incremental_source_projects_evolved_field_ids(tmp_path, native, stream, mode):
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.schema.data_types import AtomicType
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
old_schema = pa.schema([_SCHEMA.field('id'), _SCHEMA.field('value')])
source = _source(tmp_path / 'source', mode, schema=old_schema)
_write_rows(source, old_schema, [dict(id=1, value=11)])
_set_time(source, 100)
catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
catalog.alter_table('db.source', [
SchemaChange.rename_column('value', 'incoming'), SchemaChange.add_column('name', AtomicType('STRING'))], False)
source = catalog.get_table('db.source')
evolved_schema = pa.schema([_SCHEMA.field('id'), pa.field('incoming', pa.int32()), _SCHEMA.field('name')])
_write_rows(source, evolved_schema, [dict(id=2, incoming=22, name='insert')])
_set_time(source, 200)
_merge(target, source.copy({'incremental-between-timestamp': '0,200'}), stream, native, 'incoming')
assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, value=22, name='insert')]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('window', ['200,100', '100', 'one,200', '100, 200', '1_00,200',
'0,9223372036854775808'])
def test_invalid_incremental_source_never_stages_target(tmp_path, native, stream, window):
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
source = _source(tmp_path / 'source')
_write_rows(source, _SCHEMA, [dict(id=1, value=11, name='new')])
_set_time(source, 100)
before_snapshot = target.snapshot_manager().get_latest_snapshot().id
before_files = {path for path in (tmp_path / 'target').rglob('*') if path.is_file()}
with pytest.raises(ValueError):
_merge(target, source.copy({'incremental-between-timestamp': window}), stream, native)
assert target.snapshot_manager().get_latest_snapshot().id == before_snapshot
assert {path for path in (tmp_path / 'target').rglob('*') if path.is_file()} == before_files
assert _rows(target) == [dict(id=1, value=10, name='old')]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_incremental_source_auto_uses_changelog(tmp_path, native, stream):
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
source = _source(tmp_path / 'source', 'pk', options={'changelog-producer': 'input'})
for time, values in [(100, [dict(id=1, value=-1, name='base')]),
(200, [dict(id=1, value=11, name='new')]),
(300, [dict(id=2, value=22, name='insert')])]:
_write_rows(source, _SCHEMA, values)
_set_time(source, time)
_merge(target, source.copy({'incremental-between-timestamp': '100,300'}), stream, native)
assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, value=22, name='insert')]
@pytest.mark.parametrize('stream', [False, True])
def test_missing_source_snapshot_does_not_stage_target(tmp_path, stream):
target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
source = _source(tmp_path / 'source')
for time in (100, 200, 300):
_write_rows(source, _SCHEMA, [dict(id=time, value=time, name='source')])
_set_time(source, time)
source.file_io.delete(source.snapshot_manager().get_snapshot_path(2), False)
before_snapshot = target.snapshot_manager().get_latest_snapshot().id
before_files = {path for path in (tmp_path / 'target').rglob('*') if path.is_file()}
with pytest.raises(ValueError, match='Snapshot 2 does not exist'):
_merge(target, source.copy({'incremental-between-timestamp': '0,300'}), stream, True)
assert target.snapshot_manager().get_latest_snapshot().id == before_snapshot
assert {path for path in (tmp_path / 'target').rglob('*') if path.is_file()} == before_files