blob: 0cf7bab4dc35a34233a67959f6c0032545df5d8b [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.
"""Filtered events retain Java's row-kind-specific top-level NULL checks."""
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.row.row_kind import RowKind
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.write.row_kind import skip_write_row
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])]
def _table(tmp_path, **options):
schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
pa.field('op', pa.string()),
pa.field('seq', pa.int64(), nullable=False),
pa.field('value', pa.int32(), nullable=False)])
config = {'bucket': '1', 'rowkind.field': 'op', 'ignore-delete': 'true',
'sequence.field': 'seq', 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}
config.update(options)
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, primary_keys=['id'], options=config), False)
return catalog.get_table('default.t'), schema
def _write(writer, table, schema, rows, input_):
if input_ == 'row':
for row in rows:
writer.write_row(GenericRow([row[field.name] for field in table.fields], table.fields))
elif input_ == 'batch':
writer.write_arrow_batch(pa.RecordBatch.from_pylist(rows, schema=schema))
elif input_ == 'pandas':
import pandas as pd
writer.write_pandas(pd.DataFrame.from_records(rows, columns=schema.names))
else:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('kind,field', [('-D', 'id'), ('-D', 'seq'), ('-U', 'value'), ('+I', 'value')])
def test_filtered_events_check_required_nulls_before_routing(tmp_path, input_, stream, kind, field):
table, schema = _table(tmp_path)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer = builder.new_write()
invalid = dict(id=1, op=kind, seq=1, value=10)
invalid[field] = None
try:
with pytest.raises(ValueError, match=r'non-null column\(%s\)' % field), \
patch.object(writer._execution.row_key_extractor, 'extract_partition_bucket_groups',
side_effect=AssertionError('invalid event reached bucket routing')):
_write(writer, table, schema, [invalid], input_)
assert not writer._execution.file_store_write.data_writers
finally:
writer.close()
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_ignored_delete_may_omit_non_key_not_null_values(tmp_path, input_, stream, native):
table, schema = _table(tmp_path, **{'write.native.enabled': str(native).lower()})
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
expected = dict(id=2, op='+I', seq=2, value=42)
try:
assert isinstance(writer, NativeTableWrite) == native
_write(writer, table, schema, [dict(id=1, op='-D', seq=1, value=None), expected], input_)
if native:
assert writer._python_writer is None
messages = writer.prepare_commit(1) if stream else writer.prepare_commit()
commit.commit(messages, 1) if stream else commit.commit(messages)
finally:
writer.close()
commit.close()
builder = table.new_read_builder()
assert builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist() == [expected]
@pytest.mark.parametrize('input_', ['batch', 'table'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
def test_large_string_row_kinds_filter_before_casting_payloads(tmp_path, input_, stream, native):
table, schema = _table(tmp_path, **{'write.native.enabled': str(native).lower()})
transport = pa.schema([field.with_type(pa.large_string()) if field.name == 'op' else field
for field in schema])
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
expected = dict(id=2, op='+I', seq=2, value=42)
try:
assert isinstance(writer, NativeTableWrite) == native
# A whole-table STRING cast checks unrelated top-level NOT NULL
# fields, and would reject this DELETE before its row kind is applied.
_write(writer, table, transport, [dict(id=1, op='-D', seq=1, value=None), expected], input_)
if native:
assert writer._python_writer is None
messages = writer.prepare_commit(1) if stream else writer.prepare_commit()
commit.commit(messages, 1) if stream else commit.commit(messages)
finally:
writer.close()
commit.close()
builder = table.new_read_builder()
assert builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist() == [expected]
@pytest.mark.native_plan
@_NATIVE
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('kind,field', [('-D', 'id'), ('-D', 'seq'), ('-U', 'value'), ('+I', 'value')])
def test_native_filtered_events_keep_required_null_checks(tmp_path, input_, stream, kind, field):
table, schema = _table(tmp_path, **{'write.native.enabled': 'true'})
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer = builder.new_write()
invalid = dict(id=1, op=kind, seq=1, value=10)
invalid[field] = None
try:
assert isinstance(writer, NativeTableWrite)
with pytest.raises(ValueError, match=r'non-null column\(%s\)' % field):
_write(writer, table, schema, [invalid], input_)
assert writer._python_writer is None
assert not list(tmp_path.rglob('*.parquet'))
assert table.snapshot_manager().get_latest_snapshot() is None
finally:
writer.close()
def test_native_pandas_facade_preserves_null_payload_for_core_filtering(tmp_path):
import pandas as pd
table, schema = _table(tmp_path)
native = Mock()
writer = NativeTableWrite(table, 'user', None, False, native)
rows = [dict(id=1, op='-D', seq=1, value=None), dict(id=2, op='+I', seq=2, value=42)]
writer.write_pandas(pd.DataFrame.from_records(rows, columns=schema.names))
native.write_arrow.assert_called_once()
transported = native.write_arrow.call_args[0][0]
assert transported.to_pylist() == rows
assert transported.schema.field('value').nullable
assert not writer._execution._schema.field('value').nullable
assert writer._python_writer is None
@pytest.mark.parametrize('primary_key', [False, True], ids=['append', 'pk-without-filter'])
def test_pandas_non_filtering_writers_keep_not_null_validation(tmp_path, primary_key):
import pandas as pd
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
pa.field('value', pa.int32(), nullable=False)])
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if primary_key else [], options={
'bucket': '1', 'write.native.enabled': 'false'}), False)
table = catalog.get_table('default.t')
data = pd.DataFrame([dict(id=1, value=None)])
writer = table.new_batch_write_builder().new_write()
facade = NativeTableWrite(table, 'native-user', None, False, Mock())
try:
with pytest.raises(ValueError, match='non-null column|non-nullable'):
writer.write_pandas(data)
if primary_key:
# The native facade preserves nullable KV transport; the actual
# core writer enforces logical INSERT nullability, covered by the
# retained DELETE integration tests.
facade.write_pandas(data)
transported = facade._native_writer.write_arrow.call_args[0][0]
assert transported.column('value').to_pylist() == [None]
assert transported.schema.field('value').nullable
assert not facade._execution._schema.field('value').nullable
else:
with pytest.raises(ValueError, match='non-nullable'):
facade.write_pandas(data)
facade._native_writer.write_arrow.assert_not_called()
assert not writer._execution.file_store_write.data_writers
finally:
writer.close()
def test_delete_null_exception_is_limited_to_non_cross_partition_deduplicate(tmp_path):
table, _ = _table(tmp_path)
values = dict(id=1, op='-D', seq=1, value=None)
assert skip_write_row(table, values)
for variant, cross_partition in [(table.copy({'merge-engine': 'first-row'}), False), (table, True)]:
with patch.object(variant, 'cross_partition_update', cross_partition), \
pytest.raises(ValueError, match=r'non-null column\(value\)'):
skip_write_row(variant, values)
@pytest.mark.parametrize('kind,field', [
(RowKind.DELETE, 'id'), (RowKind.DELETE, 'seq'), (RowKind.UPDATE_BEFORE, 'value')])
def test_internal_row_kinds_check_required_nulls_before_filtering(tmp_path, kind, field):
table, _ = _table(tmp_path, **{'rowkind.field': None})
writer = table.new_batch_write_builder().new_write()
values = dict(id=1, op=None, seq=1, value=10)
values[field] = None
try:
with pytest.raises(ValueError, match=r'non-null column\(%s\)' % field):
writer.write_row(GenericRow([values[field.name] for field in table.fields], table.fields, kind))
assert not writer._execution.file_store_write.data_writers
finally:
writer.close()
def test_native_bridge_leaves_nested_value_validation_to_core(tmp_path):
from pypaimon.execution.native.write.native_write import NativeTableWrite
transport = pa.schema([('items', pa.list_(pa.int32()))])
declared = pa.schema([('items', pa.list_(pa.field('element', pa.int32(), nullable=False)))])
data = pa.RecordBatch.from_arrays([pa.array([[None]], type=transport.field(0).type)], schema=transport)
writer = NativeTableWrite.__new__(NativeTableWrite)
writer._table_schema = writer._schema = declared
writer._write_cols = None
with patch('pypaimon.execution.native.write.native_write.normalize_arrow_write_nullability',
side_effect=AssertionError('bridge inspected values before core filtering'), create=True), \
patch('pypaimon.execution.native.write.native_write.normalize_arrow_strings',
side_effect=AssertionError('bridge cast values before core filtering'), create=True):
assert writer._prepare_native_arrow_data(data) is data
def test_schema_only_writer_keeps_strict_nested_validation():
from pypaimon.write.table_write import TableWrite
from pypaimon.execution.python.write.table_write import PythonTableWrite
writer = TableWrite.__new__(TableWrite)
writer._execution = PythonTableWrite(writer)
writer._execution.file_store_write = SimpleNamespace(write_cols=None)
writer._execution.table_pyarrow_schema = pa.schema([
('items', pa.list_(pa.field('element', pa.int32(), nullable=False)))])
data = pa.table({'items': [[None]]}, schema=pa.schema([('items', pa.list_(pa.int32()))]))
with pytest.raises(ValueError, match='consistent'):
writer._execution._prepare_arrow_data(data)
def test_python_filters_before_nested_normalization(tmp_path):
table, _ = _table(tmp_path)
writer = table.new_batch_write_builder().new_write()
declared = pa.schema([field if field.name != 'value' else field.with_type(
pa.list_(pa.field('element', pa.int32(), nullable=False))) for field in writer._execution.table_pyarrow_schema])
# Use a real table for options and routing, but a schema-only writer facade
# to observe normalization before any actual Parquet output is attempted.
writer._execution.table_pyarrow_schema = declared
transport = pa.schema([field if field.name != 'value' else field.with_type(pa.list_(pa.int32()))
for field in declared])
data = pa.table({'id': [1, 2], 'op': ['-D', '+I'], 'seq': [1, 2], 'value': [[None], [42]]}, schema=transport)
def normalize(selected, expected):
assert selected['id'].to_pylist() == [2]
assert expected == declared
return selected.cast(expected)
try:
with patch(
'pypaimon.execution.python.write.table_write.normalize_arrow_write_nullability',
side_effect=normalize):
selected = writer._execution._prepare_arrow_data(data)
assert selected.to_pydict() == {'id': [2], 'op': ['+I'], 'seq': [2], 'value': [[42]]}
finally:
writer.close()
@pytest.mark.parametrize('ignored', [False, True])
def test_python_retained_nested_schema_stays_strict_without_native_extension(tmp_path, ignored):
table, _ = _table(tmp_path)
writer = table.new_batch_write_builder().new_write()
writer._execution.table_pyarrow_schema = pa.schema([field if field.name != 'value' else field.with_type(
pa.list_(pa.field('element', pa.int32(), nullable=False))) for field in writer._execution.table_pyarrow_schema])
transport = pa.schema([field if field.name != 'value' else field.with_type(pa.list_(pa.int32()))
for field in writer._execution.table_pyarrow_schema])
data = pa.table({'id': [1], 'op': ['-D' if ignored else '+I'], 'seq': [1], 'value': [[None]]}, schema=transport)
try:
with patch('pypaimon.execution.native.runtime.native_runtime_available', return_value=False):
if ignored:
assert writer._execution._prepare_arrow_data(data).num_rows == 0
else:
with pytest.raises(ValueError, match='consistent'):
writer._execution._prepare_arrow_data(data)
assert not writer._execution.file_store_write.data_writers
finally:
writer.close()