blob: 72e51df1f39ff10d623f1249ea4dc39e915b8d04 [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.
"""Public file scans dispatch without constructing the other backend."""
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.catalog.catalog_exception import TableNoPermissionException
from pypaimon.execution.native.read.file_scan import from_native_file_plan
from pypaimon.execution.native.read.plan import NativeTableScan
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.execution.python.read.table_scan import PythonTableScan
from pypaimon.read.plan import Plan
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.tests import native_plan_rest_test
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.commit_message_serializer import serialize_commit_message
rest_catalog = native_plan_rest_test.rest_catalog
SCHEMA = pa.schema([('id', pa.int32()), ('pt', pa.string())])
@pytest.fixture
def table(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.files', Schema.from_pyarrow_schema(
SCHEMA, partition_keys=['pt'], options={'file.format': 'parquet'}), False)
return catalog.get_table('default.files')
def _append(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))
messages = writer.prepare_commit()
commit.commit(messages)
return messages
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('native_plan,native_read', [(True, False), (False, True), (True, True)])
@pytest.mark.parametrize('method', ['scan', 'scan_with_stats', 'plan_files'])
def test_public_file_scanner_does_not_initialize_python(table, method, native_plan, native_read):
table = table.copy({'scan.native-plan.enabled': str(native_plan).lower(),
'read.native.enabled': str(native_read).lower()})
with ExitStack() as stack:
for target in ('FileScanner', 'ManifestListManager'):
stack.enter_context(patch('pypaimon.execution.python.read.table_scan.' + target,
side_effect=AssertionError('Python execution constructed')))
stack.enter_context(patch('pypaimon.execution.native.runtime.native_runtime_available', return_value=True))
scan = table.new_read_builder().new_scan()
facade = scan.file_scanner
assert facade is scan.file_scanner
native = stack.enter_context(patch('pypaimon.execution.native.read.plan.native_plan',
return_value=Plan([], 7)))
files = stack.enter_context(patch.object(NativeTableScan, 'plan_files', return_value=(['file'], 7)))
result = getattr(facade, method)()
if method == 'plan_files':
assert result == ['file'] and facade._scanned_snapshot_id == 7
files.assert_called_once_with(False, False)
native.assert_not_called()
else:
assert (result[0] if method == 'scan_with_stats' else result).snapshot_id == 7
native.assert_called_once()
files.assert_not_called()
@pytest.mark.parametrize('skip_level0,only_real', [(False, False), (False, True), (True, False), (True, True)])
def test_native_raw_file_planning_ignores_query_result_transforms(table, skip_level0, only_real):
scan = table.new_read_builder().with_projection(['id']).with_limit(1).new_scan()
scan.with_shard(0, 3)
scan.file_scanner.with_all_buckets()
native = Mock()
raw = native.new_scan.return_value
raw.with_file_filters.return_value = raw
plan = raw.plan_files.return_value
plan.files.return_value = []
plan.snapshot_id.return_value = 9
with patch('pypaimon.execution.native.read.builder._native_read_builder', return_value=native):
assert NativeTableScan(scan).plan_files(skip_level0, only_real) == ([], 9)
raw.with_file_filters.assert_called_once_with(skip_level0, only_real)
raw.plan_files.assert_called_once_with()
native.with_projection.assert_not_called()
native.with_limit.assert_not_called()
raw.with_shard.assert_not_called()
native.with_row_ranges.assert_not_called()
def test_raw_file_dispatch_does_not_probe_query_result_capabilities(table):
table = table.copy({'scan.native-plan.enabled': 'true'})
scan = table.new_read_builder().with_limit(1).new_scan()
scanner = scan.file_scanner.with_chunk_shuffle(1, 2)
with patch.object(scan, '_native_plan_supported', side_effect=AssertionError('Query transform probe')), \
patch.object(NativeTableScan, 'plan_files', return_value=(['file'], 7)) as native:
assert scanner.plan_files() == ['file']
native.assert_called_once_with(False, False)
def test_file_filters_are_forwarded_only_after_explicit_configuration(table):
scan = table.new_read_builder().new_scan()
facade = scan.file_scanner
with patch('pypaimon.execution.native.read.plan.native_plan', return_value=Plan([], 7)) as native:
scan._try_native_plan()
assert 'file_filters' not in native.call_args[1]
facade.skip_level0 = True
facade.with_all_buckets()
scan._try_native_plan()
assert native.call_args[1]['file_filters'] == (True, False)
@pytest.mark.parametrize('assign_directly', [False, True])
def test_changed_file_predicate_is_used_by_native_planning(table, assign_directly):
scan = table.new_read_builder().new_scan()
predicate = table.new_read_builder().new_predicate_builder().equal('id', 2)
scanner = scan.file_scanner
if assign_directly:
scanner.predicate = predicate
else:
scanner._set_predicate(predicate)
with patch('pypaimon.execution.native.read.plan.native_plan', return_value=Plan([], 7)) as native:
scan._try_native_plan()
assert native.call_args[1]['predicate'] is predicate
def test_changed_file_predicate_is_included_in_query_authorization(table):
scan = table.new_read_builder().with_projection(['id']).new_scan()
requested = []
def authorize(columns):
requested.append(set(columns))
return None
scan._query_auth_fn = authorize
scan.file_scanner._set_predicate(table.new_read_builder().new_predicate_builder().equal('pt', 'a'))
scan.plan()
assert requested == [{'id', 'pt'}]
def test_changed_file_partition_selection_is_included_in_query_authorization(table):
scan = table.new_read_builder().with_projection(['id']).new_scan()
requested = []
def authorize(columns):
requested.append(set(columns))
return None
scan._query_auth_fn = authorize
scanner = scan.file_scanner
scanner.partition_key_predicate = table.new_read_builder().new_predicate_builder().equal('pt', 'b').new_index(0)
scan.plan()
assert requested == [{'id', 'pt'}]
@pytest.mark.parametrize('native_auth', [False, True])
@pytest.mark.parametrize('original_partition_filter', [False, True])
def test_clearing_partition_selector_restores_derived_pruning_without_native_fallback(
table, native_auth, original_partition_filter):
_append(table, [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}])
predicates = table.new_read_builder().new_predicate_builder()
predicate = predicates.equal('pt', 'a')
expected = table.new_read_builder().with_filter(predicate).new_scan().plan()
table = table.copy({'scan.native-plan.enabled': 'true', 'read.native.enabled': 'false',
'query-auth.enabled': str(native_auth).lower()})
builder = table.new_read_builder()
builder.with_filter(predicate).with_projection(['id'])
if original_partition_filter:
builder.with_partition_filter(predicates.equal('pt', 'b'))
scan = builder.new_scan()
scan.file_scanner.partition_key_predicate = None
request = scan._scan_request()
assert request.partition_key_predicate == predicate.new_index(0)
assert request.explicit_partition_filter is False
assert request.partition_filter is None
requested = []
def authorize(columns):
requested.append(set(columns))
return None
scan._query_auth_fn = authorize
with patch.object(scan, '_native_plan_supported', return_value=True), \
patch('pypaimon.execution.native.read.plan.native_plan',
return_value=expected) as native, \
patch('pypaimon.execution.python.read.table_scan.FileScanner',
side_effect=AssertionError('Native planning initialized Python')):
plan = scan.plan()
native.assert_called_once()
assert native.call_args[1]['predicate'] == predicate
assert 'partition_predicate' not in native.call_args[1]
assert requested == ([] if native_auth else [{'id', 'pt'}])
assert {split.partition.values[0] for split in plan.splits()} == {'a'}
assert builder.new_read().to_arrow(plan.splits()).to_pylist() == [{'id': 1}]
@pytest.mark.parametrize('original_partition_filter', [False, True])
@pytest.mark.parametrize('selected_partition', [None, 'b'])
def test_raw_file_partition_selection_overrides_the_derived_filter(
table, original_partition_filter, selected_partition):
table = table.copy({'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'})
_append(table, [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}])
builder = table.new_read_builder()
predicates = builder.new_predicate_builder()
predicate = predicates.and_predicates([
predicates.greater_than('id', 0), predicates.equal('pt', 'a')])
builder.with_filter(predicate)
if original_partition_filter:
builder.with_partition_filter(predicates.equal('pt', 'b'))
scan = builder.new_scan()
scanner = scan.file_scanner
scanner.partition_key_predicate = (
None if selected_partition is None else predicates.equal('pt', selected_partition).new_index(0))
expected = ['a'] if selected_partition is None else ['b']
assert sorted(entry.partition.values[0] for entry in scanner.plan_files()) == expected
assert scan._scan_request().read_predicate == predicate
rows = builder.new_read().to_arrow(scan.plan().splits()).to_pylist()
assert rows == ([{'id': 1, 'pt': 'a'}] if selected_partition is None else [])
native = Mock()
native.with_filter.return_value = native
native.with_partition_filter.return_value = native
raw = native.new_scan.return_value
raw.with_file_filters.return_value = raw
with patch('pypaimon.execution.native.read.builder._native_read_builder', return_value=native), \
patch('pypaimon.execution.native.read.file_scan.from_native_file_plan', return_value=([], 1)):
assert NativeTableScan(scan).plan_files(False, False) == ([], 1)
native.with_filter.assert_called_once_with({
'method': 'and', 'children': [
{'method': 'greaterThan', 'field': 'id', 'literals': [0]},
{'method': 'equal', 'field': 'pt', 'literals': ['a']}]})
if selected_partition is None:
native.with_partition_filter.assert_not_called()
else:
native.with_partition_filter.assert_called_once_with({
'method': 'equal', 'field': 'pt', 'literals': ['b']})
raw.plan_files.assert_called_once_with()
with patch('pypaimon.execution.native.read.plan.native_plan', return_value=Plan([], 1)) as plan:
result = NativeTableScan(scan).plan()
assert result.snapshot_id == 1
assert plan.call_args[1]['predicate'] == predicate
if selected_partition is not None:
assert plan.call_args[1]['partition_predicate'] == scanner.partition_key_predicate
else:
assert 'partition_predicate' not in plan.call_args[1]
@pytest.mark.parametrize('original_partition_filter', [False, True])
@pytest.mark.parametrize('selected_partition', [None, 'b'])
def test_file_partition_selection_is_independent_of_later_row_filters(
table, original_partition_filter, selected_partition):
table = table.copy({'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false'})
_append(table, [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}])
builder = table.new_read_builder()
predicates = builder.new_predicate_builder()
if original_partition_filter:
builder.with_partition_filter(predicates.equal('pt', 'b'))
scan = builder.new_scan()
scanner = scan.file_scanner
selector = None if selected_partition is None else predicates.equal('pt', selected_partition).new_index(0)
scanner.partition_key_predicate = selector
scanner.predicate = predicates.equal('pt', 'a')
assert scanner.partition_key_predicate == (
predicates.equal('pt', 'a').new_index(0) if selector is None else selector)
assert scanner._explicit_partition_filter is (selector is not None)
expected = ['a'] if selected_partition is None else ['b']
assert sorted(entry.partition.values[0] for entry in scanner.plan_files()) == expected
def test_incremental_facade_retains_level_zero_after_native_preparation_fails(tmp_path):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.files', Schema.from_pyarrow_schema(
SCHEMA, primary_keys=['id'], options={'bucket': '1', 'merge-engine': 'first-row'}), False)
table = catalog.get_table('default.files')
_append(table, [{'id': 1, 'pt': 'a'}])
selected = table.copy({'scan.native-plan.enabled': 'true',
'incremental-between-timestamp': '0,9223372036854775807'})
scan = selected.new_read_builder().new_scan()
assert scan.file_scanner.skip_level0 is False
with patch.object(scan, '_native_plan_supported', return_value=True), \
patch('pypaimon.execution.native.read.plan.native_plan', return_value=None):
plan = scan.plan()
assert len(plan.splits()) == 1
assert all(file.level == 0 for split in plan.splits() for file in split.files)
def test_python_file_scanner_configuration_can_change_after_first_plan(table):
_append(table, [{'id': 1, 'pt': 'a'}])
_append(table, [{'id': 2, 'pt': 'b'}])
scan = table.new_read_builder().new_scan()
scanner = scan.file_scanner
assert len(scanner.plan_files()) == 2
scanner._set_predicate(table.new_read_builder().new_predicate_builder().equal('pt', 'a'))
entries = scanner.plan_files()
assert len(entries) == 1 and entries[0].partition.values == ['a']
assert len(scanner.scan().splits()) == 1
assert scanner.scan_with_stats()[0].snapshot_id == 2
def test_raw_file_planning_preserves_all_files_before_limit_and_shard(table):
for i in range(3):
_append(table, [{'id': i, 'pt': str(i)}])
scanner = table.new_read_builder().with_limit(1).new_scan().with_shard(0, 3).file_scanner
assert len(scanner.plan_files()) == 3
def test_native_file_metadata_bridge_keeps_partition_sequence_and_path(table):
messages = _append(table, [{'id': 1, 'pt': '甲🙂'}])
entry_message = messages[0]
payload = serialize_commit_message(CommitMessage(
entry_message.partition, entry_message.bucket, [entry_message.new_files[0]]),
table.partition_keys_fields)
entry = SimpleNamespace(kind=0, bucket=entry_message.bucket, total_buckets=7,
_to_commit_message=lambda: SimpleNamespace(serialize=lambda: payload))
plan = SimpleNamespace(files=lambda: [entry], snapshot_id=lambda: 3)
entries, snapshot = from_native_file_plan(table, plan)
assert snapshot == 3 and len(entries) == 1
decoded = entries[0]
assert decoded.partition.values == ['甲🙂'] and decoded.total_buckets == 7
assert decoded.file.file_path == entry_message.new_files[0].file_path
assert decoded.file.max_sequence_number == entry_message.new_files[0].max_sequence_number
assert decoded.file.schema_id == entry_message.new_files[0].schema_id
@pytest.mark.parametrize('native_failure', [False, True])
def test_raw_metadata_never_falls_back_past_restricted_authorization(table, native_failure):
table = table.copy({'scan.native-plan.enabled': str(native_failure).lower()})
scan = table.new_read_builder().new_scan()
scan._query_auth_fn = object()
with patch.object(scan, '_native_plan_supported', return_value=True), \
patch('pypaimon.execution.native.table._native_query_auth_supported', return_value=True), \
patch.object(NativeTableScan, 'plan_files', return_value=None), \
patch('pypaimon.read.table_scan.authorize', return_value=object()) as authorize, \
patch.object(PythonTableScan, '_file_scanner', side_effect=AssertionError('Raw metadata leaked')):
with pytest.raises(TableNoPermissionException):
scan.file_scanner.plan_files()
assert authorize.call_args[0][2] is None # permission covers every column
@pytest.mark.native_plan
@pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed')
@pytest.mark.parametrize('policy', ['filter', 'mask', 'denied', 'unrestricted'])
def test_rest_raw_file_authorization_matches_both_backends(rest_catalog, policy):
import json
from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse
catalog, server = rest_catalog
catalog.create_table('default.files', Schema.from_pyarrow_schema(SCHEMA), False)
table = catalog.get_table('default.files')
_append(table, [{'id': 1, 'pt': 'a'}])
catalog.alter_table(table.identifier, [SchemaChange.set_option('query-auth.enabled', 'true')])
table = catalog.get_table('default.files')
if policy == 'filter':
rule = {'kind': 'LEAF', 'transform': {'name': 'FIELD_REF',
'fieldRef': {'index': 0, 'name': 'id', 'type': 'INT'}},
'function': 'GREATER_THAN', 'literals': [0]}
response = AuthTableQueryResponse(filter=[json.dumps(rule)])
elif policy == 'mask':
response = AuthTableQueryResponse(column_masking={
'pt': json.dumps({'name': 'UPPER', 'inputs': [{'index': 1, 'name': 'pt', 'type': 'STRING'}]})})
else:
response = AuthTableQueryResponse()
server.table_query_auth_store[table.identifier.get_full_name()] = response
with ExitStack() as stack:
if policy == 'denied':
denied = server._mock_response(ErrorResponse('TABLE', 'files', 'permission denied', 403), 403)
stack.enter_context(patch.object(server, '_table_query_auth_handle', return_value=denied))
for native in (False, True):
selected = table.copy({'scan.native-plan.enabled': str(native).lower()})
scanner = selected.new_read_builder().with_projection(['id']).new_scan().file_scanner
if policy == 'unrestricted':
assert len(scanner.plan_files()) == 1
else:
with pytest.raises(Exception, match='[Pp]ermission|denied|unrestricted|403'):
scanner.plan_files()