blob: ed29b4b78b0ac4f83b7aa7548f17f4897e5b4153 [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.
"""Native upserts preserve typed keys, partial inputs and partition matching."""
from contextlib import ExitStack
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
from pypaimon.table.row.generic_row import GenericRow
pytestmark = pytest.mark.native_plan
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize('row_input', [False, True])
@pytest.mark.parametrize('key_type', [pa.int32(), pa.float32(), pa.float64()])
def test_partial_upsert_keys_and_values(tmp_path, native, streaming, row_input, key_type):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('key', key_type), ('keep', pa.string()),
('pt', pa.string()), ('value', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, partition_keys=['pt'], options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': str(native).lower(),
}), False)
table = catalog.get_table('default.t')
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
existing_key = 1 if pa.types.is_integer(key_type) else 1.25
new_key = 2 if pa.types.is_integer(key_type) else 2.5
seed = pa.table({'key': [existing_key] * 3 + [None], 'keep': ['a', 'b', 'c', 'd'],
'pt': ['a', 'a', 'b', 'a'], 'value': [10, 11, 20, 30]}, schema=schema)
batch_builder = table.new_batch_write_builder()
writer = batch_builder.new_write()
try:
writer.write_arrow(seed)
batch_builder.new_commit().commit(writer.prepare_commit())
finally:
writer.close()
# Reordered subset, duplicate source key, NULL key, and a new key. The
# same key in another partition must retain its value.
partial_schema = pa.schema([schema.field(name) for name in ['value', 'pt', 'key']])
source = pa.table({'value': [12, 40, 31, 13], 'pt': ['a'] * 4,
'key': [existing_key, new_key, None, existing_key]}, schema=partial_schema)
builder = table.new_stream_write_builder() if streaming else batch_builder
update = builder.new_update().with_update_type(['value'])
with ExitStack() as stack:
if native:
for method in ('_upsert_partition', '_upsert_row_partition'):
stack.enter_context(patch.object(PythonTableUpsert, method,
side_effect=AssertionError('Python upsert fallback')))
if row_input:
fields = [field for field in table.fields if field.name in source.column_names]
rows = [GenericRow([row[field.name] for field in fields], fields)
for row in source.to_pylist()]
messages = update.upsert_by_key(rows, ['key'], 7) if streaming else update.upsert_by_key(rows, ['key'])
else:
# Two chunks exercise core batch concatenation after normalization.
source = pa.concat_tables([source.slice(0, 2), source.slice(2)])
messages = (update.upsert_by_arrow_with_key(source, ['key'], 7) if streaming
else update.upsert_by_arrow_with_key(source, ['key']))
for message in messages:
for file in message.new_files:
assert file.write_cols is not None and 'keep' not in file.write_cols
commit = builder.new_commit()
try:
commit.commit(messages, 7) if streaming else commit.commit(messages)
finally:
commit.close()
expected = [
{'key': existing_key, 'keep': 'a', 'pt': 'a', 'value': 13},
{'key': existing_key, 'keep': 'b', 'pt': 'a', 'value': 13},
{'key': existing_key, 'keep': 'c', 'pt': 'b', 'value': 20},
{'key': None, 'keep': 'd', 'pt': 'a', 'value': 31},
{'key': new_key, 'keep': None, 'pt': 'a', 'value': 40},
]
for native_read in (False, True):
copy = table.copy({'read.native.enabled': str(native_read).lower(),
'scan.native-plan.enabled': str(native_read).lower()})
reader = copy.new_read_builder()
actual = reader.new_read().to_arrow(reader.new_scan().plan().splits())
assert sorted(actual.to_pylist(), key=lambda row: row['keep'] or 'z') == expected
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('streaming', [False, True])
@pytest.mark.parametrize('row_input', [False, True])
@pytest.mark.parametrize('key_type', [
pa.time32('ms'), pa.timestamp('ms'), pa.timestamp('us'), pa.timestamp('ns'),
pa.timestamp('us', 'UTC'), pa.timestamp('ns', 'UTC'),
])
def test_temporal_upsert_keys(tmp_path, native, streaming, row_input, key_type):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('key', key_type), ('tag', pa.int32()), ('value', pa.string())])
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')
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
key_type = schema.field('key').type
# Adjacent storage-unit values must stay distinct, including nanoseconds.
keys = pa.array([10, 10, None, 11], type=key_type)
seed = pa.table({'key': keys, 'tag': [0, 1, 2, 3], 'value': ['old'] * 4}, schema=schema)
batch_builder = table.new_batch_write_builder()
writer, commit = batch_builder.new_write(), batch_builder.new_commit()
try:
writer.write_arrow(seed)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
source = pa.table({'key': pa.array([None, 10, 12, 10], type=key_type),
'tag': [99, 99, 4, 99],
'value': ['null update', 'discarded', 'new', 'final']}, schema=schema)
builder = table.new_stream_write_builder() if streaming else table.new_batch_write_builder()
update = builder.new_update().with_update_type(['value'])
with ExitStack() as stack:
if native:
for method in ('_upsert_partition', '_upsert_row_partition'):
stack.enter_context(patch.object(PythonTableUpsert, method,
side_effect=AssertionError('Python upsert fallback')))
if row_input:
rows = [GenericRow([row[field.name] for field in table.fields], table.fields)
for row in source.to_pylist()]
messages = (update.upsert_by_key(rows, ['key'], 7) if streaming
else update.upsert_by_key(rows, ['key']))
else:
source = source.select(['value', 'key', 'tag'])
messages = (update.upsert_by_arrow_with_key(source, ['key'], 7) if streaming
else update.upsert_by_arrow_with_key(source, ['key']))
commit = builder.new_commit()
try:
commit.commit(messages, 7) if streaming else commit.commit(messages)
finally:
commit.close()
for native_read in (False, True):
copy = table.copy({'read.native.enabled': str(native_read).lower(),
'scan.native-plan.enabled': str(native_read).lower()})
reader = copy.new_read_builder()
result = reader.new_read().to_arrow(reader.new_scan().plan().splits()).sort_by('tag')
assert result['value'].to_pylist() == ['final', 'final', 'null update', 'old', 'new']
assert result['tag'].to_pylist() == [0, 1, 2, 3, 4]
assert result['key'].cast(pa.int32() if pa.types.is_time32(key_type)
else pa.int64()).to_pylist() == [10, 10, None, 11, 12]
@pytest.mark.parametrize('native', [False, True])
def test_upsert_reads_only_exact_input_partitions(tmp_path, native):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('sub', pa.int32()),
('value', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, partition_keys=['pt', 'sub'], options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': str(native).lower(),
}), False)
table = catalog.get_table('default.t')
data = pa.table({'id': [1] * 5, 'pt': ['a', 'a', 'b', None, None],
'sub': [1, 2, 1, 1, 2], 'value': [10] * 5}, schema=schema)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(data)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
def relevant(path):
return any(part in str(path) for part in (
'/pt=a/sub=1/', '/pt=b/sub=2/', '/pt=__DEFAULT_PARTITION__/sub=1/'))
removed = 0
for path in tmp_path.rglob('*.parquet'):
if not relevant(path):
path.unlink()
removed += 1
assert removed == 3
source = pa.table({'id': [1] * 4, 'pt': ['a', 'b', None, 'a'],
'sub': [1, 2, 1, 1], 'value': [15, 30, 40, 20]}, schema=schema)
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(PythonTableUpsert, '_upsert_partition',
side_effect=AssertionError('Python upsert fallback')))
messages = builder.new_update().with_update_type(['value']).upsert_by_arrow_with_key(
source, ['id'])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
# Read only the partitions intentionally left accessible. The missing
# unrelated files prove that matching did not scan their data.
read = table.new_read_builder()
splits = [split for split in read.new_scan().plan().splits()
if relevant(table.path_factory().bucket_path(tuple(split.partition.values), split.bucket))]
assert sorted(read.new_read().to_arrow(splits)['value'].to_pylist()) == [20, 30, 40]