blob: 27829d71351580a5c5349e2169ce297d1e2039bb [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.
"""Append event validation follows Java before routing or opening BLOB inputs."""
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
pytestmark = [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=False, evolution=False, generated=True, **options):
schema = pa.schema([pa.field('id', pa.int32(), nullable=False), ('op', pa.string()),
('pt', pa.string())])
config = {'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'data-evolution.enabled': str(evolution).lower(),
'row-tracking.enabled': str(evolution).lower()}
if generated:
config['rowkind.field'] = 'op'
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, partition_keys=['pt'], options=config), False)
return catalog.get_table('default.t'), schema
def _write(writer, table, schema, values, input_, generated):
if input_ == 'row':
for value in values:
kind = RowKind.INSERT if generated else RowKind.from_string(value['op'] or '+I')
writer.write_row(GenericRow([value[field.name] for field in table.fields], table.fields, kind))
elif input_ == 'pandas':
import pandas as pd
writer.write_pandas(pd.DataFrame.from_records(values, columns=schema.names))
else:
if input_ == 'large_string':
schema = pa.schema([field.with_type(pa.large_string()) if pa.types.is_string(field.type)
else field for field in schema])
data = pa.Table.from_pylist(values, schema=schema)
writer.write_arrow(data.to_batches()[0] if input_ == 'batch' else data)
def _read(table):
read = table.new_read_builder()
return sorted(read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist(), key=lambda x: x['id'])
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('evolution', [False, True])
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'large_string', 'pandas'])
@pytest.mark.parametrize('kind', ['-D', '-U'])
def test_append_retract_is_rejected(tmp_path, native, evolution, input_, kind):
table, schema = _table(tmp_path, native, evolution)
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(Exception, match='(?i)append.*row.*kind|append.*retract'):
_write(writer, table, schema, [dict(id=1, op=kind, pt='bad')], input_, True)
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('evolution', [False, True])
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'large_string', 'pandas'])
def test_append_ignore_delete_filters_before_partitioning(tmp_path, native, evolution, input_):
table, schema = _table(tmp_path, native, evolution, **{'ignore-delete': 'true'})
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
values = [dict(id=i, op=kind, pt=pt) for i, kind, pt in [
(1, '-D', 'delete'), (2, '+U', 'keep'), (3, '-U', 'before'), (4, '+I', 'keep')]]
try:
_write(writer, table, schema, values, input_, True)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [values[1], values[3]]
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('generated', [False, True])
@pytest.mark.parametrize('ignored', [False, True])
def test_append_internal_row_header_respects_retract_policy(tmp_path, native, generated, ignored):
table, schema = _table(tmp_path, native, generated=generated, **{'ignore-delete': str(ignored).lower()})
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
value = dict(id=1, op='-D', pt='p')
try:
if ignored:
_write(writer, table, schema, [value], 'row', generated)
assert writer.prepare_commit() == []
else:
with pytest.raises(Exception, match='(?i)append.*row.*kind|append.*retract'):
_write(writer, table, schema, [value], 'row', generated)
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
def test_append_checks_not_null_before_ignoring_delete(tmp_path, native, input_):
table, schema = _table(tmp_path, native, **{'ignore-delete': 'true'})
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(Exception, match='(?i)non-null|non nullable|non-nullable'):
_write(writer, table, schema, [dict(id=None, op='-D', pt='bad')], input_, True)
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
def test_append_ignore_before_does_not_ignore_delete(tmp_path, native):
table, schema = _table(tmp_path, native, **{'ignore-update-before': 'true'})
writer = table.new_batch_write_builder().new_write()
try:
writer.write_arrow(pa.Table.from_pylist([dict(id=1, op='-U', pt='bad')], schema=schema))
with pytest.raises(Exception, match='(?i)append.*row.*kind|append.*retract'):
writer.write_arrow(pa.Table.from_pylist([dict(id=2, op='-D', pt='bad')], schema=schema))
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
def test_append_configured_add_still_checks_internal_retract_header(tmp_path, native):
table, _ = _table(tmp_path, native)
writer = table.new_batch_write_builder().new_write()
try:
row = GenericRow([1, '+I', 'p'], table.fields, RowKind.DELETE)
with pytest.raises(Exception, match='(?i)append.*row.*kind|append.*retract'):
writer.write_row(row)
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('input_', ['row', 'batch'])
def test_append_write_type_checks_only_written_not_null_fields(tmp_path, native, input_):
table, schema = _table(tmp_path, native, generated=False)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
writer.with_write_type(['pt'])
value = dict(id=None, op=None, pt='p')
try:
_write(writer, table, schema, [value], input_, False)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [value]
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('ignored', [False, True])
def test_append_retract_never_opens_blob_input(tmp_path, native, ignored, monkeypatch):
from pypaimon.table.row.blob import BlobData
schema = pa.schema([('id', pa.int32()), ('op', pa.string()), ('payload', pa.large_binary())])
options = {'write.native.enabled': str(native).lower(), 'rowkind.field': 'op',
'ignore-delete': str(ignored).lower(), 'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'}
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options=options), False)
table = catalog.get_table('default.t')
writer = table.new_batch_write_builder().new_write()
blob = BlobData(b'unused')
def unexpected(*args):
raise AssertionError('Append retract opened its Blob payload')
monkeypatch.setattr(blob, 'to_data', unexpected)
monkeypatch.setattr(blob, 'new_input_stream', unexpected)
try:
row = GenericRow([1, '-D', blob], table.fields)
if ignored:
writer.write_row(row)
else:
with pytest.raises(Exception, match='(?i)append.*row.*kind|append.*retract'):
writer.write_row(row)
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('stream', [False, True])
def test_generated_primary_key_kinds_preserve_input_changelog(tmp_path, native, stream):
import pyarrow.parquet as pq
schema = pa.schema([('id', pa.int32()), ('value', pa.int32()), ('op', pa.string())])
options = {'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'bucket': '1', 'rowkind.field': 'op', 'changelog-producer': 'input'}
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=options), False)
table = catalog.get_table('default.t')
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
values = [dict(id=key, value=value, op=kind) for key, value, kind in [
(1, 10, '+I'), (1, 20, '-D'), (2, 25, '-U'), (2, 30, '+U')]]
try:
writer.write_arrow(pa.Table.from_pylist(values, schema=schema))
messages = writer.prepare_commit(1) if stream else writer.prepare_commit()
logs = [file for message in messages for file in message.changelog_files]
assert len(logs) == 1
assert logs[0].row_count == 4
assert logs[0].delete_row_count == 2
from pathlib import Path
path = next(Path(tmp_path).rglob(logs[0].file_name))
events = pq.read_table(path).to_pylist()
assert [(row['value'], row['_VALUE_KIND']) for row in events] == [(10, 0), (20, 3), (25, 1), (30, 2)]
if stream:
commit.commit(messages, 1)
else:
commit.commit(messages)
finally:
writer.close()
commit.close()
assert _read(table) == [values[-1]]
assert table.snapshot_manager().get_latest_snapshot().changelog_record_count == 4
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('input_', ['row', 'pandas'])
@pytest.mark.parametrize('write_cols', [['id'], ['id', 'pt']])
def test_append_write_type_retains_event_input_before_physical_projection(tmp_path, native, input_, write_cols):
table, schema = _table(tmp_path, native, **{'ignore-delete': 'true'})
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
writer.with_write_type(write_cols)
values = [dict(id=1, op='-D', pt='bad'), dict(id=2, op='+I', pt='keep')]
try:
_write(writer, table, schema, values, input_, True)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [dict(id=2, op=None, pt='keep')]
@pytest.mark.parametrize('native', _NATIVE)
def test_pandas_partial_event_transport_does_not_convert_unwritten_blob(tmp_path, native, monkeypatch):
import pandas as pd
from pypaimon.table.row.blob import BlobData
schema = pa.schema([('id', pa.int32()), ('op', pa.string()), ('payload', pa.large_binary())])
options = {'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'rowkind.field': 'op', 'ignore-delete': 'true',
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options=options), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
writer.with_write_type(['id'])
blob = BlobData(b'unwritten')
def unexpected(*args):
raise AssertionError('Unwritten Blob payload was opened')
monkeypatch.setattr(blob, 'to_data', unexpected)
monkeypatch.setattr(blob, 'new_input_stream', unexpected)
source = pd.DataFrame({'id': [1, 2], 'op': ['-D', '+I'], 'payload': [blob, blob]})
try:
writer.write_pandas(source)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert source['payload'].iloc[0] is blob
assert _read(table) == [dict(id=2, op=None, payload=None)]
def _pandas_subset_table(tmp_path, native):
schema = pa.schema([('id', pa.int32()), ('payload', pa.int32()), ('op', pa.string()), ('pt', pa.string())])
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, partition_keys=['pt'], options={
'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false', 'rowkind.field': 'op',
}), False)
return catalog.get_table('default.t')
@pytest.mark.parametrize('native', _NATIVE)
def test_pandas_partial_write_requires_every_selected_input(tmp_path, native):
import pandas as pd
table = _pandas_subset_table(tmp_path, native)
writer = table.new_batch_write_builder().new_write()
writer.with_write_type(['payload'])
try:
with pytest.raises(KeyError, match='payload'):
writer.write_pandas(pd.DataFrame({'id': [1], 'op': ['+I'], 'pt': ['p']}))
assert writer.prepare_commit() == []
finally:
writer.close()
@pytest.mark.parametrize('native', _NATIVE)
def test_pandas_partial_write_keeps_selected_named_index(tmp_path, native):
import pandas as pd
table = _pandas_subset_table(tmp_path, native)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
writer.with_write_type(['payload'])
source = pd.DataFrame({'id': [1], 'payload': [42], 'op': ['+I'], 'pt': ['p']}).set_index('payload')
try:
writer.write_pandas(source)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _read(table) == [dict(id=None, payload=42, op=None, pt='p')]
assert source.index.tolist() == [42]
@pytest.mark.python_plan
@pytest.mark.python_read
@pytest.mark.parametrize('buckets', [1, 2])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'pandas'])
def test_partial_write_keeps_bucket_key_until_routing(tmp_path, buckets, stream, input_):
schema = pa.schema([('id', pa.int32()), ('payload', pa.int32()), ('pt', pa.string())])
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, partition_keys=['pt'], options={
'bucket': str(buckets), 'bucket-key': 'id', 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false',
}), False)
table = catalog.get_table('default.t')
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
writer.with_write_type(['payload'])
values = [dict(id=1, payload=10, pt='a'), dict(id=2, payload=20, pt='b')]
try:
_write(writer, table, schema, values, input_, True)
messages = writer.prepare_commit(1) if stream else writer.prepare_commit()
assert all(file.write_cols == ['payload'] for message in messages for file in message.new_files)
assert all(0 <= message.bucket < buckets for message in messages)
if stream:
commit.commit(messages, 1)
else:
commit.commit(messages)
finally:
writer.close()
commit.close()
read = table.new_read_builder()
actual = read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()
assert sorted(actual, key=lambda row: row['payload']) == [
dict(id=None, payload=10, pt='a'), dict(id=None, payload=20, pt='b')]
@pytest.mark.parametrize('columns', [['payload', 'id'], []])
def test_partial_batch_projection_uses_columns_and_preserves_schema_metadata(columns):
from unittest.mock import Mock
from pypaimon.execution.python.write.file_store_write import FileStoreWrite
schema = pa.schema([pa.field('id', pa.int32()),
pa.field('payload', pa.string(), metadata={'role': 'value'})],
metadata={'source': 'batch'})
batch = pa.RecordBatch.from_pylist([dict(id=1, payload='kept')], schema=schema)
class ColumnBatch:
# Arrow 6 exposes column access, but has no RecordBatch.select API.
schema = batch.schema
num_rows = batch.num_rows
def column(self, index):
return batch.column(index)
writer = object.__new__(FileStoreWrite)
writer.table = Mock(field_names=schema.names)
writer.write_cols = columns
writer.data_writers = {}
writer._runtime_total_buckets = {}
writer._written_buckets = set()
writer._ever_written = False
writer.options = object()
target = Mock()
writer._create_data_writer = Mock(return_value=target)
writer.write((), 0, ColumnBatch())
actual = target.write.call_args[0][0]
assert actual.num_rows == batch.num_rows
assert actual.to_pydict() == {name: batch.column(name).to_pylist() for name in columns}
assert actual.schema.metadata == schema.metadata
if 'payload' in columns:
assert actual.schema.field('payload').metadata == schema.field('payload').metadata