blob: 80adb0c5d633be021b6fb0933de5dc1adb7fe139 [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.
"""Selected Arrow values can carry separate routing and event columns."""
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.runtime import native_runtime_available
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
_NATIVE = [False, pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
def _table(tmp_path, native, bucket=-1, generated=False):
fields = pa.schema([
pa.field('id', pa.int32(), nullable=False),
pa.field('value', pa.int32(), nullable=False),
('other', pa.int32()),
('unused', pa.struct([pa.field('item', pa.string(), nullable=False)])),
pa.field('pt', pa.string(), nullable=False),
pa.field('op', pa.string(), nullable=False),
])
options = {'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'bucket': str(bucket)}
if bucket > 0:
options['bucket-key'] = 'id'
else:
options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'})
if generated:
options.update({'rowkind.field': 'op', 'ignore-delete': 'true'})
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.transport', Schema.from_pyarrow_schema(
fields, partition_keys=['pt'], options=options), False)
return catalog.get_table('default.transport'), fields
def _batch(schema, columns, rows):
return pa.Table.from_pylist(rows, schema=pa.schema([schema.field(name) for name in columns])).to_batches()[0]
def _values(table):
read = table.new_read_builder().with_projection(['value', 'other'])
return sorted(read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist(),
key=lambda row: row['value'])
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
@pytest.mark.parametrize('input_', ['batch', 'table'])
def test_selected_values_accept_partition_transport(tmp_path, native, input_):
table, schema = _table(tmp_path, native)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.with_write_type(['value', 'other'])
input_schema = pa.schema([field.with_type(pa.large_string()) if field.name == 'pt' else field
for field in schema])
batch = _batch(input_schema, ['pt', 'value', 'other'], [{'pt': 'old', 'value': 10, 'other': 20}])
writer.write_arrow(batch if input_ == 'batch' else pa.Table.from_batches([batch]))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _values(table) == [{'value': 10, 'other': 20}]
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
@pytest.mark.parametrize('full', [False, True], ids=['transport', 'full-table'])
@pytest.mark.parametrize('generated', [False, True], ids=['insert', 'rowkind'])
def test_unselected_nulls_and_nested_payloads_do_not_validate_as_written_values(
tmp_path, native, full, generated):
table, schema = _table(tmp_path, native, generated=generated)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
row = {'id': None, 'value': 10, 'other': 20, 'unused': {'item': None},
'pt': None, 'op': '+I' if generated else None}
input_schema = pa.schema([
field.with_type(pa.struct([('item', pa.large_string())])) if field.name == 'unused' else
field.with_type(pa.large_string()) if field.name == 'pt' else field for field in schema])
columns = schema.names if full else ['value', 'pt', 'other'] + (['op'] if generated else [])
try:
writer.with_write_type(['value', 'other'])
writer.write_arrow(_batch(input_schema, columns, [row]))
messages = writer.prepare_commit()
assert [message.partition for message in messages] == [(None,)]
assert all(file.write_cols == ['value', 'other'] for message in messages for file in message.new_files)
commit.commit(messages)
finally:
writer.close()
commit.close()
assert _values(table) == [{'value': 10, 'other': 20}]
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
def test_selected_null_is_checked_before_event_filtering(tmp_path, native):
table, schema = _table(tmp_path, native, generated=True)
writer = table.new_batch_write_builder().new_write().with_write_type(['value', 'other'])
try:
with pytest.raises(ValueError, match='Cannot write null to non-null column.*value'):
writer.write_arrow(_batch(schema, ['value', 'other', 'pt', 'op'], [
{'value': None, 'other': 20, 'pt': 'ignored', 'op': '-D'}]))
assert writer.with_write_type(['other']) is writer
assert not list(tmp_path.rglob('*.parquet'))
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
def test_ignored_transport_needs_no_routing_values_or_captured_layout(tmp_path, native):
table, schema = _table(tmp_path, native, generated=True)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.with_write_type(['value', 'other'])
writer.write_arrow(_batch(schema, ['value', 'other', 'op'], [
{'value': 10, 'other': 20, 'op': '-D'}]))
assert writer.with_write_type(['value']) is writer
writer.write_arrow(_batch(schema, ['value', 'pt', 'op'], [
{'value': 30, 'pt': 'keep', 'op': '+I'}]))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _values(table) == [{'value': 30, 'other': None}]
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
@pytest.mark.parametrize('columns', [
['other', 'value', 'pt'],
['value', 'other', 'pt', 'id'],
['value', 'other', 'pt', 'op'],
['value', 'other', 'pt', 'pt'],
['value', 'other', 'pt', 'unknown'],
['other', 'pt'],
], ids=['reordered', 'unneeded-hash-key', 'unconfigured-event', 'duplicate', 'unknown', 'missing-selected'])
def test_transport_rejects_payload_extras_and_changed_selected_layout(tmp_path, native, columns):
table, schema = _table(tmp_path, native)
writer = table.new_batch_write_builder().new_write().with_write_type(['value', 'other'])
fields = [pa.field('unknown', pa.int32()) if name == 'unknown' else schema.field(name)
for name in columns]
arrays = [pa.array(['old'] if field.name in ('pt', 'op') else [10], type=field.type)
for field in fields]
try:
with pytest.raises(ValueError):
writer.write_arrow(pa.RecordBatch.from_arrays(arrays, schema=pa.schema(fields)))
assert writer.with_write_type(['other']) is writer
assert not list(tmp_path.rglob('*.parquet'))
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
@pytest.mark.parametrize('explicit', [False, True], ids=['automatic', 'explicit'])
def test_fixed_transport_requires_hash_keys_only_for_automatic_assignment(tmp_path, native, explicit):
table, schema = _table(tmp_path, native, bucket=4)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
row = {'id': 7, 'value': 10, 'other': 20, 'pt': 'old'}
try:
writer.with_write_type(['value', 'other'])
transport = _batch(schema, ['value', 'other', 'pt'], [row])
if explicit:
writer.write_arrow(transport, bucket=3)
else:
with pytest.raises(ValueError, match='routing column.*id'):
writer.write_arrow(transport)
assert writer.with_write_type(['other']) is writer
writer.with_write_type(['value', 'other'])
writer.write_arrow(_batch(schema, ['pt', 'id', 'value', 'other'], [row]))
messages = writer.prepare_commit()
assert len(messages) == 1
if explicit:
assert messages[0].bucket == 3
commit.commit(messages)
finally:
writer.close()
commit.close()
assert _values(table) == [{'value': 10, 'other': 20}]
@pytest.mark.parametrize('native', _NATIVE, ids=['python', 'native'])
def test_transport_still_requires_partition_for_surviving_rows(tmp_path, native):
table, schema = _table(tmp_path, native)
writer = table.new_batch_write_builder().new_write().with_write_type(['value', 'other'])
try:
with pytest.raises(ValueError, match='routing column.*pt'):
writer.write_arrow(_batch(schema, ['value', 'other'], [{'value': 10, 'other': 20}]))
assert writer.with_write_type(['other']) is writer
finally:
writer.close()