blob: 36cc51c186ef14bccadf05e550f9a270d58d16f0 [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.
"""REST full-text queries retain Java coverage, filtering and snapshot semantics."""
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.api.api_response import GetTableSnapshotResponse, ErrorResponse
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.table.source.full_text_search_builder import FullTextSearchBuilderImpl
from pypaimon.tests import native_sorted_index_build_test as indexes
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()), ('label', pa.string()), ('pt', pa.int32())])
def _rows(start, count):
return [{'id': i, 'content': None if i == 3 else ('paimon lake' if i % 2 else 'paimon paimon paimon'),
'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', 'full-text-index.search-mode': 'full',
'scalar-index.search-mode': 'full', 'global-index.row-count-per-shard': '4'}
settings.update(options or {})
table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options=settings)
indexes._append(table, _rows(0, 8), schema=SCHEMA)
if indexed:
table.create_global_index('content', index_type='full-text')
if partial:
indexes._append(table, _rows(8, 4), schema=SCHEMA)
return table
def _builder(table, query=None, limit=64, predicate=None, partition=None, column='content'):
builder = (table.new_full_text_search_builder().with_query(column, query or match_query('paimon'))
.with_limit(limit))
if predicate is not None:
builder.with_filter(predicate)
if partition is not None:
builder.with_partition_filter(partition)
return builder
def _scores(result):
if result.is_empty():
return {}
score = result.score_getter()
return {row_id: score(row_id) for row_id in result.results()}
def _compare(table, **kwargs):
classic = table.copy({'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
expected = _scores(_builder(classic, **kwargs).execute_local())
with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
side_effect=AssertionError('Python full-text scan')):
actual = _scores(_builder(table, **kwargs).execute_local())
assert set(actual) == set(expected)
assert actual == pytest.approx(expected, rel=1e-6, abs=1e-6)
return actual
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
@pytest.mark.parametrize('query', [
match_query('paimon'), match_query('paimon lake', 'And'),
boost_query(match_query('paimon'), match_query('lake'), 0.1)])
@pytest.mark.parametrize('limit', [1, 64])
def test_native_full_text_coverage_and_structured_queries(rest_catalog, mode, query, limit):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, partial=True)
scores = _compare(table, query=query, limit=limit)
if limit == 64 and query == match_query('paimon'):
assert set(scores) == ({0, 1, 2, 4, 5, 6, 7} if mode == 'fast' else {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11})
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('text_mode', ['fast', 'full', 'detail'])
@pytest.mark.parametrize('scalar_mode', ['fast', 'full', 'detail'])
@pytest.mark.parametrize('scalar_index', [None, 'btree', 'bitmap'])
def test_scalar_filter_modes_before_full_text_top_k(rest_catalog, text_mode, scalar_mode, scalar_index):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': text_mode, 'scalar-index.search-mode': scalar_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)
indexed = set() if scalar_mode == 'fast' and scalar_index is None else {1, 5, 7}
assert set(scores) == indexed.union(set() if text_mode == 'fast' else {9, 11})
top = _compare(table, predicate=predicate, limit=1)
assert len(top) == min(len(scores), 1)
assert all(row_id % 2 for row_id in top)
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('refine', [False, True])
@pytest.mark.parametrize('scalar_mode', ['fast', 'full', 'detail'])
@pytest.mark.parametrize('index_type', ['btree', 'bitmap'])
@pytest.mark.parametrize('method, literal', [('contains', 'eep'), ('endswith', 'eep'), ('like', 'k%p')])
def test_candidate_filter_refinement_retains_full_corpus_scores(
rest_catalog, refine, scalar_mode, index_type, method, literal):
catalog, _ = rest_catalog
table = _table(catalog, {'global-index.filter.refine-from-data': str(refine).lower(),
'scalar-index.search-mode': scalar_mode})
table.create_global_index('label', index_type=index_type)
indexes._append(table, _rows(8, 4), schema=SCHEMA)
predicate = getattr(table.new_read_builder().new_predicate_builder(), method)('label', literal)
scores = _compare(table, predicate=predicate)
assert set(scores) == ({1, 5, 7, 9, 11} if refine else {9, 11})
full = _compare(table)
assert scores == pytest.approx({row_id: full[row_id] for row_id in scores}, abs=1e-6)
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('refine', [False, True])
@pytest.mark.parametrize('compound', ['and', 'or'])
@pytest.mark.parametrize('method, literal', [('contains', 'eep'), ('endswith', 'eep'), ('like', '%eep%')])
def test_bitmap_candidate_leaves_require_refinement_in_compounds(rest_catalog, refine, compound, method, literal):
catalog, _ = rest_catalog
table = _table(catalog, {'global-index.filter.refine-from-data': str(refine).lower(),
'scalar-index.search-mode': 'fast'})
table.create_global_index('label', index_type='bitmap')
indexes._append(table, _rows(8, 4), schema=SCHEMA)
pb = table.new_read_builder().new_predicate_builder()
candidate = getattr(pb, method)('label', literal)
predicate = (pb.and_predicates([pb.is_not_null('label'), candidate]) if compound == 'and'
else pb.or_predicates([pb.equal('label', 'drop'), candidate]))
rows = set(_compare(table, predicate=predicate))
indexed = ({1, 5, 7} if compound == 'and' else {0, 1, 2, 4, 5, 6, 7}) if refine else set()
assert rows == indexed.union({9, 11} if compound == 'and' else {8, 9, 10, 11})
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('pattern', ['keep', 'ke%'])
@pytest.mark.parametrize('index_type', ['btree', 'bitmap'])
def test_optimized_like_filters_stay_exact_without_refinement(rest_catalog, pattern, index_type):
catalog, _ = rest_catalog
table = _table(catalog, {'global-index.filter.refine-from-data': 'false',
'scalar-index.search-mode': 'fast'})
table.create_global_index('label', index_type=index_type)
predicate = table.new_read_builder().new_predicate_builder().like('label', pattern)
assert set(_compare(table, predicate=predicate)) == {1, 5, 7}
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('refine', [False, True])
@pytest.mark.parametrize('partial', [False, True])
@pytest.mark.parametrize('index_type', ['btree', 'bitmap'])
@pytest.mark.parametrize('method, literal', [
('startswith', 'ke'), ('like', 'ke%'), ('startswith', 'missing'), ('like', 'missing%')])
def test_prefix_filters_are_exact_before_refinement(rest_catalog, refine, partial, index_type, method, literal):
catalog, _ = rest_catalog
table = _table(catalog, {'global-index.filter.refine-from-data': str(refine).lower()})
table.create_global_index('label', index_type=index_type)
if partial:
indexes._append(table, _rows(8, 4), schema=SCHEMA)
predicate = getattr(table.new_read_builder().new_predicate_builder(), method)('label', literal)
expected = ({1, 5, 7, 9, 11} if partial else {1, 5, 7}) if literal.startswith('ke') else set()
assert set(_compare(table, predicate=predicate)) == expected
assert indexes._ids(table) == list(range(12 if partial else 8))
@pytest.mark.parametrize('prefix', ['\x7f', '\u00ff', '\uffff', '\U0010ffff'])
@pytest.mark.parametrize('method', ['startswith', 'like'])
def test_btree_unicode_prefix_bounds_keep_indexed_and_raw_matches(rest_catalog, prefix, method):
catalog, _ = rest_catalog
table = indexes._create(catalog, schema=SCHEMA, options={
'read.native.enabled': 'true', 'global-index.filter.refine-from-data': 'false'})
indexes._append(table, [
{'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
for i, label in enumerate([prefix, prefix + 'a', None, 'keep'])], schema=SCHEMA)
table.create_global_index('content', index_type='full-text')
table.create_global_index('label', index_type='btree')
indexes._append(table, [
{'id': 4, 'content': 'paimon', 'label': prefix + 'raw', 'pt': 0},
{'id': 5, 'content': 'paimon', 'label': 'drop', 'pt': 0}], schema=SCHEMA)
literal = prefix + '%' if method == 'like' else prefix
predicate = getattr(table.new_read_builder().new_predicate_builder(), method)('label', literal)
assert set(_compare(table, predicate=predicate)) == {0, 1, 4}
assert indexes._ids(table) == list(range(6))
@pytest.mark.parametrize('refine', [False, True])
@pytest.mark.parametrize('partial', [False, True])
@pytest.mark.parametrize('index_type', ['btree', 'bitmap'])
@pytest.mark.parametrize('method, literal', [
('like', ''), ('like', '%'), ('like', '%%'), ('contains', ''), ('endswith', ''), ('startswith', '')])
def test_empty_string_filters_preserve_java_refinement(rest_catalog, refine, partial, index_type, method, literal):
catalog, _ = rest_catalog
table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options={
'read.native.enabled': 'true', 'full-text-index.search-mode': 'full',
'scalar-index.search-mode': 'full', 'global-index.filter.refine-from-data': str(refine).lower()})
labels = ['', None, 'keep', 'drop', '', 'keep', 'drop', None]
indexes._append(table, [
{'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
for i, label in enumerate(labels)], schema=SCHEMA)
table.create_global_index('content', index_type='full-text')
table.create_global_index('label', index_type=index_type)
if partial:
labels += ['', None, 'keep', 'drop']
indexes._append(table, [
{'id': i, 'content': 'paimon', 'label': label, 'pt': 0}
for i, label in enumerate(labels[8:], 8)], schema=SCHEMA)
predicate = getattr(table.new_read_builder().new_predicate_builder(), method)('label', literal)
indexed = {0, 4} if method == 'like' and literal == '' else {0, 2, 3, 4, 5, 6}
raw = ({8} if method == 'like' and literal == '' else {8, 10, 11}) if partial else set()
expected = (indexed if refine or method == 'startswith' else set()).union(raw)
assert set(_compare(table, predicate=predicate)) == expected
assert indexes._ids(table) == list(range(len(labels)))
@pytest.mark.parametrize('refine', [False, True])
def test_escaped_bitmap_like_is_refined_without_prefix_rewrite(rest_catalog, refine):
catalog, _ = rest_catalog
table = indexes._create(catalog, schema=SCHEMA, partitioned=True, options={
'read.native.enabled': 'true', 'scalar-index.search-mode': 'fast',
'global-index.filter.refine-from-data': str(refine).lower()})
indexes._append(table, [
{'id': 0, 'content': 'paimon', 'label': 'ke%', 'pt': 0},
{'id': 1, 'content': 'paimon', 'label': r'ke\suffix', 'pt': 0},
{'id': 2, 'content': 'paimon', 'label': 'keep', 'pt': 0}], schema=SCHEMA)
table.create_global_index('content', index_type='full-text')
table.create_global_index('label', index_type='bitmap')
predicate = table.new_read_builder().new_predicate_builder().like('label', r'ke\%')
assert set(_compare(table, predicate=predicate)) == ({0} if refine else set())
assert indexes._ids(table) == list(range(3))
@pytest.mark.parametrize('selection', ['partition', 'mixed', 'repeat'])
@pytest.mark.parametrize('mode', ['full', 'detail'])
def test_partition_filter_applies_to_index_and_raw_reads(rest_catalog, selection, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, partial=True)
pb = table.new_read_builder().new_predicate_builder()
partition = pb.greater_or_equal('pt', 1)
if selection == 'partition':
scores = _compare(table, partition=partition)
assert set(scores) == set(range(4, 12))
elif selection == 'mixed':
scores = _compare(table, predicate=pb.and_predicates([partition, pb.equal('label', 'keep')]))
assert set(scores) == {5, 7, 9, 11}
else:
builder = _builder(table, partition=partition).with_partition_filter(pb.less_than('pt', 2))
assert set(_scores(builder.execute_local())) == set(range(4, 8))
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
def test_no_full_text_definition_is_empty_in_every_mode(rest_catalog, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, indexed=False)
assert _compare(table) == {}
assert indexes._ids(table) == list(range(8))
def test_scan_read_plan_pins_snapshot_and_accepts_reader_filters(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog)
native = (_native_table(table).new_full_text_search_builder()
.with_query('content', match_query('paimon')).with_limit(64))
plan = native.new_full_text_scan().scan()
before = table.snapshot_manager().get_latest_snapshot().id
assert plan.snapshot().id() == before
reader = native.new_full_text_read()
indexes._append(table, _rows(8, 4), schema=SCHEMA)
assert set(reader.read_plan(plan).row_ids()) == {0, 1, 2, 4, 5, 6, 7}
assert set(native.execute().row_ids()) == {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11}
predicate = table.new_read_builder().new_predicate_builder().equal('id', 0)
changed = (_native_table(table).new_full_text_search_builder()
.with_query('content', match_query('paimon')).with_limit(64))
changed.with_filter(_predicate_to_native(predicate))
filtered = changed.new_full_text_read().read_plan(plan)
assert filtered.snapshot_id() == before
assert set(filtered.row_ids()) == {0}
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('response', ['first', 'empty', 'error'])
def test_rest_snapshot_response_is_authoritative(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':
reply = ErrorResponse('TABLE', 'indexes', 'snapshot unavailable', 503)
code = 503
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)):
if response == 'error':
with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
side_effect=AssertionError('Unexpected Python retry')):
with pytest.raises(Exception, match='snapshot unavailable'):
_builder(table).execute_local()
else:
assert set(_compare(table)) == ({0, 1, 2, 4, 5, 6, 7} if response == 'first' else set())
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('native', [False, True])
def test_missing_index_errors_propagate_without_search_retry(rest_catalog, native):
catalog, _ = rest_catalog
table = _table(catalog, {'read.native.enabled': str(native).lower()})
file = indexes._files(table)[0]
table.file_io.delete(indexes._path(table, file))
if native:
with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
side_effect=AssertionError('Unexpected Python retry')):
with pytest.raises(Exception, match='NotFound|not found|No such file|does not exist'):
_builder(table).execute_local()
else:
with pytest.raises(Exception):
_builder(table).execute_local()
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('case', ['limit', 'query', 'missing-column', 'partition', 'dsl'])
def test_invalid_search_inputs_fail(rest_catalog, native, case):
catalog, _ = rest_catalog
table = _table(catalog, {'read.native.enabled': str(native).lower()})
if case == 'limit':
builder = _builder(table, limit=0)
elif case == 'query':
builder = table.new_full_text_search_builder().with_limit(1)
elif case == 'missing-column':
builder = table.new_full_text_search_builder().with_query('missing', match_query('paimon')).with_limit(1)
elif case == 'partition':
with pytest.raises(ValueError, match='Partition filter'):
_builder(table, partition=table.new_read_builder().new_predicate_builder().equal('id', 1))
assert indexes._ids(table) == list(range(8))
return
else:
builder = _builder(table, query='paimon')
with pytest.raises(Exception):
builder.execute_local()
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('mode', ['full', 'detail'])
@pytest.mark.parametrize('options', [
{'full-text.stop-words': ['paimon']},
{'full-text.stop-words': ['lake'], 'full-text.lowercase': True}])
def test_typed_analyzer_options_keep_raw_query_semantics(rest_catalog, mode, options):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, partial=True).copy(options)
scores = _compare(table)
expected = ({0, 1, 2, 4, 5, 6, 7} if options['full-text.stop-words'] == ['paimon']
else {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11})
assert set(scores) == expected
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
def test_composite_scalar_indexes_are_excluded_from_search_pre_filters(rest_catalog, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'scalar-index.search-mode': mode})
# Python intentionally supports single-column builds. Seed a composite
# definition through Rust to exercise Java-compatible search exclusion.
messages = (_native_table(table).new_global_index_build_builder()
.with_index_columns(['label', 'id']).with_index_type('btree').build())
indexes._commit(table, indexes.from_native_commit_messages(table, messages))
pb = table.new_read_builder().new_predicate_builder()
predicate = pb.and_predicates([pb.equal('label', 'keep'), pb.greater_than('id', 0)])
assert set(_compare(table, predicate=predicate)) == (set() if mode == 'fast' else {1, 5, 7})
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('mode', ['full', 'detail'])
def test_schema_only_rename_retains_read_schema_for_unindexed_rows(rest_catalog, mode):
from pypaimon.schema.schema_change import SchemaChange
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, partial=True)
snapshot = table.snapshot_manager().get_latest_snapshot()
catalog.alter_table('default.indexes', [SchemaChange.rename_column('content', 'body')], False)
table = catalog.get_table('default.indexes')
assert table.table_schema.id > snapshot.schema_id
assert set(_compare(table, column='body')) == {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11}
native = (_native_table(table).new_full_text_search_builder()
.with_query('body', match_query('paimon')).with_limit(64))
plan = native.new_full_text_scan().scan()
assert set(native.new_full_text_read().read_plan(plan).row_ids()) == {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11}
assert indexes._ids(table) == list(range(12))
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
def test_deleted_indexed_and_raw_rows_are_excluded_before_top_k(rest_catalog, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode}, partial=True)
table = table.copy({'deletion-vectors.enabled': 'true'})
builder = table.new_batch_write_builder()
indexes._commit(table, builder.new_update().delete_by_row_id([0, 1, 8, 9]))
assert set(_compare(table)) == ({2, 4, 5, 6, 7} if mode == 'fast' else {2, 4, 5, 6, 7, 10, 11})
assert len(_compare(table, limit=3)) == 3
assert indexes._ids(table) == [2, 3, 4, 5, 6, 7, 10, 11]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('limit', [-1, 0])
def test_invalid_limit_has_same_value_error(rest_catalog, native, limit):
catalog, _ = rest_catalog
table = _table(catalog, {'read.native.enabled': str(native).lower()})
with pytest.raises(ValueError, match='Limit must be positive'):
_builder(table, limit=limit).execute_local()
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('mode', ['fast', 'full', 'detail'])
def test_updated_text_uses_invalidated_index_coverage(rest_catalog, mode):
catalog, _ = rest_catalog
table = _table(catalog, {'full-text-index.search-mode': mode,
'global-index.column-update-action': 'DROP_PARTITION_INDEX'}, partial=True)
builder = table.new_batch_write_builder()
changed = pa.table({'_ROW_ID': pa.array([4], type=pa.int64()), 'content': ['rust']})
messages = builder.new_update().with_update_type(['content']).update_by_arrow_with_row_id(changed)
indexes._commit(table, messages)
scores = _compare(table)
assert 4 not in scores
if mode != 'fast':
assert set(scores) == {0, 1, 2, 5, 6, 7, 8, 9, 10, 11}
assert indexes._ids(table) == list(range(12))
def test_unrepresentable_literal_qualifies_before_native_execution(rest_catalog):
catalog, _ = rest_catalog
table = _table(catalog)
predicate = table.new_read_builder().new_predicate_builder().equal('id', 1.0)
expected = _scores(_builder(table.copy({'read.native.enabled': 'false'}), predicate=predicate).execute_local())
scan = FullTextSearchBuilderImpl.new_full_text_scan
with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan', autospec=True, side_effect=scan) as selected:
assert _scores(_builder(table, predicate=predicate).execute_local()) == expected
selected.assert_called_once()
assert set(expected) == {1}
assert indexes._ids(table) == list(range(8))
@pytest.mark.parametrize('native', [False, True])
def test_query_auth_rejects_search_before_index_access(rest_catalog, native):
catalog, _ = rest_catalog
table = _table(catalog)
authorized = table.copy({'query-auth.enabled': 'true', 'read.native.enabled': str(native).lower()})
with pytest.raises(Exception, match='query-auth'):
_builder(authorized).execute_local()
assert indexes._ids(table) == list(range(8))