blob: b329c548071a5ea457d0ed32c6ae2a5975085f18 [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.
"""Upsert converts only values selected by matching and last-write-wins."""
import datetime
from contextlib import ExitStack
from decimal import Decimal
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
from pypaimon.schema.data_types import PyarrowFieldParser
pytestmark = pytest.mark.native_plan
def _table(tmp_path, native, value_type=pa.int32()):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('key', pa.int32()), ('pt', pa.string()),
('value', value_type), ('keep', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, partition_keys=['pt'], options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': str(native).lower(),
}), False)
table = catalog.get_table('default.t')
return table, PyarrowFieldParser.from_paimon_schema(table.fields)
def _seed(table, schema, value):
builder = table.new_batch_write_builder()
writer = builder.new_write()
commit = builder.new_commit()
try:
writer.write_arrow(pa.table({'key': [1, 1, 1], 'pt': ['a', 'a', 'b'],
'value': [value] * 3, 'keep': [10, 11, 12]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
read = table.new_read_builder().with_projection(['keep', '_ROW_ID'])
rows = read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()
return [row['_ROW_ID'] for row in sorted(rows, key=lambda row: row['keep'])]
def _upsert(table, source, native, stream, columns=('value',)):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(
PythonTableUpsert, '_upsert_partition',
side_effect=AssertionError('upsert fell back to Python')))
update = builder.new_update().with_update_type(list(columns))
messages = (update.upsert_by_arrow_with_key(source, ['key'], 17) if stream
else update.upsert_by_arrow_with_key(source, ['key']))
commit = builder.new_commit()
try:
commit.commit(messages, 17) if stream else commit.commit(messages)
finally:
commit.close()
return messages
def _rows(table, native_modes=(False, True)):
for native in native_modes:
view = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
builder = view.new_read_builder().with_projection(['key', 'pt', 'value', 'keep', '_ROW_ID'])
rows = builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
yield sorted(rows, key=lambda row: row['keep'])
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('value_type,input_type,initial,value,expected', [
(pa.int32(), pa.string(), 0, '42', 42),
(pa.int32(), pa.float64(), 0, -42.9, -42),
(pa.int64(), pa.int32(), 0, 42, 42),
(pa.int32(), pa.null(), 0, None, None),
(pa.decimal128(8, 2), pa.string(), Decimal('0.00'), '42.25', Decimal('42.25')),
(pa.list_(pa.int32()), pa.list_(pa.string()), [], ['42', None, '-7'], [42, None, -7]),
(pa.struct([('n', pa.int32())]), pa.struct([('n', pa.string())]),
{'n': 0}, {'n': '42'}, {'n': 42}),
(pa.binary(), pa.string(), b'old', '中文', '中文'.encode()),
(pa.binary(), pa.null(), b'old', None, None),
(pa.large_binary(), pa.string(), b'old', 'new', b'new'),
(pa.large_binary(), pa.null(), b'old', None, None),
(pa.int32(), pa.binary(), 0, b'42', 42),
(pa.list_(pa.int32()), pa.list_(pa.binary()), [], [b'42', None], [42, None]),
(pa.struct([('data', pa.binary()), ('n', pa.int32())]),
pa.struct([('data', pa.binary()), ('n', pa.string())]),
{'data': b'old', 'n': 0}, {'data': b'new', 'n': '42'}, {'data': b'new', 'n': 42}),
(pa.struct([('data', pa.binary()), ('n', pa.string())]),
pa.struct([('data', pa.binary()), ('n', pa.int32())]),
{'data': b'old', 'n': 'old'}, {'data': b'new', 'n': 42}, {'data': b'new', 'n': '42'}),
(pa.map_(pa.string(), pa.binary()), pa.struct([('a', pa.string())]),
[('a', b'old')], {'a': 'new'}, [('a', b'new')]),
(pa.map_(pa.string(), pa.large_binary()), pa.map_(pa.string(), pa.string()),
[('a', b'old')], [('a', 'new')], [('a', b'new')]),
(pa.list_(pa.int32()), pa.struct([('dummy', pa.int32())]), [], None, None),
])
def test_matched_values_use_row_id_update_casts(
tmp_path, native, stream, value_type, input_type, initial, value, expected):
table, schema = _table(tmp_path, native, value_type)
row_ids = _seed(table, schema, initial)
source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
'value': pa.array([value], type=input_type)})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [expected, expected, initial]
assert [row['keep'] for row in rows] == [10, 11, 12]
assert [row['_ROW_ID'] for row in rows] == row_ids
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_matched_rows_ignore_unwritten_payload_columns(tmp_path, native, stream):
table, schema = _table(tmp_path, native)
_seed(table, schema, 0)
source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
'value': pa.array([42], type=pa.int32()), 'keep': ['not an integer']})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0]
assert [row['keep'] for row in rows] == [10, 11, 12]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('encoded_payload', [False, True], ids=['map', 'dictionary-map'])
@pytest.mark.parametrize('target_key,input_key,initial,value,expected', [
(pa.int32(), pa.int64(), 1, 2, 2),
(pa.string(), pa.dictionary(pa.int8(), pa.string()), 'old', 'new', 'new'),
(pa.decimal128(10, 2), pa.decimal128(8, 3), Decimal('1.00'), Decimal('1.235'), Decimal('1.24')),
(pa.time32('ms'), pa.time64('us'), datetime.time(1), datetime.time(1, 2, 3, 123456),
datetime.time(1, 2, 3, 123000)),
])
def test_matched_blob_map_keys_follow_java_serialization(
tmp_path, native, stream, encoded_payload, target_key, input_key, initial, value, expected):
table, schema = _table(tmp_path, native, pa.map_(target_key, pa.large_binary()))
row_ids = _seed(table, schema, [(initial, b'old')])
keys = (pa.array([value], type=input_key.value_type).dictionary_encode()
if pa.types.is_dictionary(input_key) else pa.array([value], type=input_key))
payload = pa.MapArray.from_arrays(
pa.array([0, 1], type=pa.int32()), keys, pa.array([b'new'], type=pa.large_binary()))
if encoded_payload:
payload = pa.DictionaryArray.from_arrays(pa.array([0], type=pa.int8()), payload)
source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 'value': payload})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [[(expected, b'new')]] * 2 + [[(initial, b'old')]]
assert [row['_ROW_ID'] for row in rows] == row_ids
# An unmatched outer key is an ordinary append, whose Arrow types remain strict.
source = source.set_column(0, 'key', pa.array([2], type=pa.int32()))
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['_ROW_ID'] for row in rows] == row_ids
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('key_type,input_type,initial,values', [
(pa.time32('ms'), pa.time64('us'), datetime.time(1),
[datetime.time(1, 2, 3, 123001), datetime.time(1, 2, 3, 123999)]),
(pa.decimal128(10, 2), pa.decimal128(8, 3), Decimal('1.00'), [Decimal('1.231'), Decimal('1.234')]),
])
def test_blob_map_duplicate_keys_are_rejected_after_java_normalization(
tmp_path, native, key_type, input_type, initial, values):
table, schema = _table(tmp_path, native, pa.map_(key_type, pa.large_binary()))
row_ids = _seed(table, schema, [(initial, b'old')])
source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
'value': pa.array([[(value, b'new') for value in values]],
type=pa.map_(input_type, pa.large_binary()))})
with pytest.raises(ValueError, match='keys must be unique'):
_upsert(table, source, native, False)
for rows in _rows(table):
assert [row['value'] for row in rows] == [[(initial, b'old')]] * 3
assert [row['_ROW_ID'] for row in rows] == row_ids
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_discarded_duplicate_values_are_not_cast(tmp_path, native, stream):
table, schema = _table(tmp_path, native)
_seed(table, schema, 0)
first = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 'value': ['invalid']})
last = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'], 'value': ['42']})
# The winning key is in a separate source chunk.
_upsert(table, pa.concat_tables([first, last]), native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_discarded_duplicates_do_not_validate_required_nested_children(tmp_path, native, stream):
value_type = pa.struct([pa.field('n', pa.int32(), nullable=False), pa.field('label', pa.string())])
table, schema = _table(tmp_path, native, value_type)
_seed(table, schema, {'n': 0, 'label': 'old'})
source = pa.table({'key': pa.array([1, 1], type=pa.int32()), 'pt': ['a', 'a'],
'value': pa.array([{'n': None, 'label': 'discarded'}, {'n': 42, 'label': 'final'}],
type=pa.struct([('n', pa.int32()), ('label', pa.string())]))})
_upsert(table, source, native, stream)
# Python's format reader cannot restore required ROW child metadata. Read
# the files from both update implementations through the native reader.
for rows in _rows(table, native_modes=(True,)):
assert [row['value'] for row in rows] == [
{'n': 42, 'label': 'final'}, {'n': 42, 'label': 'final'}, {'n': 0, 'label': 'old'}]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_invalid_surviving_update_value_propagates_without_retry(tmp_path, native, stream):
table, schema = _table(tmp_path, native)
_seed(table, schema, 0)
source = pa.table({'key': pa.array([1, 1], type=pa.int32()), 'pt': ['a', 'a'],
'value': ['42', 'invalid']})
with pytest.raises((ValueError, TypeError)):
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [0, 0, 0]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
def test_new_rows_keep_ordinary_write_type_validation(tmp_path, native, stream):
table, schema = _table(tmp_path, native)
row_ids = _seed(table, schema, 0)
source = pa.table({'key': pa.array([2], type=pa.int32()), 'pt': ['a'], 'value': ['42']})
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [0, 0, 0]
assert [row['_ROW_ID'] for row in rows] == row_ids
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('target_type,source_type,initial,value,expected', [
(pa.binary(), pa.large_binary(), b'old', b'\xff\0new', b'\xff\0new'),
(pa.large_binary(), pa.binary(), b'old', b'\xff\0new', b'\xff\0new'),
(pa.binary(), pa.large_binary(), b'old', None, None),
(pa.large_binary(), pa.binary(), b'old', None, None),
(pa.map_(pa.string(), pa.binary()),
pa.map_(pa.field('k', pa.string(), nullable=False), pa.field('v', pa.large_binary())),
[('a', b'old')], [('a', b'new')], [('a', b'new')]),
(pa.map_(pa.string(), pa.binary()), pa.struct([('a', pa.large_binary())]),
[('a', b'old')], {'a': b'new'}, [('a', b'new')]),
(pa.struct([('a', pa.binary())]), pa.map_(pa.string(), pa.large_binary()),
{'a': b'old'}, [('a', b'new')], {'a': b'new'}),
(pa.map_(pa.string(), pa.binary()),
pa.list_(pa.list_(pa.large_binary())),
[('a', b'old')], [[b'a', b'new']], [('a', b'new')]),
])
def test_matched_binary_values_use_existing_row_id_casts(
tmp_path, native, stream, target_type, source_type, initial, value, expected):
table, schema = _table(tmp_path, native, target_type)
row_ids = _seed(table, schema, initial)
source = pa.table({'key': pa.array([1], type=pa.int32()), 'pt': ['a'],
'value': pa.array([value], type=source_type)})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [expected, expected, initial]
assert [row['_ROW_ID'] for row in rows] == row_ids