| # 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)] |