blob: 087577c72423a15c24d9ecb1074f4f750bd14469 [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.
"""Existing upsert keys compare logical values, independently of encoding."""
from contextlib import ExitStack
from pathlib import Path
from urllib.parse import unquote, urlparse
from unittest.mock import MagicMock, patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.execution.native.write.native_update import (
_native_upsert_key_compatible, _native_upsert_key_type_compatible, create_native_upsert,
)
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust is not installed'))]
_KINDS = [
'dense_union', 'sparse_union', 'dictionary_dense_union', 'dictionary_sparse_union',
pytest.param('dictionary_string_view', marks=pytest.mark.skipif(
not hasattr(pa, 'string_view'), reason='PyArrow view arrays are unavailable')),
pytest.param('dictionary_binary_view', marks=pytest.mark.skipif(
not hasattr(pa, 'binary_view'), reason='PyArrow view arrays are unavailable')),
pytest.param('dictionary_run_end', marks=pytest.mark.skipif(
not hasattr(pa, 'RunEndEncodedArray'), reason='PyArrow run-end arrays are unavailable')),
]
def _files(table, pattern='*'):
root = Path(unquote(urlparse(table.table_path).path))
assert root.is_dir(), table.table_path
return set(root.rglob(pattern))
def _union(dense):
# The first logical value overflows INT and the final child never occurs.
# Slicing excludes the first value; inactive sparse children still contain
# overflows and text which must never participate in key conversion.
tags = pa.array([42, 7, 42, 7, 42], type=pa.int8())
fields = ['small', 'large', 'unused']
if dense:
children = [pa.array([0, 1, 2], type=pa.int32()),
pa.array([2 ** 63 - 1, 1, None], type=pa.int64()), pa.array(['unused'])]
return pa.UnionArray.from_dense(tags, pa.array([0, 1, 1, 2, 2], type=pa.int32()),
children, field_names=fields, type_codes=[7, 42, 99])
children = [pa.array([0, 1, 0, 2, 0], type=pa.int32()),
pa.array([2 ** 63 - 1, 2 ** 63 - 1, 1, 2 ** 63 - 1, None], type=pa.int64()),
pa.array(['unused'] * 5)]
return pa.UnionArray.from_sparse(tags, children, field_names=fields, type_codes=[7, 42, 99])
def _keys(kind):
if 'union' in kind:
values = _union('sparse' not in kind).slice(1, 4)
if kind.startswith('dictionary'):
values = pa.DictionaryArray.from_arrays(pa.array([0, None, 1, 2], type=pa.int8()), values)
else:
values = values.take(pa.array([0, 3, 1, 2], type=pa.int32()))
return pa.int32(), values, [1, 2]
if kind == 'dictionary_run_end':
values = pa.RunEndEncodedArray.from_arrays(
pa.array([2, 4, 5, 6], type=pa.int16()), pa.array([999, 1, 2, None], type=pa.int32()))
values = values.slice(2, 4)
values = pa.DictionaryArray.from_arrays(pa.array([0, None, 1, 2], type=pa.int8()), values)
return pa.int32(), values, [1, 2]
binary = kind == 'dictionary_binary_view'
first, second = (b'\xff\0', b'longer-than-inline-view') if binary else ('a', 'longer-than-inline-view')
view = pa.binary_view() if binary else pa.string_view()
values = pa.array([first, second, None, first], type=view)
values = pa.DictionaryArray.from_arrays(pa.array([0, None, 3, 1], type=pa.int8()), values)
return pa.binary() if binary else pa.string(), values, [first, second]
def _table(catalog, key_type, keys, native, guarded):
schema = pa.schema([('key', key_type), ('p', pa.string()), ('value', pa.int32()), ('keep', pa.string())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, partition_keys=['p'], options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'query-auth.enabled': str(guarded).lower(), 'write.native.enabled': 'false',
'commit.native.enabled': 'false', 'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
table = catalog.get_table('default.t')
rows = [dict(key=keys[0], p='a', value=10, keep='first'),
dict(key=keys[0], p='a', value=11, keep='duplicate'),
dict(key=keys[1], p='a', value=20, keep='second'),
dict(key=None, p='a', value=30, keep='null'),
dict(key=keys[0], p='b', value=40, keep='other-partition')]
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table.copy({'write.native.enabled': str(native).lower()})
def _native_only(stack, native):
if native:
stack.enter_context(patch.object(PythonTableUpsert, '_upsert_partition',
side_effect=AssertionError('Python Arrow upsert fallback')))
stack.enter_context(patch.object(PythonTableUpsert, '_upsert_row_partition',
side_effect=AssertionError('Python Row upsert fallback')))
def _read(table):
read = table.new_read_builder()
return read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('keep').to_pylist()
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('guarded', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('kind', _KINDS)
def test_encoded_upsert_keys_preserve_last_wins_nulls_and_partitions(
native_rest_catalog, native, guarded, stream, kind):
key_type, keys, seed = _keys(kind)
table = _table(native_rest_catalog, key_type, seed, native, guarded)
values = pa.array([90, 91, 92, 93], type=pa.int32())
# Duplicate source keys cross Arrow batch boundaries, while target keys
# fan out to duplicate existing rows within one partition only.
source = pa.table({'value': values, 'p': ['a'] * 4, 'key': keys})
source = pa.concat_tables([source.slice(0, 2), source.slice(2)])
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with ExitStack() as stack:
_native_only(stack, native)
update = builder.new_update().with_update_type(['value'])
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()
assert _read(table) == [
dict(key=seed[0], p='a', value=92, keep='duplicate'),
dict(key=seed[0], p='a', value=92, keep='first'),
dict(key=None, p='a', value=91, keep='null'),
dict(key=seed[0], p='b', value=40, keep='other-partition'),
dict(key=seed[1], p='a', value=93, keep='second'),
]
@pytest.mark.parametrize('kind', _KINDS)
def test_native_key_gate_qualifies_existing_encoded_upsert_layouts(kind):
key_type, keys, _ = _keys(kind)
assert _native_upsert_key_type_compatible(keys.type, key_type)
if kind in ('dictionary_string_view', 'dictionary_binary_view', 'dictionary_run_end'):
assert not _native_upsert_key_type_compatible(keys.dictionary.type, key_type), (
'Bare views/run-end arrays have no PyArrow take kernel and remain outside this bridge')
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('invalid', [2 ** 63 - 1, 1.5, '1'])
def test_active_union_key_cast_failure_never_matches_or_stages(
native_rest_catalog, native, invalid):
table = _table(native_rest_catalog, pa.int32(), [1, 2], native, False)
child = pa.array([invalid])
keys = pa.UnionArray.from_dense(pa.array([7], type=pa.int8()), pa.array([0], type=pa.int32()),
[child, pa.array([1], type=pa.int32())], type_codes=[7, 42])
source = pa.table({'key': keys, 'p': ['a'], 'value': pa.array([99], type=pa.int32())})
before = _files(table, '*.parquet')
snapshot = table.snapshot_manager().get_latest_snapshot().id
expected = _read(table)
with ExitStack() as stack:
_native_only(stack, native)
with pytest.raises((ValueError, RuntimeError, pa.ArrowInvalid)):
table.new_batch_write_builder().new_update().with_update_type(['value']).upsert_by_arrow_with_key(
source, ['key'])
assert table.snapshot_manager().get_latest_snapshot().id == snapshot
assert _files(table, '*.parquet') == before
assert _read(table) == expected
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('dense', [False, True])
@pytest.mark.parametrize('dictionary', [False, True])
@pytest.mark.parametrize('child_type', [pa.string(), pa.struct([('unused', pa.string())])])
def test_typed_null_union_children_match_existing_null_keys(
native_rest_catalog, native, dense, dictionary, child_type):
table = _table(native_rest_catalog, pa.int32(), [1, 2], native, True)
tags = pa.array([7, 42], type=pa.int8())
if dense:
keys = pa.UnionArray.from_dense(
tags, pa.array([0, 0], type=pa.int32()),
[pa.array([1], type=pa.int32()), pa.nulls(1, type=child_type)], type_codes=[7, 42])
else:
inactive = 'not an integer' if pa.types.is_string(child_type) else {'unused': 'not an integer'}
keys = pa.UnionArray.from_sparse(
tags, [pa.array([1, 0], type=pa.int32()), pa.array([inactive, None], type=child_type)],
type_codes=[7, 42])
if dictionary:
keys = pa.DictionaryArray.from_arrays(pa.array([0, 1], type=pa.int8()), keys)
source = pa.table({'key': keys, 'p': ['a', 'a'], 'value': pa.array([90, 91], type=pa.int32())})
source = pa.concat_tables([source.slice(0, 1), source.slice(1)])
builder = table.new_batch_write_builder()
with ExitStack() as stack:
_native_only(stack, native)
messages = builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(source, ['key'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert _read(table) == [
dict(key=1, p='a', value=90, keep='duplicate'), dict(key=1, p='a', value=90, keep='first'),
dict(key=None, p='a', value=91, keep='null'), dict(key=1, p='b', value=40, keep='other-partition'),
dict(key=2, p='a', value=20, keep='second'),
]
def _typed_null_keys(kind, value_type):
if kind == 'plain':
return pa.nulls(3, type=value_type)
unused = 'not an integer' if pa.types.is_string(value_type) else {'unused': 'not an integer'}
values = pa.array([unused, None, unused], type=value_type)
if kind == 'nested_dictionary':
values = pa.DictionaryArray.from_arrays(pa.array([0, 1, 2], type=pa.int8()), values)
return pa.DictionaryArray.from_arrays(pa.array([0, 1, None, 1, 2], type=pa.int8()), values).slice(1, 3)
def _typed_null_union_keys(dense, dictionary, non_null=False):
tags = pa.array([7, 42, 7, 42, 7], type=pa.int8())
middle = '1' if non_null else None
row_type = pa.struct([('unused', pa.string())])
if dense:
values = pa.UnionArray.from_dense(
tags, pa.array([0, 0, 1, 0, 2], type=pa.int32()),
[pa.array(['outside', middle, 'outside']),
pa.array([None, {'unused': 'unselected'}], type=row_type)], type_codes=[7, 42])
else:
values = pa.UnionArray.from_sparse(
tags, [pa.array(['outside', 'inactive', middle, 'inactive', 'outside']),
pa.array([{'unused': 'inactive'}, None, {'unused': 'inactive'}, None,
{'unused': 'inactive'}], type=row_type)], type_codes=[7, 42])
# Nonzero parent offsets must select child tags/offsets from the slice.
values = values.slice(1, 3)
if dictionary:
values = pa.DictionaryArray.from_arrays(
pa.array([0, 0, 1, None, 2], type=pa.int8()), values).slice(1, 3)
return values
@pytest.mark.parametrize('dense', [False, True])
@pytest.mark.parametrize('dictionary', [False, True])
@pytest.mark.parametrize('non_null', [False, True])
def test_null_union_key_gate_uses_selected_logical_values(dense, dictionary, non_null):
keys = _typed_null_union_keys(dense, dictionary, non_null)
assert keys.to_pylist() == [None, '1' if non_null else None, None]
assert not _native_upsert_key_type_compatible(keys.type, pa.int32())
assert _native_upsert_key_compatible(keys, pa.int32()) is not non_null
chunks = pa.chunked_array([keys.slice(0, 1), keys.slice(1)])
assert _native_upsert_key_compatible(chunks, pa.int32()) is not non_null
def test_unrelated_non_null_key_types_still_use_the_existing_python_path():
for source in [pa.array([None, '1']), pa.array([True]), pa.array([{'unused': 'value'}])]:
assert not _native_upsert_key_compatible(source, pa.int32())
source = pa.DictionaryArray.from_arrays(
pa.array([0, 1], type=pa.int8()), pa.array([None, '1']))
assert not _native_upsert_key_compatible(source, pa.int32())
assert not _native_upsert_key_compatible(pa.chunked_array([source.slice(0, 1), source]), pa.int32())
# Already compatible types do not inspect value or kernel availability.
with patch('pypaimon.execution.native.write.native_update.pc.take', side_effect=AssertionError('unnecessary take')):
assert _native_upsert_key_compatible(pa.array([1], type=pa.int32()), pa.int32())
@pytest.mark.parametrize('kind', ['plain', 'dictionary', 'nested_dictionary'])
@pytest.mark.parametrize('value_type', [pa.string(), pa.struct([('unused', pa.string())])])
def test_all_null_key_columns_select_native_even_with_an_unrelated_physical_type(
native_rest_catalog, kind, value_type):
table = _table(native_rest_catalog, pa.int32(), [1, 2], False, True)
keys = _typed_null_keys(kind, value_type)
source = pa.table({'key': keys, 'p': ['a'] * 3, 'value': pa.array([90, 91, 92], type=pa.int32())})
source = pa.concat_tables([source.slice(0, 1), source.slice(1)])
native_table = MagicMock()
with patch('pypaimon.execution.native.write.native_update._native_row_id_table', return_value=native_table):
selected = create_native_upsert(table, 'null-keys', source, ['key'], ['value'])
assert selected is not None
native_table.new_batch_write_builder.assert_called_once()
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('kind', ['plain', 'dictionary', 'nested_dictionary'])
@pytest.mark.parametrize('value_type', [pa.string(), pa.struct([('unused', pa.string())])])
def test_all_null_key_columns_update_existing_null_rows(
native_rest_catalog, native, kind, value_type):
table = _table(native_rest_catalog, pa.int32(), [1, 2], native, True)
keys = _typed_null_keys(kind, value_type)
source = pa.table({'key': keys, 'p': ['a'] * 3, 'value': pa.array([90, 91, 92], type=pa.int32())})
source = pa.concat_tables([source.slice(0, 1), source.slice(1)])
builder = table.new_batch_write_builder()
with ExitStack() as stack:
_native_only(stack, native)
messages = builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(source, ['key'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert _read(table) == [
dict(key=1, p='a', value=11, keep='duplicate'), dict(key=1, p='a', value=10, keep='first'),
dict(key=None, p='a', value=92, keep='null'), dict(key=1, p='b', value=40, keep='other-partition'),
dict(key=2, p='a', value=20, keep='second'),
]
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('dense', [False, True])
@pytest.mark.parametrize('dictionary', [False, True])
def test_all_null_union_keys_update_existing_null_rows(
native_rest_catalog, native, dense, dictionary):
table = _table(native_rest_catalog, pa.int32(), [1, 2], native, True)
keys = _typed_null_union_keys(dense, dictionary)
source = pa.table({'key': keys, 'p': ['a'] * 3, 'value': pa.array([90, 91, 92], type=pa.int32())})
source = pa.concat_tables([source.slice(0, 1), source.slice(1)])
builder = table.new_batch_write_builder()
with ExitStack() as stack:
_native_only(stack, native)
messages = builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(source, ['key'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
assert _read(table) == [
dict(key=1, p='a', value=11, keep='duplicate'), dict(key=1, p='a', value=10, keep='first'),
dict(key=None, p='a', value=92, keep='null'), dict(key=1, p='b', value=40, keep='other-partition'),
dict(key=2, p='a', value=20, keep='second'),
]