blob: ed5df8a02df0c9ec43b8b86c48fb7ff5821f9b70 [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.
"""Nested Native update dispatch and parity with the Python implementation."""
from pypaimon.execution.python.write.table_update_by_row_id import PythonTableUpdateByRowId
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.table_update import TableUpdate
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
pytestmark = pytest.mark.native_plan
CASES = [
(pa.list_(pa.struct([('name', pa.string()), ('amount', pa.int32())])),
[[{'name': 'a', 'amount': 1}, None], [], None, [{'name': None, 'amount': 4}]]),
(pa.map_(pa.field('source_key', pa.string(), nullable=False),
pa.field('source_value', pa.list_(pa.int32()))),
[[('a', [1, None]), ('b', [])], [], None, [('c', None)]]),
(pa.struct([('items', pa.list_(pa.int32())), ('raw', pa.binary())]),
[{'items': [1, None], 'raw': b'abc'}, {'items': [], 'raw': None}, None,
{'items': None, 'raw': b''}]),
]
def _table(tmp_path, data_type, native):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', data_type), ('untouched', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': str(native).lower(),
}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': [0, 1, 2, 3], 'value': [None] * 4,
'untouched': [10, 11, 12, 13]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table, schema
def _native_only(native):
stack = ExitStack()
if native:
for cls, method in [(PythonTableUpdateByRowId, '_load_existing_files_info'),
(TableUpdate, '_build_predicate_update_table'),
(TableUpsertByKey, '_upsert_partition'),
(TableUpsertByKey, '_upsert_row_partition')]:
stack.enter_context(patch.object(cls, method, side_effect=AssertionError('Python fallback')))
return stack
def _commit(builder, messages, stream):
commit = builder.new_commit()
try:
commit.commit(messages, 7) if stream else commit.commit(messages)
finally:
commit.close()
def _check_rows(table, values):
for native in (False, True):
copy = table.copy({'scan.native-plan.enabled': str(native).lower(),
'read.native.enabled': str(native).lower()})
builder = copy.new_read_builder()
read = builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')
))
actual = read.to_arrow(builder.new_scan().plan().splits()).sort_by('id')
assert actual['value'].to_pylist() == values
assert actual['untouched'].to_pylist() == list(range(10, 10 + len(values)))
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('data_type,values', CASES)
@pytest.mark.parametrize('operation', [
'row_id', 'incremental', 'predicate_array', 'predicate_callable', 'predicate_scalar',
])
def test_nested_updates_use_core(tmp_path, native, stream, data_type, values, operation):
table, _ = _table(tmp_path, data_type, native)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
# Slices retain nonzero offsets in both the top level and nested buffers.
array = pa.array([values[0]] + values, type=data_type).slice(1)
data = pa.table({'_ROW_ID': [0, 1, 2, 3], 'value': array})
with _native_only(native):
if operation == 'row_id':
messages = (update.update_by_arrow_with_row_id(data, 7) if stream
else update.update_by_arrow_with_row_id(data))
elif operation == 'incremental':
writer = update.new_update_by_row_id(7) if stream else update.new_update_by_row_id()
messages = writer.update_columns(data, ['value'])
else:
predicate = PredicateBuilder(table.fields).greater_or_equal('id', 0)
if operation == 'predicate_callable':
def assignment(matched):
assert matched.column_names == ['id', '_ROW_ID']
return pa.array([values[i] for i in matched['id'].to_pylist()], type=data_type)
elif operation == 'predicate_scalar':
assignment = values[0]
else:
assignment = pa.chunked_array([array.slice(0, 1), array.slice(1)])
kwargs = {'read_columns': ['id']} if operation == 'predicate_callable' else {}
if stream:
messages = update.update_by_predicate(predicate, {'value': assignment}, 7, **kwargs)
else:
messages = update.update_by_predicate(predicate, {'value': assignment}, **kwargs)
_commit(builder, messages, stream)
_check_rows(table, [values[0]] * 4 if operation == 'predicate_scalar' else values)
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('data_type,values', CASES)
@pytest.mark.parametrize('row_input', [False, True])
def test_nested_upsert_updates_and_appends(tmp_path, native, stream, data_type, values, row_input):
table, schema = _table(tmp_path, data_type, native)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
ids = [0, 0, 1, 2, 3, 4]
source_values = [None] + values + [values[0]]
data = pa.table({'id': ids, 'value': source_values,
'untouched': [99, 99, 99, 99, 99, 14]}, schema=schema)
with _native_only(native):
if row_input:
rows = [GenericRow([row[name] for name in schema.names], table.fields)
for row in data.to_pylist()]
messages = (update.upsert_by_key(rows, ['id'], 7) if stream
else update.upsert_by_key(rows, ['id']))
else:
# Column order is unrelated to the schema/updated-column order.
data = data.select(['untouched', 'value', 'id'])
messages = (update.upsert_by_arrow_with_key(data, ['id'], 7) if stream
else update.upsert_by_arrow_with_key(data, ['id']))
_commit(builder, messages, stream)
_check_rows(table, values + [values[0]])
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('input_values,target', [
(pa.array([{'a': 1.5, 'b': 2.0}, None]), pa.map_(pa.string(), pa.int32())),
(pa.array([{'a': 1, 'b': None}, None]), pa.map_(pa.string(), pa.int32())),
(pa.array([{}, None]), pa.map_(pa.string(), pa.int32())),
(pa.array([[[1.0, 2.5], [3.0, 4.0]], None, []]), pa.map_(pa.int32(), pa.int32())),
(pa.array([[[1, 2, 3]], None]), pa.map_(pa.int32(), pa.int32())),
(pa.array([[1.5, None], [], None]), pa.list_(pa.int32())),
(pa.array([{'a': 1, 'b': 1.5}]), pa.struct([('a', pa.string()), ('b', pa.int32())])),
(pa.array([{'m': {'a': 1, 'b': None}}, None]),
pa.struct([('m', pa.map_(pa.string(), pa.int32()))])),
(pa.array([{'m': [[1, 2]]}]), pa.struct([('m', pa.map_(pa.int32(), pa.int32()))])),
(pa.array([[{'a': 1, 'b': None}], None]), pa.list_(pa.map_(pa.string(), pa.int32()))),
(pa.array([[[[1, 2]]]]), pa.list_(pa.map_(pa.int32(), pa.int32()))),
(pa.array([[('a', 1), ('b', 2)], None], type=pa.map_(pa.string(), pa.int32())),
pa.struct([('a', pa.int32()), ('b', pa.int32())])),
(pa.array([[('a', 1.5), ('b', 2.5), ('ignored', float('nan'))], [('a', 3.5)]],
type=pa.map_(pa.string(), pa.float64())),
pa.struct([('a', pa.int32()), ('b', pa.int32())])),
(pa.array([[('b', 1), ('a', 2)]], type=pa.map_(pa.string(), pa.int32())),
pa.struct([('a', pa.int32()), ('b', pa.int32())])),
(pa.StructArray.from_arrays([
pa.DictionaryArray.from_arrays(pa.array([0, 1], type=pa.int8()),
pa.array([1, None], type=pa.int32()))], names=['a']),
pa.map_(pa.string(), pa.int32())),
])
def test_nested_row_id_coercion_matches_python(tmp_path, native, input_values, target):
table, _ = _table(tmp_path, target, native)
builder = table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
data = pa.table({'_ROW_ID': list(range(len(input_values))), 'value': input_values})
before = set(tmp_path.rglob('*.parquet'))
try:
expected = PythonTableUpdateByRowId._coerce_column(input_values, target).to_pylist()
except (ValueError, TypeError, pa.ArrowException):
with _native_only(native), pytest.raises((ValueError, TypeError, pa.ArrowException)):
update.update_by_arrow_with_row_id(data)
assert set(tmp_path.rglob('*.parquet')) == before
expected = [None] * len(input_values)
else:
with _native_only(native):
messages = update.update_by_arrow_with_row_id(data)
_commit(builder, messages, False)
_check_rows(table, expected + [None] * (4 - len(input_values)))
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('operation', ['row_id', 'predicate'])
def test_nested_struct_reordering_uses_operation_cast_rules(tmp_path, native, operation):
target = pa.struct([('b', pa.int32()), ('a', pa.int32())])
values = pa.array([{'a': 1, 'b': 2}])
table, _ = _table(tmp_path, target, native)
builder = table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
predicate = PredicateBuilder(table.fields).equal('id', 0)
before = set(tmp_path.rglob('*.parquet'))
with _native_only(native):
if operation == 'predicate':
# Safe assignment casts reject reordering; row-ID coercion retries
# the whole input as dictionaries and can match ROW fields by name.
with pytest.raises((ValueError, pa.ArrowException)):
update.update_by_predicate(predicate, {'value': values})
assert set(tmp_path.rglob('*.parquet')) == before
expected = [None] * 4
else:
messages = update.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0], 'value': values}))
_commit(builder, messages, False)
expected = [{'b': 2, 'a': 1}, None, None, None]
_check_rows(table, expected)
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('layout', ['list_view', 'large_list_view'])
def test_view_pairs_update_map(tmp_path, native, layout):
if not hasattr(pa, layout):
pytest.skip('List view arrays require a newer PyArrow version')
target = pa.map_(pa.int32(), pa.int32())
table, _ = _table(tmp_path, target, native)
values = pa.array([[[99, 99, 99]], [[1, 2], [3, None]], None, []],
type=getattr(pa, layout)(pa.list_(pa.int64()))).slice(1)
builder = table.new_batch_write_builder()
with _native_only(native):
messages = builder.new_update().update_by_arrow_with_row_id(
pa.table({'_ROW_ID': [0, 1, 2], 'value': values}))
_commit(builder, messages, False)
_check_rows(table, [[(1, 2), (3, None)], None, [], None])
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('data_type,values', CASES)
@pytest.mark.parametrize('overlap', [False, True])
def test_nested_grouped_updates_preserve_files_on_overlap(tmp_path, native, data_type, values, overlap):
table, _ = _table(tmp_path, data_type, native)
builder = table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
data = pa.table({'_ROW_ID': [0, 1, 2, 3], 'value': pa.array(values, type=data_type)})
before = set(tmp_path.rglob('*.parquet'))
with _native_only(native):
if overlap:
with pytest.raises(ValueError):
update.update_by_arrow_batches_with_row_id(iter([data, data]))
assert set(tmp_path.rglob('*.parquet')) > before
else:
messages = update.update_by_arrow_batches_with_row_id(iter([data.slice(0, 0), data]))
_commit(builder, messages, False)
_check_rows(table, [None] * 4 if overlap else values)
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('key_type', ['string', 'large_string', 'string_view', 'binary',
'large_binary', 'binary_view', 'fixed_binary'])
def test_map_field_name_encodings(tmp_path, native, key_type):
if key_type == 'fixed_binary':
key_type = pa.binary(1)
elif hasattr(pa, key_type):
key_type = getattr(pa, key_type)()
else:
pytest.skip('View arrays require a newer PyArrow version')
target = pa.struct([('a', pa.int32()), ('b', pa.int32())])
table, _ = _table(tmp_path, target, native)
values = pa.array([[('a', 1.5), ('b', 2.5), ('x', float('nan'))], [('a', 3.5)]],
type=pa.map_(key_type, pa.float64()))
if not native:
try:
values.take(pa.array([0], type=pa.int32()))
except pa.ArrowNotImplementedError:
pytest.skip('This PyArrow version cannot filter MAPs with view keys')
builder = table.new_batch_write_builder()
with _native_only(native):
messages = builder.new_update().update_by_arrow_with_row_id(
pa.table({'_ROW_ID': [0, 1], 'value': values}))
_commit(builder, messages, False)
_check_rows(table, [{'a': 1, 'b': 2}, {'a': 3, 'b': None}, None, None])