blob: 223fda60793cd704a1df478710ab408d2a40fbf1 [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.
"""Equivalent Arrow layouts must not send upsert matching back to Python."""
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.schema.data_types import PyarrowFieldParser
from pypaimon.write.table_update import TableUpdate
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
pytestmark = pytest.mark.native_plan
_LAYOUTS = [
(pa.string(), pa.large_string(), ['old', '', None, '中文']),
(pa.list_(pa.string()), pa.list_(pa.large_string()),
[['old'], [], None, ['中文', None, '']]),
(pa.struct([('text', pa.string()), ('count', pa.int32())]),
pa.struct([('text', pa.large_string()), ('count', pa.int32())]),
[{'text': 'old', 'count': 1}, None, {'text': None, 'count': 2}, {'text': '中文', 'count': 3}]),
(pa.map_(pa.string(), pa.list_(pa.string())),
pa.map_(pa.large_string(), pa.list_(pa.large_string())),
[[('old', ['first'])], [], None, [('中文', [None, ''])]]),
]
def _table(catalog, key_type, value_type, native):
catalog.create_database('default', True)
schema = pa.schema([('key', key_type), ('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 _commit(builder, messages, stream=False):
commit = builder.new_commit()
try:
commit.commit(messages, 7) if stream else commit.commit(messages)
finally:
commit.close()
def _seed(table, schema, keys, values):
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_arrow(pa.table({'key': keys, 'pt': ['a', 'a', 'a', 'b'],
'value': values, 'keep': [0, 1, 2, 3]}, schema=schema))
_commit(builder, writer.prepare_commit())
finally:
writer.close()
def _native_only(native):
stack = ExitStack()
if native:
for owner, method in [(TableUpsertByKey, '_upsert_partition'),
(PythonTableUpdateByRowId, '_load_existing_files_info'),
(TableUpdate, '_build_predicate_update_table')]:
stack.enter_context(patch.object(owner, method, side_effect=AssertionError('Python update fallback')))
return stack
def _check(table, values, key, new_key='new'):
# Check both readers, including the row identity of the two updated rows.
for native in (False, True):
view = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
reader = view.new_read_builder().with_projection(['key', 'pt', 'value', 'keep', '_ROW_ID'])
result = reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
existing = sorted([row for row in result if row['keep'] is not None], key=lambda row: row['keep'])
assert [row['value'] for row in existing] == [values[3], values[3], values[2], values[0]]
assert [row['key'] for row in existing] == [key, key, None, key]
assert [row['pt'] for row in existing] == ['a', 'a', 'a', 'b']
assert sorted(row['_ROW_ID'] for row in existing) == [0, 1, 2, 3]
added = [row for row in result if row['keep'] is None]
assert len(added) == 1
assert added[0]['key'] == new_key
assert added[0]['value'] == values[1] and added[0]['pt'] == 'a'
assert added[0]['_ROW_ID'] == 4
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('value_type,input_type,values', _LAYOUTS)
def test_upsert_large_strings_in_keys_partitions_and_nested_values(
tmp_path, native, stream, value_type, input_type, values):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
table, schema = _table(catalog, pa.string(), value_type, native)
_seed(table, schema, ['key', 'key', None, 'key'], [values[0]] * 4)
source = pa.table({
'value': pa.array([values[0], values[1], values[2], values[3]], type=input_type),
'pt': pa.array(['a'] * 4, type=pa.large_string()),
'key': pa.array(['key', 'new', None, 'key'], type=pa.large_string()),
})
# Chunks have separate string offsets; the duplicate key spans chunks.
source = pa.concat_tables([source.slice(0, 1), source.slice(1, 2), source.slice(3)])
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with _native_only(native):
update = builder.new_update().with_update_type(['value'])
messages = (update.upsert_by_arrow_with_key(source, ['key'], 7) if stream
else update.upsert_by_arrow_with_key(source, ['key']))
_commit(builder, messages, stream)
_check(table, values, 'key')
@pytest.mark.parametrize('native', [False, True])
def test_upsert_equivalent_binary_keys_on_rest_table(native_rest_catalog, native):
table, schema = _table(native_rest_catalog, pa.binary(), pa.string(), native)
_seed(table, schema, [b'ab', b'ab', None, b'ab'], ['old'] * 4)
source = pa.table({'key': pa.array([b'ab', b'cd', None, b'ab'], type=pa.binary(2)),
'pt': ['a'] * 4,
'value': pa.array(['discarded', '', None, '中文'], type=pa.large_string())})
builder = table.new_batch_write_builder()
with _native_only(native):
messages = builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(source, ['key'])
_commit(builder, messages)
_check(table, ['old', '', None, '中文'], b'ab', b'cd')
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('visible_null', [False, True])
@pytest.mark.parametrize('operation', ['upsert', 'row_id', 'predicate'])
def test_large_strings_preserve_required_nested_fields(tmp_path, native, visible_null, operation):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
value_type = pa.struct([pa.field('text', pa.string(), nullable=False)])
table, schema = _table(catalog, pa.string(), value_type, native)
_seed(table, schema, ['key', 'key', None, 'key'], [{'text': 'old'}] * 4)
read = table.new_read_builder().with_projection(['keep', '_ROW_ID'])
row_ids = {row['keep']: row['_ROW_ID'] for row in
read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()}
source = pa.table({
'key': ['key', None], 'pt': ['a', 'a'],
'value': pa.array([None, {'text': None if visible_null else '中文'}],
type=pa.struct([('text', pa.large_string())])),
})
files = set(tmp_path.rglob('*.parquet'))
builder = table.new_batch_write_builder()
with _native_only(native):
update = builder.new_update().with_update_type(['value'])
def prepare():
if operation == 'upsert':
return update.upsert_by_arrow_with_key(source, ['key'])
if operation == 'row_id':
return update.update_by_arrow_with_row_id(
source.select(['value']).append_column(
'_ROW_ID', pa.array([row_ids[0], row_ids[2]], type=pa.int64())))
predicate = update.new_predicate_builder().equal('pt', 'a')
def assignment(matched):
# The two equal keys fan out to both original rows; NULL is
# selected independently, without changing the other partition.
return pa.array([None if row['key'] is not None else source['value'][1].as_py()
for row in matched.to_pylist()], type=pa.struct([
pa.field('text', pa.large_string(), nullable=False)]))
return update.update_by_predicate(predicate, {'value': assignment}, read_columns=['key'])
if visible_null:
with pytest.raises((ValueError, pa.ArrowInvalid)):
prepare()
assert set(tmp_path.rglob('*.parquet')) == files
else:
messages = prepare()
_commit(builder, messages)
reader = table.new_read_builder()
result = reader.new_read().to_arrow(reader.new_scan().plan().splits()).sort_by('keep')
assert result['value'].to_pylist() == [
None, {'text': 'old'} if operation == 'row_id' else None,
{'text': '中文'}, {'text': 'old'}]