blob: 8ff6efb6cde9f2d90e89de6940b86d8fbb567bd5 [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.
"""Existing full batch startup modes must use Native planning and reading."""
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.table.data_evolution_merge_into import WhenMatched, WhenNotMatched, source_col
from pypaimon.tests import native_plan_rest_test
rest_catalog = native_plan_rest_test.rest_catalog
pytestmark = pytest.mark.native_plan
_SCHEMA = pa.schema([('id', pa.int32()), ('value', pa.string())])
def _write(table, rows):
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
def _create(catalog, name, mode):
options = {'write.native.enabled': 'false', 'source.split.target-size': '1b'}
if mode == 'pk':
options['bucket'] = '1'
elif mode == 'de':
options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'deletion-vectors.enabled': 'true'})
catalog.create_table('default.' + name, Schema.from_pyarrow_schema(
_SCHEMA, primary_keys=['id'] if mode == 'pk' else [], options=options), False)
return catalog.get_table('default.' + name)
@pytest.fixture(params=['append', 'pk', 'de'])
def source(rest_catalog, request):
catalog, _ = rest_catalog
table = _create(catalog, 'source', request.param)
_write(table, [{'id': 1, 'value': 'first'}])
first = table.snapshot_manager().get_latest_snapshot().id
_write(table, [{'id': 2, 'value': 'second'}])
return catalog, table, first
def _view(table, mode, first, native):
options = {'scan.mode': mode, 'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()}
if mode == 'from-snapshot-full':
options['scan.snapshot-id'] = str(first)
return table.copy(options)
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('mode', ['latest', 'latest-full', 'from-snapshot-full'])
@pytest.mark.parametrize('method', ['arrow', 'batch', 'iterator'])
@pytest.mark.parametrize('filtered', [False, True])
def test_full_batch_startup_reads(source, native, mode, method, filtered):
_, original, first = source
table = _view(original, mode, first, native)
builder = table.new_read_builder()
if filtered:
predicate = builder.new_predicate_builder().equal('id', 2)
builder.with_projection(['value']).with_limit(1)
builder.with_filter(predicate)
scan = builder.new_scan()
read = builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Python scan')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read')
))
plan = scan.plan()
if method == 'arrow':
rows = read.to_arrow(plan.splits()).to_pylist()
elif method == 'batch':
with read.to_arrow_batch_reader(plan.splits()) as reader:
rows = reader.read_all().to_pylist()
else:
names = ['value'] if filtered else ['id', 'value']
rows = [dict(zip(names, (row.get_field(index) for index in range(len(row)))))
for row in read.to_iterator(plan.splits())]
historical = mode == 'from-snapshot-full'
assert plan.snapshot_id == (first if historical else first + 1)
expected = ([{'value': 'second'}] if not historical else []) if filtered else (
[{'id': 1, 'value': 'first'}] + ([] if historical else [{'id': 2, 'value': 'second'}]))
assert sorted(rows, key=lambda row: row.get('id', 0)) == expected
assert table.options.scan_mode() == mode
assert original.snapshot_manager().get_latest_snapshot().id == first + 1
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('mode', ['latest', 'latest-full', 'from-snapshot-full'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch-commit', 'stream-commit'])
def test_merge_full_startup_table_source(source, native, mode, stream):
catalog, original, first = source
target = _create(catalog, 'target', 'de')
_write(target, [{'id': 1, 'value': 'old'}])
target = target.copy({'write.native.enabled': str(native).lower()})
selected = _view(original, mode, first, native)
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')))
messages = builder.new_update().merge_into(
selected, on=['id'], when_matched=[WhenMatched.update({'value': source_col('value')})],
when_not_matched=[WhenNotMatched('*')], **({'commit_identifier': 37} if stream else {}))
commit = builder.new_commit()
try:
commit.commit(messages, 37) if stream else commit.commit(messages)
finally:
commit.close()
reader = target.new_read_builder()
actual = reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
expected = [{'id': 1, 'value': 'first'}]
if mode != 'from-snapshot-full':
expected.append({'id': 2, 'value': 'second'})
assert sorted(actual, key=lambda row: row['id']) == expected
assert original.snapshot_manager().get_latest_snapshot().id == first + 1
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('mode', ['latest', 'latest-full'])
def test_full_startup_empty_table(rest_catalog, native, mode):
table = _create(rest_catalog[0], 'empty', 'append')
table = _view(table, mode, 1, native)
builder = table.new_read_builder()
scan, read = builder.new_scan(), builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Python scan')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read')
))
plan = scan.plan()
assert plan.snapshot_id is None
assert plan.splits() == []
assert read.to_arrow(plan.splits()).num_rows == 0
assert list(read.to_iterator(plan.splits())) == []
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('view', ['empty', 'snapshot', 'retained-snapshot', 'latest'])
def test_merge_keeps_captured_source_view(source, native, view):
catalog, original, first = source
snapshot = original.snapshot_manager().get_snapshot_by_id(first)
selected = (original._copy_with_snapshot(None if view == 'empty' else snapshot)
if view != 'latest' else original)
selected = selected.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
if view == 'retained-snapshot':
original.file_io.delete(original.snapshot_manager().get_snapshot_path(first))
target = _create(catalog, 'target', 'de')
_write(target, [{'id': 1, 'value': 'old'}])
target = target.copy({'write.native.enabled': str(native).lower()})
builder = 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')))
messages = builder.new_update().merge_into(selected, on=['id'], when_matched=[
WhenMatched.update({'value': source_col('value')})], when_not_matched=[WhenNotMatched('*')])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
reader = target.new_read_builder()
rows = reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
expected = [{'id': 1, 'value': 'old' if view == 'empty' else 'first'}]
if view == 'latest':
expected.append({'id': 2, 'value': 'second'})
assert sorted(rows, key=lambda row: row['id']) == expected
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('empty', [False, True])
def test_merge_unrestricted_authorized_source(source, rest_catalog, native, empty):
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.schema.schema_change import SchemaChange
catalog, original, _ = source
if empty:
original = _create(catalog, 'empty_source', 'append')
catalog.alter_table(original.identifier, [SchemaChange.set_option('query-auth.enabled', 'true')])
original = catalog.get_table(original.identifier)
selections = []
def authorize(select):
selections.append(select)
return AuthTableQueryResponse()
rest_catalog[1].table_query_auth_store[original.identifier.get_full_name()] = authorize
target = _create(catalog, 'target', 'de')
_write(target, [{'id': 1, 'value': 'old'}])
target = target.copy({'write.native.enabled': str(native).lower()})
builder = 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')))
messages = builder.new_update().merge_into(original, on=['id'], when_matched=[
WhenMatched.update({'value': source_col('value')})], when_not_matched=[WhenNotMatched('*')])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
reader = target.new_read_builder()
assert reader.new_read().to_arrow(reader.new_scan().plan().splits()).sort_by('id').to_pylist() == (
[{'id': 1, 'value': 'old'}] if empty else
[{'id': 1, 'value': 'first'}, {'id': 2, 'value': 'second'}])
assert selections and all(select is None or set(select) == set(original.field_names) for select in selections)
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('restriction', ['filter', 'mask', 'malformed', 'denied'])
@pytest.mark.parametrize('empty', [False, True])
def test_merge_authorized_source_failures_never_modify_target(rest_catalog, native, restriction, empty):
import json
from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse
from pypaimon.schema.schema_change import SchemaChange
catalog, server = rest_catalog
source = _create(catalog, 'source', 'append')
if not empty:
_write(source, [{'id': 1, 'value': 'source'}])
catalog.alter_table(source.identifier, [SchemaChange.set_option('query-auth.enabled', 'true')])
source = catalog.get_table(source.identifier)
target = _create(catalog, 'target', 'de')
_write(target, [{'id': 1, 'value': 'old'}])
target = target.copy({'write.native.enabled': str(native).lower()})
before = target.snapshot_manager().get_latest_snapshot().id
if restriction == 'mask':
response = AuthTableQueryResponse(column_masking={'value': json.dumps({
'name': 'UPPER', 'inputs': [{'index': 1, 'name': 'value', 'type': 'STRING'}]})})
elif restriction == 'filter':
response = AuthTableQueryResponse(filter=[json.dumps({
'kind': 'LEAF', 'transform': {
'name': 'FIELD_REF', 'fieldRef': {'index': 0, 'name': 'id', 'type': 'INT'}},
'function': 'GREATER_THAN', 'literals': [1]})])
elif restriction == 'malformed':
response = AuthTableQueryResponse(filter=['not-json'])
else:
response = AuthTableQueryResponse()
server.table_query_auth_store[source.identifier.get_full_name()] = response
builder = 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')))
if restriction == 'denied':
denied = server._mock_response(ErrorResponse('TABLE', 'source', 'permission denied', 403), 403)
stack.enter_context(patch.object(server, '_table_query_auth_handle', return_value=denied))
with pytest.raises(Exception) as caught:
# No value column is used by this MERGE, but a mask on that column
# still prevents plan_all from exposing the source table.
builder.new_update().merge_into(source, on=['id'], when_matched=[WhenMatched.delete()])
assert not isinstance(caught.value, AssertionError)
assert target.snapshot_manager().get_latest_snapshot().id == before
reader = target.new_read_builder()
assert reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist() == [
{'id': 1, 'value': 'old'}]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('enabled', ['on', 'yes', '1', ' true '])
def test_merge_source_preserves_enabled_authorization(rest_catalog, native, enabled):
import json
from pypaimon.api.api_response import AuthTableQueryResponse
from pypaimon.schema.schema_change import SchemaChange
catalog, server = rest_catalog
source = _create(catalog, 'source', 'append')
_write(source, [{'id': 1, 'value': 'restricted'}])
catalog.alter_table(source.identifier, [SchemaChange.set_option('query-auth.enabled', enabled)])
source = catalog.get_table(source.identifier)
assert source.options.query_auth_enabled
server.table_query_auth_store[source.identifier.get_full_name()] = AuthTableQueryResponse(filter=[json.dumps({
'kind': 'LEAF', 'transform': {
'name': 'FIELD_REF', 'fieldRef': {'index': 0, 'name': 'id', 'type': 'INT'}},
'function': 'GREATER_THAN', 'literals': [1]})])
target = _create(catalog, 'target', 'de')
_write(target, [{'id': 1, 'value': 'old'}])
before = target.snapshot_manager().get_latest_snapshot().id
builder = target.copy({'write.native.enabled': str(native).lower()}).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')))
with pytest.raises(Exception) as caught:
builder.new_update().merge_into(source, on=['id'], when_matched=[WhenMatched.delete()])
assert not isinstance(caught.value, AssertionError)
assert target.snapshot_manager().get_latest_snapshot().id == before
reader = target.new_read_builder()
assert reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist() == [
{'id': 1, 'value': 'old'}]