blob: 0b6a240defdb48d04fe68bdb35b7331b345adf45 [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.
"""Native Variant projections retain the public InternalRow iterator contract."""
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.data.generic_variant import GenericVariant
from pypaimon.execution.native.read.table_read import native_read
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.execution.python.read.table_read import PythonTableRead
from pypaimon.schema.arrow_schema import read_arrow_schema
from pypaimon.table.row.row_kind import RowKind
pytestmark = pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed')
@pytest.fixture(params=['append', 'pk', 'de'])
def variant_table(tmp_path, request):
mode = request.param
payload = GenericVariant.to_arrow_array([
GenericVariant.from_python({'score': 1.25, 'good': 2.5}),
GenericVariant.from_python({'score': 'bad', 'good': 3.5}),
GenericVariant.from_python({}), None,
GenericVariant.from_python({'score': None}), None,
])
options = {
'bucket': '1' if mode == 'pk' else '-1',
'file.format': 'parquet', 'read.batch-size': '2',
'source.split.target-size': '1b',
'read.native.enabled': 'true', 'scan.native-plan.enabled': 'false',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
}
if mode == 'de':
options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true'})
data = pa.Table.from_arrays([
pa.array(range(1, 7), type=pa.int64()), payload,
pa.StructArray.from_arrays([
payload, pa.array(['Hangzhou', 'Beijing', None, 'Shenzhen', 'Suzhou', 'hidden']),
], names=['payload', 'city'], mask=pa.array([False] * 5 + [True])),
pa.array([
[('source', 'web'), ('source', 'mobile')], [('source', None), ('source', 'later')],
[], None, [('other', 'value')], [('source', 'app')],
], type=pa.map_(pa.string(), pa.string())),
], names=['id', 'payload', 'profile', 'attributes'])
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('db', True)
catalog.create_table('db.t', Schema.from_pyarrow_schema(
data.schema, primary_keys=['id'] if mode == 'pk' else None, options=options), False)
table = catalog.get_table('db.t')
for offset in range(0, data.num_rows, 2):
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(data.slice(offset, 2))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
with patch.object(PythonTableRead, 'read_rows', side_effect=AssertionError('Python fallback')), \
patch.object(PythonTableRead, 'read_batches', side_effect=AssertionError('Python fallback')):
yield table
@pytest.fixture(params=['payload', 'profile.payload'])
def variant_builder(variant_table, request):
source = request.param
builder = variant_table.new_read_builder().with_projection({
'score': "try_variant_get(%s, '$.score', 'float')" % source,
'identifier': 'id',
'score_copy': "try_variant_get(%s, '$.score', 'float')" % source,
'good': "variant_get(%s, '$.good', 'float')" % source,
'identifier_copy': 'id',
})
return builder, source
def _rows(iterator):
try:
result = []
for row in iterator:
assert row.get_row_kind() == RowKind.INSERT
result.append(tuple(row.get_field(index) for index in range(len(row))))
return result
finally:
iterator.close()
@pytest.mark.parametrize('limit', [None, 0, 1, 3])
def test_variant_iterator_matches_arrow(variant_builder, limit):
builder, _ = variant_builder
splits = builder.new_scan().plan().splits()
assert len(splits) >= 2
if limit is not None:
builder.with_limit(limit)
read = builder.new_read()
expected = [(1.25, 1, 1.25, 2.5, 1), (None, 2, None, 3.5, 2)]
expected += [(None, value, None, None, value) for value in range(3, 7)]
if limit is not None:
expected = expected[:limit]
assert _rows(read.to_iterator(splits)) == expected
arrow = read.to_arrow(splits, parallelism=1)
assert arrow.column_names == [alias for alias, _ in read.output_projection.columns]
assert arrow.schema.field('score').type == pa.float32()
assert list(zip(*(column.to_pylist() for column in arrow.columns))) == expected
with read.to_arrow_batch_reader(splits, parallelism=1) as batches:
assert [tuple(row.values()) for batch in batches for row in batch.to_pylist()] == expected
assert _rows(read.to_iterator([])) == []
assert read.include_row_kind is False
def test_variant_iterator_propagates_strict_cast_errors(variant_builder):
builder, source = variant_builder
builder.with_projection({'score': "variant_get(%s, '$.score', 'float')" % source})
splits = builder.new_scan().plan().splits()
read = builder.new_read()
with pytest.raises(Exception, match='Cannot cast Variant'):
read.to_arrow(splits, parallelism=1)
with pytest.raises(Exception, match='Cannot cast Variant'):
_rows(read.to_iterator(splits))
@pytest.mark.parametrize('failure', ['setup', 'unsupported_split'])
def test_variant_iterator_never_falls_back_to_python(variant_builder, failure):
builder, _ = variant_builder
splits = builder.new_scan().plan().splits()
target, options = (
('native_read', {'side_effect': RuntimeError('Native setup failed')}) if failure == 'setup' else
('NativeTableRead._split_files_supported', {'return_value': False}))
with patch('pypaimon.execution.native.read.table_read.' + target, **options):
with pytest.raises(RuntimeError, match='Variant extraction requires a compatible native reader'):
_rows(builder.new_read().to_iterator(splits))
@pytest.mark.parametrize('stop', ['unstarted', 'close', 'failure', 'exhaust'])
def test_variant_iterator_batch_lifetime(variant_builder, stop):
fetched, closed = [], []
failure = OSError('Native stream failed')
def tracked_native(*args, **kwargs):
batches = native_read(*args, **kwargs)
def tracked_batches():
try:
for batch in batches:
fetched.append(batch.num_rows)
yield batch
if stop == 'failure':
raise failure
finally:
batches.close()
closed.append(True)
return tracked_batches()
builder, _ = variant_builder
splits = builder.new_scan().plan().splits()
with patch('pypaimon.execution.native.read.table_read.native_read', side_effect=tracked_native) as native:
iterator = builder.new_read().to_iterator(splits)
native.assert_not_called()
try:
if stop != 'unstarted':
assert next(iterator).get_field(0) == 1.25
assert len(fetched) == 1
assert closed == []
if stop == 'failure':
with pytest.raises(OSError) as raised:
list(iterator)
assert raised.value is failure
elif stop == 'exhaust':
assert len(list(iterator)) == 5
finally:
iterator.close()
assert native.call_count == int(stop != 'unstarted')
assert closed == ([] if stop == 'unstarted' else [True])
if stop in ('close', 'failure'):
assert len(fetched) == 1
if stop == 'exhaust':
assert sum(fetched) == 6
def test_variant_iterator_preserves_row_kinds(variant_builder):
builder, path = variant_builder
read = builder.new_read()
source = {'0': 1.25, '1': 1.25, '2': 2.5}
field = path.split('.')[0]
payload = {'payload': source} if field == 'profile' else source
kinds = ['+I', '-U', '+U', '-D']
batch = pa.RecordBatch.from_pylist([
{'rowkind': kind, field: payload, 'id': index + 1} for index, kind in enumerate(kinds)
], schema=pa.schema([pa.field('rowkind', pa.string())] + list(read_arrow_schema(read.read_type))))
splits = builder.new_scan().plan().splits()
with patch('pypaimon.execution.native.read.table_read.native_read', return_value=iter([batch])):
actual = [(tuple(row.get_field(index) for index in range(len(row))), row.get_row_kind())
for row in read.to_iterator(splits)]
assert actual == [((1.25, index + 1, 1.25, 2.5, index + 1), RowKind.from_string(kind))
for index, kind in enumerate(kinds)]