blob: 4bc4af26aa1ccbf5024c4fa72c97d067caf5d647 [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.
"""Global indexes must select ROW sidecars without losing Variant column versions."""
import os
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.data.generic_variant import GenericVariant
from pypaimon.execution.python.globalindex.data_evolution_global_index_scanner import DataEvolutionGlobalIndexScanner
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.utils.range import Range
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
def _commit(builder, messages):
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
def _append(table, data):
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_arrow(data)
messages = writer.prepare_commit()
_commit(builder, messages)
return messages
finally:
writer.close()
def _data(schema, ids):
roots, nested = [], []
for row in ids:
value = ({'score': row} if row not in [6, 7, 8, 9]
else {6: None, 7: None, 8: 'scalar', 9: [9, None]}[row])
roots.append(None if row == 6 else GenericVariant.from_python(value))
child = (None if row == 7 else GenericVariant.from_python(
None if row == 6 else {'score': row * 10}))
nested.append(None if row == 8 else {
'detail': None if child is None else {'value': child.value(), 'metadata': child.metadata()},
'tag': 'nested-%d' % row})
return pa.table({
'id': list(ids),
'category': ['match' if row in range(5, 10) else 'other' for row in ids],
'payload': GenericVariant.to_arrow_array(roots),
'nested': nested,
}, schema=schema)
def _indexed_read(table):
predicate = table.new_read_builder().new_predicate_builder().equal('category', 'match')
builder = table.new_read_builder().with_filter(predicate).with_projection([
'renamed_payload', 'id', 'nested', '_ROW_ID'])
splits = builder.new_scan().plan().splits()
assert splits and all(isinstance(split, IndexedSplit) for split in splits)
assert [r for split in splits for r in split.row_ranges()] == [Range(25, 29)]
return builder.new_read().to_arrow(splits)
@pytest.mark.parametrize('index_type', ['bitmap', 'btree'])
@pytest.mark.parametrize('column_update', [False, True])
def test_global_index_reads_variant_sidecars_after_historical_rename(tmp_path, index_type, column_update):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
variant = GenericVariant.to_arrow_array([]).type
schema = pa.schema([
('id', pa.int32()), ('category', pa.string()), ('payload', variant),
('nested', pa.struct([('detail', variant), ('tag', pa.string())])),
])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'file.format': 'parquet', 'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true', 'data-evolution.row-sidecar.enabled': 'true',
'global-index.enabled': 'true', 'scan.native-plan.enabled': 'false',
'read.native.enabled': 'false', 'write.native.enabled': 'false',
'commit.native.enabled': 'false',
}), False)
table = catalog.get_table('default.t')
# The second physical file starts at global row ID 20, so global index
# positions 25..29 must be converted into local ROW positions 5..9.
messages = _append(table, _data(schema, range(-20, 0)))
data = _data(schema, range(100))
messages += _append(table, data)
assert table.create_global_index('category', index_type=index_type) > 0
predicate = table.new_read_builder().new_predicate_builder().equal('category', 'match')
with DataEvolutionGlobalIndexScanner.create(table, predicate=predicate) as scanner:
result = scanner.scan(predicate)
assert result.results().to_range_list() == [Range(25, 29)]
expected = data.slice(5, 5).to_pylist()
if column_update:
values = GenericVariant.to_arrow_array([
GenericVariant.from_python({'score': 500}), GenericVariant.from_python(None),
None, GenericVariant.from_python('updated'), GenericVariant.from_python([99, None]),
])
builder = table.new_batch_write_builder()
updates = builder.new_update().new_update_by_row_id().update_columns(
pa.table({'_ROW_ID': list(range(25, 30)), 'payload': values}), ['payload'])
_commit(builder, updates)
updated_files = [file for message in updates for file in message.new_files]
assert len(updated_files) == 1
assert updated_files[0].write_cols == ['payload']
assert updated_files[0].row_count == 100
assert updated_files[0].first_row_id == 20
messages += updates
for row, value in zip(expected, values.to_pylist()):
row['payload'] = value
payload_id = table.field_dict['payload'].id
catalog.alter_table('default.t', [
SchemaChange.rename_column('payload', 'renamed_payload'),
SchemaChange.rename_column(['nested', 'detail'], 'renamed_detail'),
], False)
table = catalog.get_table('default.t')
assert table.field_dict['renamed_payload'].id == payload_id
expected = [{
'renamed_payload': row['payload'], 'id': row['id'],
'nested': (None if row['nested'] is None else {
'renamed_detail': row['nested']['detail'], 'tag': row['nested']['tag']}),
'_ROW_ID': row['id'] + 20,
} for row in expected]
# This assertion also guards against silently choosing the old full file
# instead of the latest column-only file after its write_cols was renamed.
assert _indexed_read(table).to_pylist() == expected
for message in messages:
for file in message.new_files:
sidecars = [path for path in file.collect_files() if path.endswith('.row')]
assert len(sidecars) == 1 and os.path.exists(sidecars[0])
os.remove(file.external_path or file.file_path)
# A correct result now requires every contributing column bunch to use ROW.
assert _indexed_read(table).to_pylist() == expected
# Without the real global index there is no row selection and the deleted
# primary must be opened. This rules out an accidental full-scan success.
unindexed = table.copy({'global-index.enabled': 'false'})
builder = unindexed.new_read_builder().with_filter(predicate)
with pytest.raises(OSError):
builder.new_read().to_arrow(builder.new_scan().plan().splits())