blob: b0d1e282efe0408c7fa8a4d02431252344d43a08 [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.
"""Deduplicate tombstones persist nullable values without changing logical NOT NULL."""
from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
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
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, **options):
schema = pa.schema([pa.field('id', pa.int32(), nullable=False),
pa.field('seq', pa.int32(), nullable=False),
pa.field('value', pa.string(), nullable=False),
pa.field('op', pa.string())])
config = {'bucket': '1', 'sequence.field': 'seq', 'ignore-delete': 'false',
'rowkind.field': 'op',
'write.native.enabled': str(native).lower(), 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'}
config.update(options)
config = {key: value for key, value in config.items() if value is not None}
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_, bucket=None):
if input_ == 'row':
for values in rows:
row = GenericRow([values[field.name] for field in table.fields], table.fields,
RowKind.from_string(values['op']))
writer.write_row(row)
elif input_ == 'pandas':
import pandas as pd
writer.write_pandas(pd.DataFrame.from_records(rows, 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(rows, schema=schema)
writer.write_arrow(data.to_batches()[0] if input_ == 'batch' else data, bucket)
def _read(table, predicate=None):
reader = table.new_read_builder()
if predicate is not None:
reader.with_filter(predicate)
return reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('input_', ['row', 'batch', 'table', 'large_string', 'pandas'])
@pytest.mark.parametrize('stream', [False, True])
def test_retained_delete_persists_only_required_values(tmp_path, native, input_, stream):
table, schema = _table(tmp_path, native)
for identifier, (seq, value, kind, expected) in enumerate([
(10, 'before', '+I', [dict(id=1, seq=10, value='before', op='+I')]),
(9, None, '-D', [dict(id=1, seq=10, value='before', op='+I')]),
(11, None, '-D', [])], 1):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
assert isinstance(writer, NativeTableWrite) == native
_write(writer, table, schema, [dict(id=1, seq=seq, value=value, op=kind)], input_)
messages = writer.prepare_commit(identifier) if stream else writer.prepare_commit()
# Data and INPUT changelog writers share the relaxed value type.
for path in Path(tmp_path).rglob('*.parquet'):
physical = pq.read_schema(path)
assert physical.field('value').nullable
assert not physical.field('id').nullable
assert not physical.field('seq').nullable
commit.commit(messages, identifier) if stream else commit.commit(messages)
assert _read(table) == expected
predicate = table.new_read_builder().new_predicate_builder().equal('value', 'before')
assert _read(table, predicate) == expected
assert not table.table_schema.fields[2].type.nullable
if native:
assert writer._python_writer is None
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('input_', ['row', 'batch', 'large_string', 'pandas'])
@pytest.mark.parametrize('kind,field', [
('-D', 'id'), ('-D', 'seq'), ('-U', 'value'), ('+U', 'value'), ('+I', 'value')])
def test_retained_events_keep_logical_not_null_checks(tmp_path, native, kind, field, input_):
table, schema = _table(tmp_path, native)
writer = table.new_batch_write_builder().new_write()
row = dict(id=1, seq=1, value='ok', op=kind)
row[field] = None
try:
with pytest.raises((ValueError, RuntimeError), match='non-null column|non-nullable'):
_write(writer, table, schema, [row], input_)
assert not list(Path(tmp_path).rglob('*.parquet'))
finally:
writer.abort()
@pytest.mark.parametrize('native', _NATIVE)
def test_internal_row_delete_preserves_input_changelog_nulls(tmp_path, native):
table, schema = _table(tmp_path, native, **{'rowkind.field': None, 'changelog-producer': 'input'})
for seq, value, kind in [(1, 'before', '+I'), (2, None, '-D')]:
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
_write(writer, table, schema, [dict(id=1, seq=seq, value=value, op=kind)], 'row')
messages = writer.prepare_commit()
files = [file for message in messages for file in message.changelog_files]
assert len(files) == 1
path = next(Path(tmp_path).rglob(files[0].file_name))
assert pq.read_table(path, columns=['_VALUE_KIND', 'value']).to_pydict() == {
'_VALUE_KIND': [RowKind.from_string(kind).value], 'value': [value]}
commit.commit(messages)
finally:
writer.close()
commit.close()
assert _read(table) == []
@pytest.mark.parametrize('native', _NATIVE)
@pytest.mark.parametrize('engine', ['partial-update', 'aggregation', 'first-row'])
def test_delete_null_exception_does_not_relax_other_write_modes(tmp_path, native, engine):
options = {'merge-engine': engine}
if engine == 'first-row':
options['sequence.field'] = None
table, schema = _table(tmp_path, native, **options)
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises((ValueError, RuntimeError), match=r'non-null column\(value\)'):
_write(writer, table, schema, [dict(id=1, seq=1, value=None, op='-D')], 'table')
assert not list(Path(tmp_path).rglob('*.parquet'))
finally:
writer.abort()