blob: ef7a980437f9678ad444cd3c664253835e5f3ab6 [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.
"""Whole-operation native hybrid execution against REST, vector and text indexes."""
from contextlib import ExitStack, contextmanager
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.api.api_response import ErrorResponse, GetTableSnapshotResponse
from pypaimon.multimodal.table import MultimodalTable, text_route, vector_route
from pypaimon.execution.native.table import (_native_table)
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.execution.native.read.builder import (_predicate_to_native)
from pypaimon.snapshot.table_snapshot import TableSnapshot
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.table.source.hybrid_search_builder import HybridSearchBuilderImpl
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult, ScoredGlobalIndexResult
from pypaimon.table.source.hybrid_search_builder import HybridSearchRoute, HybridSearchRouteBuilder
from pypaimon.tests import native_sorted_index_build_test as indexes
from pypaimon.tests.native_full_text_search_test import _scores
from pypaimon.tests.vector_search_filter_test import match_query, boost_query
rest_catalog = indexes.rest_catalog
pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')]
SCHEMA = pa.schema([('id', pa.int32()), ('content', pa.string()),
('embedding', pa.list_(pa.float32())), ('label', pa.string()), ('pt', pa.int32())])
def _rows(start=0, count=8):
return [{'id': i, 'content': None if i == 3 else ('paimon lake' if i % 2 else 'paimon paimon paimon'),
'embedding': None if i == 3 else [float(i), 0.],
'label': 'keep' if i % 2 else 'drop', 'pt': i // 4} for i in range(start, start + count)]
def _table(catalog, options=None, indexed=True, partial=False):
settings = {'read.native.enabled': 'true', 'vector-index.search-mode': 'full',
'full-text-index.search-mode': 'full', 'scalar-index.search-mode': 'full'}
settings.update(options or {})
table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options=settings)
indexes._append(table, _rows(), schema=SCHEMA)
if indexed:
table.create_global_index('content', index_type='full-text')
table.create_global_index('embedding', index_type='ivf-flat', options={
'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 'ivf-flat.distance.metric': 'l2'})
if partial:
indexes._append(table, _rows(8, 4), schema=SCHEMA)
return table
def _builder(table, ranker='rrf', limit=16, route_limit=16, routes='mixed', predicate=None, partition=None):
builder = table.new_hybrid_search_builder().with_limit(limit).with_ranker(ranker)
if routes in ('mixed', 'vector', 'many'):
builder.add_vector_route('embedding', [0., 0.], route_limit, weight=1.25,
options={'ivf.nprobe': '1'})
if routes in ('mixed', 'text', 'many'):
builder.add_full_text_route('content', match_query('paimon'), route_limit, weight=0.5)
if routes == 'many':
# Exceed the worker bound, with distinct route limits, weights and queries.
for i in range(5):
builder.add_vector_route('embedding', [float(i + 1), 0.], i + 1, weight=float(i + 1))
if predicate is not None:
builder.with_filter(predicate)
if partition is not None:
builder.with_partition_filter(partition)
return builder
@contextmanager
def _core_only():
# Block Python routing AND fusion: individual native routes alone do not satisfy this contract.
with patch.object(HybridSearchBuilderImpl, 'route_builders', side_effect=AssertionError('Python routing')), \
patch.object(HybridSearchBuilderImpl, 'rank', side_effect=AssertionError('Python fusion')):
yield
def _compare(table, **kwargs):
classic = table.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
expected = _scores(_builder(classic, **kwargs).execute_local())
with _core_only():
actual = _scores(_builder(table, **kwargs).execute_local())
assert set(actual) == set(expected)
assert actual == pytest.approx(expected, abs=1e-6, rel=1e-6)
return actual
@pytest.mark.parametrize('routes', ['vector', 'text', 'mixed', 'many'])
@pytest.mark.parametrize('ranker', ['rrf', 'weighted_score', 'mrr'])
@pytest.mark.parametrize('limit', [2, 16])
def test_routes_weights_limits_and_fusion_match_existing_python(rest_catalog, routes, ranker, limit):
catalog, _ = rest_catalog
table = _table(catalog)
scores = _compare(table, ranker=ranker, routes=routes, limit=limit, route_limit=4)
assert len(scores) <= limit
assert scores
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
@pytest.mark.parametrize('scalar_index', [None, 'btree', 'bitmap'])
def test_partial_coverage_and_scalar_modes_apply_to_each_route(rest_catalog, mode, scalar_index):
catalog, _ = rest_catalog
table = _table(catalog, {'vector-index.search-mode': mode, 'full-text-index.search-mode': mode,
'scalar-index.search-mode': mode})
if scalar_index is not None:
table.create_global_index('label', index_type=scalar_index)
indexes._append(table, _rows(8, 4), schema=SCHEMA)
predicate = table.new_read_builder().new_predicate_builder().equal('label', 'keep')
scores = _compare(table, predicate=predicate)
# Vector FAST evaluates raw rows when no scalar index can supply a mask;
# full-text FAST excludes them. Hybrid preserves each route's behavior.
assert set(scores) == ({1, 5, 7} if mode == 'fast' else {1, 5, 7, 9, 11})
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('index_type', ['btree', 'bitmap'])
@pytest.mark.parametrize('refine', [False, True])
def test_candidate_refinement_uses_java_semantics_in_both_routes(rest_catalog, index_type, refine):
catalog, _ = rest_catalog
table = _table(catalog, {'global-index.filter.refine-from-data': str(refine).lower()})
table.create_global_index('label', index_type=index_type)
indexes._append(table, _rows(8, 4), schema=SCHEMA)
pb = table.new_read_builder().new_predicate_builder()
predicate = pb.contains('label', 'eep')
assert set(_compare(table, predicate=predicate)) == ({1, 5, 7, 9, 11} if refine else {9, 11})
assert indexes._ids(table) == list(range(12))
def test_accumulated_data_and_partition_filters_precede_route_top_k(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog)
pb = table.new_read_builder().new_predicate_builder()
def configure(table):
return (_builder(table, route_limit=1).with_filter(pb.equal('label', 'keep'))
.with_filter(pb.and_predicates([pb.equal('pt', 1), pb.greater_than('id', 4)]))
.with_partition_filter(pb.less_than('pt', 2)).with_filter(pb.less_than('id', 6)))
classic = table.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
expected = _scores(configure(classic).execute_local())
with _core_only():
actual = _scores(configure(table).execute_local())
assert actual == pytest.approx({5: 1.75 / 61.}, abs=1e-6)
assert actual == pytest.approx(expected, abs=1e-6)
# A second partition predicate accumulates rather than replacing the first.
with _core_only():
assert _scores(configure(table).with_partition_filter(pb.equal('pt', 0)).execute_local()) == {}
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('compound', ['and', 'or'])
def test_compound_filters_and_empty_partition_results(rest_catalog, compound):
catalog, _ = rest_catalog
table = _table(catalog)
pb = table.new_read_builder().new_predicate_builder()
parts = [pb.equal('pt', 1), pb.equal('label', 'keep')]
predicate = pb.and_predicates(parts) if compound == 'and' else pb.or_predicates(parts)
assert set(_compare(table, predicate=predicate)) == ({5, 7} if compound == 'and' else {1, 4, 5, 6, 7})
assert _compare(table, partition=pb.equal('pt', 99)) == {}
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('mode', ['full', 'detail'])
def test_raw_routes_and_boost_dsl_with_typed_analyzers(rest_catalog, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode, 'full-text.tokenizer': ' NGRAM ',
'full-text.ngram-min-size': ' 2 ', 'full-text.ngram-max-size': ' 3 ',
'full-text.ngram-prefix-only': ' TRUE ', 'full-text.lowercase': ' TRUE '},
indexed=False)
table.create_global_index('content', index_type='full-text')
indexes._append(table, _rows(8, 4), schema=SCHEMA)
def builder(table):
return (table.new_hybrid_search_builder().add_full_text_route('content', boost_query(
match_query('PAIMON'), match_query('LAKE'), 0.1), 8).with_limit(8).with_weighted_score_ranker())
expected = _scores(builder(table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'})).execute_local())
assert expected and set(expected).intersection({8, 9, 10, 11})
with _core_only():
assert _scores(builder(table).execute_local()) == pytest.approx(expected, abs=1e-6)
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('response', ['first', 'empty', 'error'])
def test_one_authoritative_rest_snapshot_for_all_routes_without_retry(rest_catalog, response):
catalog, server = rest_catalog
table = _table(catalog)
first = table.snapshot_manager().get_latest_snapshot()
indexes._append(table, _rows(8, 4), schema=SCHEMA)
if response == 'error':
# Use a non-retryable HTTP status to isolate repeated snapshot lookup
# by the search operation from the client's legitimate 503 retries.
reply, code = ErrorResponse('TABLE', 'indexes', 'snapshot unavailable', 500), 500
else:
reply = (GetTableSnapshotResponse(TableSnapshot(first, 1, 0, 7, first.time_millis))
if response == 'first' else GetTableSnapshotResponse())
code = 200
with patch.object(server, '_table_snapshot_handle', return_value=server._mock_response(reply, code)) as latest:
with _core_only():
if response == 'error':
with pytest.raises(Exception, match='snapshot unavailable'):
_builder(table).execute_local()
else:
assert set(_scores(_builder(table, routes='many').execute_local())) == (
{0, 1, 2, 4, 5, 6, 7} if response == 'first' else set())
# Includes the empty response; routes must not independently resolve latest.
assert latest.call_count == 1
assert indexes._ids(table) == list(range(12))
def test_missing_index_errors_propagate_without_python_retry(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog)
text_file = next(file for file in indexes._files(table) if file.index_type == 'full-text')
table.file_io.delete(indexes._path(table, text_file))
with _core_only(), pytest.raises(Exception):
_builder(table).execute_local()
assert indexes._ids(table) == list(range(8))
def test_core_binding_rejects_invalid_configuration_before_empty_search(rest_catalog):
catalog, _ = rest_catalog
table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options={'read.native.enabled': 'true'})
native = _native_table(table)
with pytest.raises(Exception, match='Routes cannot be empty'):
native.new_hybrid_search_builder().with_limit(2).execute()
with pytest.raises(Exception, match='Limit must be positive'):
native.new_hybrid_search_builder().add_vector_route('embedding', [0., 0.], 2).execute()
with pytest.raises(Exception, match='Unsupported hybrid ranker'):
native.new_hybrid_search_builder().with_ranker('unknown')
with pytest.raises(Exception, match='Limit must be positive'):
native.new_hybrid_search_builder().add_vector_route('embedding', [0., 0.], 0)
with pytest.raises(Exception, match='Weight must be finite and positive'):
native.new_hybrid_search_builder().add_vector_route('embedding', [0., 0.], 2, 0.)
with pytest.raises(Exception, match='options are not supported'):
(native.new_hybrid_search_builder()
.add_full_text_route('content', match_query('paimon'), 2, options={'unknown': '1'}))
pb = table.new_read_builder().new_predicate_builder()
with pytest.raises(Exception, match='Partition filter must reference only partition keys'):
native.new_hybrid_search_builder().with_partition_filter(_predicate_to_native(pb.equal('id', 1)))
assert indexes._ids(table) == []
# Java parses DSL only when evaluating a nonempty corpus.
indexes._append(table, _rows(), schema=SCHEMA)
table.create_global_index('content', index_type='full-text')
for query in ('plain text', '{bad json'):
with _core_only(), pytest.raises(Exception):
table.new_hybrid_search_builder().add_full_text_route('content', query, 2).with_limit(2).execute_local()
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('native', [False, True])
def test_manual_route_and_rank_api_remains_available(rest_catalog, native):
catalog, _ = rest_catalog
table = _table(catalog, {'read.native.enabled': str(native).lower()})
builder = _builder(table)
with ExitStack() as stack:
if native:
for phase in ('route_builders', 'rank'):
stack.enter_context(patch('pypaimon.execution.python.read.search.hybrid_search.' + phase,
side_effect=AssertionError('Python hybrid ' + phase)))
routes = builder.route_builders()
results = [builder.to_route_result(route, route.execute_local()) for route in routes]
assert _scores(builder.rank(results)) == pytest.approx(_scores(builder.execute_local()), abs=1e-6)
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('ranker', ['rrf', 'weighted_score', 'mrr'])
def test_rank_external_results_does_not_access_table_or_validate_unused_queries(rest_catalog, ranker):
catalog, _ = rest_catalog
table = _table(catalog, indexed=False).copy({'query-auth.enabled': 'true'})
builder = _builder(table, ranker=ranker, limit=2)
# Route metadata is not a request to execute these queries. Both empty
# vectors and empty text queries are valid public route descriptions.
routes = [HybridSearchRouteBuilder(HybridSearchRoute.vector_route('absent', [], 10, weight=2.0), None),
HybridSearchRouteBuilder(HybridSearchRoute.full_text_route('absent', '', 10), None)]
results = [builder.to_route_result(routes[0], DictBasedScoredIndexResult({1: 10., 2: None, 3: 5.})),
builder.to_route_result(routes[1], DictBasedScoredIndexResult({2: 50., 3: 50.}))]
classic = _builder(table.copy({'read.native.enabled': 'false'}), ranker=ranker, limit=1)
with patch('pypaimon.execution.native.read.native_hybrid_search._native_table',
side_effect=AssertionError('Rank accessed table metadata')), \
patch('pypaimon.execution.python.read.search.hybrid_search.rank',
side_effect=AssertionError('Native rank used Python fusion')):
actual = _scores(builder.rank(results))
assert _scores(builder.rank([])) == {}
builder.with_limit(1)
reduced = _scores(builder.rank([results[1], results[0], results[0]]))
expected = classic.with_limit(2).rank(results)
assert actual == pytest.approx(_scores(expected), abs=1e-6)
classic.with_limit(1)
assert reduced == pytest.approx(_scores(classic.rank([results[1], results[0], results[0]])), abs=1e-6)
assert all(isinstance(result.result, ScoredGlobalIndexResult) for result in results)
def test_manual_routes_pin_one_snapshot_and_keep_public_scan_read_api(rest_catalog):
catalog, server = rest_catalog
table = _table(catalog)
builder = _builder(table)
with patch.object(server, '_table_snapshot_handle', wraps=server._table_snapshot_handle) as latest, \
patch('pypaimon.execution.python.read.search.hybrid_search.route_builders',
side_effect=AssertionError('Python route capture')):
routes = builder.route_builders()
assert latest.call_count == 1
snapshots = [route.search_builder._table._read_snapshot for route in routes]
assert snapshots[0].id == snapshots[1].id
indexes._append(table, _rows(8, 4), schema=SCHEMA)
with patch.object(server, '_table_snapshot_handle', side_effect=AssertionError('Re-resolved captured view')):
vector = routes[0].search_builder
text = routes[1].search_builder
vector_result = vector.new_vector_search_read().read_plan(vector.new_vector_search_scan().scan())
text_result = text.new_full_text_read().read_plan(text.new_full_text_scan().scan())
results = [builder.to_route_result(route, result)
for route, result in zip(routes, [vector_result, text_result])]
ranked = _scores(builder.with_limit(4).with_weighted_score_ranker().rank(results))
assert ranked
assert set(ranked).issubset(set(range(8)))
assert 11 in _scores(_builder(table).execute_local())
def test_manual_empty_routes_remain_empty_after_first_commit(rest_catalog):
catalog, server = rest_catalog
table = indexes._create(catalog, schema=SCHEMA, options={'read.native.enabled': 'true'})
builder = _builder(table)
routes = builder.route_builders()
assert all(route.search_builder._table._read_snapshot is None for route in routes)
indexes._append(table, _rows(), schema=SCHEMA)
with patch.object(server, '_table_snapshot_handle', side_effect=AssertionError('Re-resolved empty view')):
results = [builder.to_route_result(route, route.execute_local()) for route in routes]
assert _scores(builder.rank(results)) == {}
assert _scores(_builder(table).execute_local())
def test_manual_routes_allow_completing_queries_after_capture(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog)
builder = (table.new_hybrid_search_builder().with_limit(16)
.add_vector_route('embedding', [], 16)
.add_full_text_route('content', '', 16))
with patch('pypaimon.execution.python.read.search.hybrid_search.route_builders',
side_effect=AssertionError('Python incomplete route capture')):
routes = builder.route_builders()
indexes._append(table, _rows(8, 4), schema=SCHEMA)
routes[0].search_builder.with_query_vector([0., 0.])
routes[1].search_builder.with_query('content', match_query('paimon'))
results = [builder.to_route_result(route, route.execute_local()) for route in routes]
actual = _scores(builder.rank(results))
assert actual
assert set(actual).issubset(set(range(8)))
def test_sdk_route_mutations_affect_route_execute_and_keep_the_captured_view(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog, indexed=False)
table.create_global_index('content', index_type='full-text')
native = _native_table(table).new_hybrid_search_builder()
native.add_vector_route('embedding', [0., 0.], 16)
native.add_full_text_route('content', match_query('paimon'), 16).with_limit(16)
vector_route, text_route = native.route_builders()
vector, text = vector_route.search_builder(), text_route.search_builder()
assert vector is vector_route.search_builder()
assert text is text_route.search_builder()
# A commit after route capture must not enter either reconfigured query.
indexes._append(table, _rows(8, 4), schema=SCHEMA)
pb = table.new_read_builder().new_predicate_builder()
keep = _predicate_to_native(pb.greater_than('id', 4))
vector.with_query_vector([6., 0.]).with_limit(1).with_filter(keep)
text.with_query('content', match_query('lake')).with_limit(1).with_filter(keep)
vector_result, text_result = vector_route.execute(), text_route.execute()
assert set(vector_result.row_ids()) == {6}
assert set(text_result.row_ids()) == {5}
assert vector_result.row_ids() == vector.execute().row_ids()
assert text_result.row_ids() == text.execute().row_ids()
assert vector_result.snapshot_id() == vector_route.snapshot().id()
assert text_result.snapshot_id() == text_route.snapshot().id()
# Repeated getters share the same state, including later reconfiguration.
vector_route.search_builder().with_limit(2)
assert set(vector_route.execute().row_ids()) == {5, 6}
ranked = native.rank([native.to_route_result(vector_route, vector_result),
native.to_route_result(text_route, text_result)])
batches = ranked.new_read_builder().with_projection(['id']).read()
assert sorted(pa.Table.from_batches(batches)['id'].to_pylist()) == [5, 6]
@pytest.mark.parametrize('native', [False, True])
def test_public_route_uses_mutated_search_builder(rest_catalog, native):
catalog, _ = rest_catalog
table = _table(catalog, {'read.native.enabled': str(native).lower()}, indexed=False)
table.create_global_index('content', index_type='full-text')
builder = _builder(table)
vector_route, text_route = builder.route_builders()
vector, text = vector_route.search_builder, text_route.search_builder
indexes._append(table, _rows(8, 4), schema=SCHEMA)
pb = table.new_read_builder().new_predicate_builder()
vector.with_query_vector([6., 0.]).with_limit(1).with_filter(pb.greater_than('id', 4))
text.with_query('content', match_query('lake')).with_limit(1).with_filter(pb.greater_than('id', 4))
assert set(_scores(vector_route.execute_local())) == {6}
assert set(_scores(text_route.execute_local())) == {5}
vector.with_limit(2)
assert set(_scores(vector_route.execute_local())) == {5, 6}
def _multimodal_query(table, catalog, **kwargs):
return (MultimodalTable(catalog, 'default.indexes', table).search_hybrid([
vector_route('embedding', [0., 0.]), text_route('paimon', column='content')], **kwargs)
.select(['id']).limit(16).with_score().order_by_score())
def test_multimodal_empty_view_does_not_observe_later_commit(rest_catalog):
catalog, server = rest_catalog
table = _table(catalog)
# Model a commit landing after _for_execution resolved an empty view.
with patch.object(TimeTravelUtil, 'resolve_snapshot', return_value=None), _core_only(), \
patch.object(server, '_table_snapshot_handle', side_effect=AssertionError('Re-resolved empty view')):
rows = _multimodal_query(table, catalog).to_arrow()
assert rows.num_rows == 0
assert rows.column_names == ['id', '_score']
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('view', ['snapshot', 'retained_tag'])
def test_multimodal_search_and_row_lookup_share_resolved_metadata(rest_catalog, view):
catalog, server = rest_catalog
table = _table(catalog)
first = table.snapshot_manager().get_latest_snapshot()
table.tag_manager().create_tag(first, 'hybrid-before')
indexes._append(table, _rows(8, 4), schema=SCHEMA)
kwargs = {'snapshot_id': first.id} if view == 'snapshot' else {'tag_name': 'hybrid-before'}
if view == 'retained_tag':
table.file_io.delete(table.snapshot_manager().get_snapshot_path(first.id))
classic = table.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
expected = _multimodal_query(classic, catalog, **kwargs).to_arrow()
with _core_only(), patch.object(server, '_table_snapshot_handle',
side_effect=AssertionError('Re-resolved fixed view')):
actual = _multimodal_query(table, catalog, **kwargs).to_arrow()
assert sorted(actual.column('id').to_pylist()) == [0, 1, 2, 4, 5, 6, 7]
assert actual.column('id').to_pylist() == expected.column('id').to_pylist()
assert actual.column('_score').to_pylist() == pytest.approx(expected.column('_score').to_pylist(), abs=1e-6)
assert indexes._ids(table) == list(range(12))
def test_unsupported_literal_qualifies_before_core_execution(rest_catalog):
from pypaimon.execution.python.read.search import hybrid_search
catalog, _ = rest_catalog
table = _table(catalog)
predicate = table.new_read_builder().new_predicate_builder().equal('id', 1.0)
classic = table.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
expected = _scores(_builder(classic, predicate=predicate).execute_local())
with patch.object(hybrid_search, 'route_builders', wraps=hybrid_search.route_builders) as fallback:
actual = _scores(_builder(table, predicate=predicate).execute_local())
fallback.assert_called_once()
assert set(actual) == {1}
assert actual == pytest.approx(expected, abs=1e-6)
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('native', [False, True])
def test_query_auth_rejects_hybrid_before_native_table_access(rest_catalog, native):
catalog, _ = rest_catalog
raw_table = _table(catalog)
table = raw_table.copy({'query-auth.enabled': 'true', 'read.native.enabled': str(native).lower()})
with patch('pypaimon.execution.native.read.native_hybrid_search._native_table',
side_effect=AssertionError('Native table access before authorization')):
with pytest.raises(Exception, match='query-auth'):
_builder(table).execute_local()
assert indexes._ids(raw_table) == list(range(8))