blob: aa81f0bdd26fa07349145f09769f996f151c27a8 [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.
"""Source Arrow nullability must not validate values discarded by update actions."""
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.table.data_evolution_merge_into import WhenMatched, source_col
from pypaimon.table.row.generic_row import GenericRow
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
_NATIVE = pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed')
_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, _NATIVE])]
_OPERATIONS = ['upsert_arrow', 'upsert_rows', 'merge']
def _table(catalog, native, value_nullable=False):
schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
pa.field('value', pa.int32(), nullable=value_nullable),
pa.field('keep', pa.int32(), nullable=False)])
catalog.create_table('default.updates', Schema.from_pyarrow_schema(schema, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
table = catalog.get_table('default.updates')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist([dict(id=1, value=10, keep=100)], schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table, schema
def _source(schema, operation, invalid):
if invalid:
rows = [dict(id=1, value=None, keep=100)]
elif operation == 'merge':
# The second row does not match and there is no INSERT clause. Its
# NULL cannot be validated as though it were written to the target.
rows = [dict(id=1, value=20, keep=None), dict(id=2, value=None, keep=None)]
else:
# Last-write-wins discards the first invalid value. The selected row
# updates only value, so keep is never a target assignment.
rows = [dict(id=1, value=None, keep=None), dict(id=1, value=20, keep=None)]
if operation == 'upsert_rows':
return rows
source = pa.Table.from_pylist([dict(id=0, value=0, keep=0)] + rows, schema=schema).slice(1)
source.validate(full=True)
return source.replace_schema_metadata({'origin': 'external-update'})
def _messages(table, source, native, stream, operation):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update()
kwargs = dict(commit_identifier=31) if stream else {}
with ExitStack() as stack:
if native:
for method in ('_upsert_partition', '_upsert_row_partition'):
stack.enter_context(patch(
'pypaimon.execution.python.write.table_upsert_by_key.PythonTableUpsert.' + method,
side_effect=AssertionError('Python upsert fallback')))
stack.enter_context(patch('pypaimon.execution.python.write.merge_into._build_tables',
side_effect=AssertionError('Python MERGE fallback')))
if operation == 'merge':
messages = update.merge_into(source, on=['id'], when_matched=[
WhenMatched.update({'value': source_col('value')})], **kwargs)
elif operation == 'upsert_rows':
rows = [GenericRow([row[name] for name in table.field_names], table.fields) for row in source]
messages = update.with_update_type(['value']).upsert_by_key(rows, ['id'], **kwargs)
else:
messages = update.with_update_type(['value']).upsert_by_arrow_with_key(source, ['id'], **kwargs)
return builder, messages
def _rows(table):
builder = table.new_read_builder()
return builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_update_validates_selected_target_values_not_source_transport(
native_rest_catalog, native, stream, operation):
table, schema = _table(native_rest_catalog, native)
source = _source(schema, operation, invalid=False)
builder, messages = _messages(table, source, native, stream, operation)
commit = builder.new_commit()
try:
commit.commit(messages, 31) if stream else commit.commit(messages)
finally:
commit.close()
assert _rows(table) == [dict(id=1, value=20, keep=100)]
if isinstance(source, pa.Table):
assert source.schema.metadata == {b'origin': b'external-update'}
assert all(not field.nullable for field in source.schema)
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_selected_target_not_null_value_is_still_required(
native_rest_catalog, native, stream, operation):
table, schema = _table(native_rest_catalog, native)
snapshot = table.snapshot_manager().get_latest_snapshot().id
with pytest.raises(ValueError, match='(?i)null'):
_messages(table, _source(schema, operation, invalid=True), native, stream, operation)
assert table.snapshot_manager().get_latest_snapshot().id == snapshot
assert _rows(table) == [dict(id=1, value=10, keep=100)]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_selected_nullable_target_accepts_null(native_rest_catalog, native, operation):
table, schema = _table(native_rest_catalog, native, value_nullable=True)
builder, messages = _messages(table, _source(schema, operation, invalid=True), native, False, operation)
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert _rows(table) == [dict(id=1, value=None, keep=100)]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('reverse', [False, True], ids=['null-last', 'null-first'])
def test_merge_chunked_source_keeps_one_transport_schema(
native_rest_catalog, native, stream, reverse):
table, schema = _table(native_rest_catalog, native)
schema = schema.with_metadata({'origin': 'chunked-merge'})
# Each chunk needs a different relaxed transport field. The NULL key and
# unmatched value never reach a target assignment, regardless of chunk order.
rows = [dict(id=1, value=20, keep=100), dict(id=2, value=None, keep=None),
dict(id=None, value=30, keep=100)]
chunks = [pa.RecordBatch.from_pylist([dict(id=0, value=0, keep=0), row], schema=schema).slice(1)
for row in rows]
source = pa.Table.from_batches(chunks[::-1] if reverse else chunks)
encoded = pa.chunked_array([pa.DictionaryArray.from_arrays(
pa.array([0], type=pa.int8()), pa.array([None], type=pa.int32())) for _ in chunks])
source = source.append_column(pa.field('encoded_unused', encoded.type, nullable=False), encoded)
schema = source.schema
assert source.column('encoded_unused').null_count == 0
assert source.column('encoded_unused').to_pylist() == [None, None, None]
assert len(source.to_batches()) == 3
builder, messages = _messages(table, source, native, stream, 'merge')
commit = builder.new_commit()
try:
commit.commit(messages, 31) if stream else commit.commit(messages)
finally:
commit.close()
assert _rows(table) == [dict(id=1, value=20, keep=100)]
assert source.schema.equals(schema, check_metadata=True)
def _blob_table(catalog, native, payload_type, nullable, before):
schema = pa.schema([pa.field('id', pa.int32()),
pa.field('payload', payload_type, nullable=nullable)])
catalog.create_table('default.blobs', Schema.from_pyarrow_schema(schema, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
table = catalog.get_table('default.blobs')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist([dict(id=1, payload=before)], schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table, schema
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('input_', ['arrow', 'row'])
@pytest.mark.parametrize('nullable', [False, True], ids=['not-null', 'nullable'])
def test_selected_blob_nullability_applies_to_dedicated_files(
native_rest_catalog, native, input_, nullable):
table, schema = _blob_table(native_rest_catalog, native, pa.large_binary(), nullable, b'before')
snapshot = table.snapshot_manager().get_latest_snapshot().id
builder = table.new_batch_write_builder()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.write.table_update_by_row_id.'
'PythonTableUpdateByRowId._load_existing_files_info',
side_effect=AssertionError('Python row-ID update fallback')))
updater = builder.new_update().new_update_by_row_id()
def update():
if input_ == 'row':
return updater.update_row_columns(GenericRow([None], [table.field_dict['payload']]),
[0], ['payload'])
source = pa.Table.from_arrays([pa.array([0], type=pa.int64()),
pa.array([None], type=pa.large_binary())], schema=pa.schema([
pa.field('_ROW_ID', pa.int64()), schema.field('payload')]))
return updater.update_columns(source, ['payload'])
if nullable:
messages = update()
else:
with pytest.raises(ValueError, match='(?i)null'):
update()
messages = []
if nullable:
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
else:
assert table.snapshot_manager().get_latest_snapshot().id == snapshot
assert _rows(table) == [dict(id=1, payload=None if nullable else b'before')]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('payload_type,before,after', [
(pa.list_(pa.large_binary()), [b'before'], [None]),
(pa.map_(pa.string(), pa.large_binary()), [('key', b'before')], [('key', None)]),
], ids=['array', 'map'])
def test_not_null_blob_containers_keep_nullable_children(
native_rest_catalog, native, payload_type, before, after):
table, schema = _blob_table(native_rest_catalog, native, payload_type, False, before)
builder = table.new_batch_write_builder()
source = pa.Table.from_arrays([pa.array([0], type=pa.int64()), pa.array([after], type=payload_type)],
schema=pa.schema([pa.field('_ROW_ID', pa.int64()), schema.field('payload')]))
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.write.table_update_by_row_id.'
'PythonTableUpdateByRowId._load_existing_files_info',
side_effect=AssertionError('Python row-ID update fallback')))
messages = builder.new_update().new_update_by_row_id().update_columns(source, ['payload'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert _rows(table) == [dict(id=1, payload=after)]