blob: a519209d6a8c094a553972a67663063495209a17 [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.
"""Nullable primary keys retain Java equality across Python and Native I/O."""
from contextlib import ExitStack
from datetime import date, datetime
from decimal import Decimal
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.table.data_evolution_merge_into import WhenMatched, WhenNotMatched, source_col
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.tests import native_plan_rest_test
from pypaimon.write.write_dispatch import NativeTableWrite
rest_catalog = native_plan_rest_test.rest_catalog
pytestmark = pytest.mark.native_plan
_SCHEMA = pa.schema([('pt', pa.int32()), ('id', pa.int32()), ('value', pa.int32())])
def _table(catalog, name, bucket, engine='deduplicate', primary_key=True, extra=None):
options = {'write.native.enabled': 'false', 'source.split.target-size': '1b'}
if primary_key:
options.update({'bucket': str(bucket), 'primary-key.nullable': 'true', 'merge-engine': engine})
if engine == 'aggregation':
options['fields.value.aggregate-function'] = 'sum'
else:
options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'deletion-vectors.enabled': 'true'})
options.update(extra or {})
# Current Java also overrides an explicitly declared NOT NULL key when enabled.
schema = pa.schema([pa.field(field.name, field.type, nullable=not primary_key or field.name == 'value')
for field in _SCHEMA])
catalog.create_table('default.' + name, Schema.from_pyarrow_schema(
schema, primary_keys=['pt', 'id'] if primary_key else [],
partition_keys=['pt'] if primary_key else [], options=options), False)
table = catalog.get_table('default.' + name)
assert all(field.type.nullable for field in table.fields)
return table
def _write(table, rows, native, stream=False, identifier=1, row_input=False):
table = table.copy({'write.native.enabled': str(native).lower()})
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite) == native
try:
if row_input:
for row in rows:
writer.write_row(GenericRow(list(row), table.fields))
else:
arrays = [pa.array([row[index] for row in rows], type=field.type)
for index, field in enumerate(_SCHEMA)]
writer.write_arrow(pa.Table.from_arrays(arrays, schema=_SCHEMA))
if native:
assert writer._python_writer is None
messages = writer.prepare_commit(identifier) if stream else writer.prepare_commit()
commit.commit(messages, identifier) if stream else commit.commit(messages)
return messages
finally:
writer.close()
commit.close()
def _read(table, native, null_field=None, all_files=False):
table = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
builder = table.new_read_builder()
if null_field:
builder.with_filter(builder.new_predicate_builder().is_null(null_field))
scan, read = builder.new_scan(), builder.new_read()
with ExitStack() as stack:
if native:
if not all_files:
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Python scan')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read')
))
splits = (scan.plan_all() if all_files else scan.plan()).splits()
rows = read.to_arrow(splits).to_pylist()
# All values in these fixtures are non-negative except the seeded MERGE target.
return sorted((tuple(row[name] for name in _SCHEMA.names) for row in rows),
key=lambda row: tuple(-1000 if value is None else value for value in row))
@pytest.mark.parametrize('bucket', [1, 3, -1], ids=['one-bucket', 'fixed', 'dynamic'])
@pytest.mark.parametrize('engine', ['deduplicate', 'partial-update', 'aggregation'])
@pytest.mark.parametrize('native_first', [False, True], ids=['python-first', 'native-first'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
def test_nullable_composite_keys_across_writer_restarts(rest_catalog, bucket, engine, native_first, stream):
table = _table(rest_catalog[0], 'nullable_keys', bucket, engine)
_write(table, [(None, None, 10), (1, None, 100), (1, 0, 1)], native_first, stream, 1)
first = table.snapshot_manager().get_latest_snapshot()
# Restart with the other writer: a null key must retain its dynamic bucket.
_write(table, [(None, None, 20), (1, None, 200), (None, 0, 2)], not native_first, stream, 2)
values = (30, 300) if engine == 'aggregation' else (20, 200)
expected = [(None, None, values[0]), (None, 0, 2), (1, None, values[1]), (1, 0, 1)]
for native in [False, True]:
assert _read(table, native) == expected
assert _read(table, native, 'id') == [expected[0], expected[2]]
assert _read(table, native, 'pt') == expected[:2]
assert _read(table._copy_with_snapshot(first), native) == [(None, None, 10), (1, None, 100), (1, 0, 1)]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('bucket', [3, -1], ids=['fixed', 'dynamic'])
def test_nullable_key_row_input_matches_arrow_input(rest_catalog, native, bucket):
table = _table(rest_catalog[0], 'nullable_row', bucket)
_write(table, [(None, None, 10), (None, 0, 20)], native, row_input=True)
_write(table, [(None, None, 30), (1, None, 40)], native)
for native_read in [False, True]:
assert _read(table, native_read) == [(None, None, 30), (None, 0, 20), (1, None, 40)]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('engine', ['deduplicate', 'partial-update', 'aggregation', 'first-row'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
def test_nullable_primary_key_source_uses_sql_merge_equality(rest_catalog, native, engine, stream):
source = _table(rest_catalog[0], 'nullable_source', -1, engine)
_write(source, [(None, None, 10), (None, 1, 11), (1, None, 12), (1, 1, 100)], False)
_write(source, [(None, None, 20), (None, 1, 21), (1, None, 22), (1, 1, 200)], True)
target = _table(rest_catalog[0], 'target', -1, primary_key=False)
_write(target, [(None, None, -1), (None, 1, -3), (1, None, -4), (1, 1, -2)], False)
target = target.copy({'write.native.enabled': str(native).lower()})
builder = target.new_stream_write_builder() if stream else target.new_batch_write_builder()
with ExitStack() as stack:
if native:
stack.enter_context(patch('pypaimon.execution.python.write.merge_into._normalize_source',
side_effect=AssertionError('Python source materialization')))
stack.enter_context(patch('pypaimon.execution.python.write.merge_into._build_tables',
side_effect=AssertionError('Python merge')))
messages = builder.new_update().merge_into(
source, on=['pt', 'id'], when_matched=[WhenMatched.update({'value': source_col('value')})],
when_not_matched=[WhenNotMatched('*')], **({'commit_identifier': 37} if stream else {}))
commit = builder.new_commit()
try:
commit.commit(messages, 37) if stream else commit.commit(messages)
finally:
commit.close()
values = {'aggregation': (30, 32, 34, 300), 'first-row': (10, 11, 12, 100)}.get(engine, (20, 21, 22, 200))
for native_read in [False, True]:
# Storage merges null PKs, but SQL MERGE ON does not match NULL to NULL.
assert _read(target, native_read) == [
(None, None, -1), (None, None, values[0]), (None, 1, -3), (None, 1, values[1]),
(1, None, -4), (1, None, values[2]), (1, 1, values[3])]
assert _read(source, True, all_files=engine == 'first-row') == [
(None, None, values[0]), (None, 1, values[1]), (1, None, values[2]), (1, 1, values[3])]
assert source.snapshot_manager().get_latest_snapshot().id == 2
@pytest.mark.parametrize('bucket', [3, -1], ids=['fixed', 'dynamic'])
@pytest.mark.parametrize('key_type,zero', [
(pa.bool_(), False), (pa.int64(), 0), (pa.string(), ''), (pa.binary(), b''),
(pa.decimal128(20, 2), Decimal('0.00')), (pa.date32(), date(1970, 1, 1)),
(pa.timestamp('us'), datetime(1970, 1, 1)),
], ids=['boolean', 'bigint', 'string', 'bytes', 'decimal', 'date', 'timestamp'])
def test_nullable_scalar_key_distinguishes_null_from_default_value(rest_catalog, bucket, key_type, zero):
catalog, _ = rest_catalog
schema = pa.schema([pa.field('id', key_type, nullable=False), ('value', pa.int32())])
catalog.create_table('default.scalar_keys', Schema.from_pyarrow_schema(
schema, primary_keys=['id'], options={'bucket': str(bucket), 'primary-key.nullable': 'true'}), False)
table = catalog.get_table('default.scalar_keys')
input_schema = pa.schema([('id', key_type), ('value', pa.int32())])
# Three separate writers exercise native index serialization, Python index
# restore and native restore after a Python row write.
for native, values in [(True, [(None, 10), (zero, 100)]),
(False, [(None, 20), (zero, 200)]), (True, [(None, 30)])]:
builder = table.copy({'write.native.enabled': str(native).lower()}).new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite) == native
try:
if native:
writer.write_arrow(pa.Table.from_arrays([
pa.array([row[index] for row in values], type=field.type)
for index, field in enumerate(input_schema)], schema=input_schema))
assert writer._python_writer is None
else:
for row in values:
writer.write_row(GenericRow(list(row), table.fields))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
for native in [False, True]:
builder = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()}).new_read_builder()
scan, read = builder.new_scan(), builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.scanner.file_scanner.FileScanner.scan',
side_effect=AssertionError('Python scan')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read')
))
rows = read.to_arrow(scan.plan().splits()).to_pylist()
assert sorted(rows, key=lambda row: row['id'] is not None) == [
{'id': None, 'value': 30}, {'id': zero, 'value': 200}]
@pytest.mark.parametrize('first_native', [False, True], ids=['python-first', 'native-first'])
def test_null_key_restores_nonzero_dynamic_bucket(rest_catalog, first_native):
table = _table(rest_catalog[0], 'nonzero_null_key', -1,
extra={'dynamic-bucket.target-row-num': '1'})
initial = _write(table, [(None, 0, 100), (None, None, 10)], first_native)
assert {message.bucket for message in initial} == {0, 1}
for native, value in [(not first_native, 20), (first_native, 30)]:
messages = _write(table, [(None, None, value)], native)
assert {message.bucket for message in messages} == {1}
for native in [False, True]:
assert _read(table, native) == [(None, None, 30), (None, 0, 100)]
def test_null_partition_requires_complete_dynamic_bucket_index(rest_catalog):
from pypaimon.execution.python.write.row_key_extractor import DynamicBucketRowKeyExtractor
table = _table(rest_catalog[0], 'missing_hash', -1)
builder = table.copy({'write.native.enabled': 'false'}).new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
# Simulate existing data whose HASH metadata is incomplete. Restoring
# this partition must fail instead of assigning its keys new buckets.
writer._execution.row_key_extractor = DynamicBucketRowKeyExtractor(table.table_schema)
writer.write_arrow(pa.Table.from_arrays([
pa.array([None], type=pa.int32()), pa.array([None], type=pa.int32()),
pa.array([10], type=pa.int32())], schema=_SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
writer = table.new_batch_write_builder().new_write()
try:
with pytest.raises(RuntimeError, match='has data files but no complete HASH index'):
writer.write_arrow(pa.Table.from_arrays([
pa.array([None], type=pa.int32()), pa.array([None], type=pa.int32()),
pa.array([20], type=pa.int32())], schema=_SCHEMA))
finally:
writer.close()