blob: 15a47c09dc7436100bc62c9043935e049b1f6d0b [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.
"""Public overwrite/truncate specs use Java's declared partition types."""
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
from urllib.parse import unquote, urlparse
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.schema.data_types import AtomicType
from pypaimon.tests import native_plan_rest_test
from pypaimon.execution.python.write.partition_spec import _partition_spec_value
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
rest_catalog = native_plan_rest_test.rest_catalog
native = pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed')
_ENGINES = [False, pytest.param(True, marks=native)]
_CASES = [
('integer', pa.int32(), [1, 2], '1'),
('typed-integer', pa.int32(), [1, 2], 1),
('unicode-integer', pa.int32(), [12, 13], '12'),
('boolean', pa.bool_(), [True, False], 'TRUE'),
('string', pa.string(), ['a/b = 中', 'other'], 'a/b = 中'),
('decimal', pa.decimal128(6, 2), [Decimal('12.35'), Decimal('13.00')], '12.345'),
('unicode-decimal', pa.decimal128(6, 2), [Decimal('12.35'), Decimal('13.00')], '12.345'),
('decimal-overflow', pa.decimal128(3, 0), [None, Decimal('12')], '1000'),
('date', pa.date32(), [date(2024, 1, 2), date(2024, 1, 3)], '2024-01-02'),
('date-partial', pa.date32(), [date(2024, 1, 1), date(2024, 1, 3)], '2024-1'),
('date-epoch', pa.date32(), [date(1970, 1, 2), date(1970, 1, 3)], '1'),
('time', pa.time32('ms'), [time(12, 34, 56, 789000), time(12, 35)], '12:34:56.789'),
('time-rounding', pa.time32('ms'), [time(12, 34, 56, 790000), time(12, 35)], '12:34:56.7895'),
('time-fraction', pa.time32('ms'), [time(12, 34, 56, 12000), time(12, 35)], '12:34:56.\u00a0123'),
('time-fraction-utf16', pa.time32('ms'), [time(12, 34, 56, 1000), time(12, 34, 56, 12000)],
'12:34:56.😀123'),
('time-offset', pa.time32('ms'), [time(13, 34, 56), time(13, 35)], '12:34:56+01:00'),
('time-epoch', pa.time32('ms'), [time(0, 0, 0, 1000), time(0, 0, 0, 2000)], '1'),
('timestamp', pa.timestamp('us'), [datetime(2024, 1, 2, 12, 34), datetime(2024, 1, 3)],
'2024-01-02 12:34:00'),
('timestamp-iso', pa.timestamp('us'), [datetime(2024, 1, 2, 12, 34), datetime(2024, 1, 3)],
'2024-01-02T12:34'),
('timestamp-repeated', pa.timestamp('us'), [datetime(2024, 1, 2, 12, 34), datetime(2024, 1, 3)],
'2024-0101-0202 1212:3434:00T12:34'),
('timestamp-defaults', pa.timestamp('us'), [datetime(2024, 1, 1), datetime(2024, 1, 3)], '2024--'),
('timestamp-nanos', pa.timestamp('ns'), [datetime(2024, 1, 2, 12, 34, 0, 123456), datetime(2024, 1, 3)],
'2024-01-02 12:34:00.123456000'),
('timestamp-overflow', pa.timestamp('s'), [datetime(1969, 12, 31, 23, 59, 59), datetime(1970, 1, 1)],
'9223372036854775807'),
('timestamp-ltz', pa.timestamp('us', tz='UTC'),
[datetime(2024, 1, 2, 12, 34, tzinfo=timezone.utc), datetime(2024, 1, 3, tzinfo=timezone.utc)],
'2024-01-02 12:34:00'),
('float', pa.float32(), [1.2, 2.0], '1.2'),
('float-hex', pa.float32(), [4.0, 2.0], '0x1.p2'),
('float-hex-overflow-positive', pa.float32(), [float('inf'), 2.0], '0x1p1024'),
('float-hex-overflow-negative', pa.float32(), [float('-inf'), 2.0], '-0x1p1024'),
('double-hex-overflow-positive', pa.float64(), [float('inf'), 2.0], '0x1p1024'),
('double-hex-overflow-negative', pa.float64(), [float('-inf'), 2.0], '-0x1p1024'),
('float-trim', pa.float32(), [1.2, 2.0], '\x001.2\x1f'),
('null', pa.int32(), [None, 2], None),
('default-name', pa.int32(), [None, 2], '__DEFAULT_PARTITION__'),
]
def _table(catalog, data_type, values, use_native):
schema = pa.schema([('id', pa.int64()), ('pt', data_type)])
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, partition_keys=['pt'], options={
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'dynamic-partition-overwrite': 'false',
}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': [1, 2], 'pt': values}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table.copy({'commit.native.enabled': str(use_native).lower()}), schema
def _ids(table):
builder = table.new_read_builder()
return sorted(row['id'] for row in builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist())
def _files(table):
return set(Path(unquote(urlparse(table.table_path).path)).rglob('*'))
@pytest.mark.parametrize('use_native', _ENGINES)
@pytest.mark.parametrize('operation', ['truncate', 'overwrite'])
@pytest.mark.parametrize('name,data_type,values,text', _CASES, ids=[case[0] for case in _CASES])
def test_partition_specs_parse_declared_types(rest_catalog, use_native, operation, name, data_type, values, text):
catalog, _ = rest_catalog
if name == 'timestamp-ltz':
# Java treats this text as process-local wall time, not as UTC.
text = values[0].astimezone().replace(tzinfo=None).strftime('%Y-%m-%d %H:%M:%S')
table, schema = _table(catalog, data_type, values, use_native)
builder = table.new_batch_write_builder()
writer = None
if operation == 'overwrite':
builder.overwrite({'pt': text})
writer = builder.new_write()
writer.write_arrow(pa.table({'id': [3], 'pt': values[:1]}, schema=schema))
commit = builder.new_commit()
try:
if use_native:
method = 'drop_partitions' if operation == 'truncate' else 'overwrite'
guard = patch.object(commit.file_store_commit, method, side_effect=AssertionError('Python fallback'))
else:
guard = patch.object(commit, '_prepare_native_commit', return_value=None)
with guard:
if operation == 'truncate':
commit.truncate_partitions([{'pt': text}])
else:
commit.commit(writer.prepare_commit())
assert _ids(table) == ([2] if operation == 'truncate' else [2, 3])
assert table.snapshot_manager().get_latest_snapshot().id == 2
finally:
if writer is not None:
writer.close()
commit.close()
@pytest.mark.parametrize('use_native', _ENGINES)
@pytest.mark.parametrize('operation', ['truncate', 'overwrite'])
@pytest.mark.parametrize('spec', [{'missing': '1'}, {'pt': 'bad'}, {'pt': '1.5'}, {'pt': '2147483648'},
{'pt': ' 1'}, {'pt': '1_0'}, {'pt': '𝟙'}])
def test_invalid_specs_do_not_publish_or_delete(rest_catalog, use_native, operation, spec):
catalog, server = rest_catalog
table, _ = _table(catalog, pa.int32(), [1, 2], use_native)
builder = table.new_batch_write_builder()
if operation == 'overwrite':
builder.overwrite(spec)
commit = builder.new_commit()
before = _files(table)
try:
with patch.object(server, '_table_commit_handle', wraps=server._table_commit_handle) as publish:
with pytest.raises((ValueError, RuntimeError)):
if operation == 'truncate':
commit.truncate_partitions([spec])
else:
commit.commit([])
publish.assert_not_called()
assert table.snapshot_manager().get_latest_snapshot().id == 1
assert _ids(table) == [1, 2]
assert _files(table) == before
finally:
commit.close()
@pytest.mark.parametrize('use_native', _ENGINES)
@pytest.mark.parametrize('data_type,values,text', [
(pa.bool_(), [True, False], 'on'),
(pa.float32(), [1.0, 2.0], '1.23456789'),
(pa.decimal128(6, 2), [Decimal('1'), Decimal('2')], 'not a decimal'),
(pa.decimal128(6, 2), [Decimal('1'), Decimal('2')], '𝟙.3'),
(pa.date32(), [date(2024, 1, 1), date(2024, 1, 2)], '2024-02-30'),
(pa.date32(), [date(2024, 1, 1), date(2024, 1, 2)], '2024-\u00a01-01'),
(pa.time32('ms'), [time(12, 34), time(12, 35)], '12:34+02:30'),
(pa.time32('ms'), [time(12, 34), time(12, 35)], '\u00a012:34:00'),
(pa.timestamp('us'), [datetime(2024, 1, 1), datetime(2024, 1, 2)], '2024-01-01 12:34'),
(pa.timestamp('us'), [datetime(2024, 1, 1), datetime(2024, 1, 2)], '2024-00-01'),
(pa.timestamp('us'), [datetime(2024, 1, 1), datetime(2024, 1, 2)], '2024-01-00'),
])
def test_invalid_typed_text_does_not_publish(rest_catalog, use_native, data_type, values, text):
catalog, server = rest_catalog
table, _ = _table(catalog, data_type, values, use_native)
commit = table.new_batch_write_builder().new_commit()
before = _files(table)
try:
with patch.object(server, '_table_commit_handle', wraps=server._table_commit_handle) as publish:
with pytest.raises((ValueError, RuntimeError)):
commit.truncate_partitions([{'pt': text}])
publish.assert_not_called()
assert table.snapshot_manager().get_latest_snapshot().id == 1
assert _ids(table) == [1, 2]
assert _files(table) == before
finally:
commit.close()
@pytest.mark.parametrize('use_native', _ENGINES)
def test_overwrite_partition_mismatch_preserves_prepared_files(rest_catalog, use_native):
catalog, server = rest_catalog
table, schema = _table(catalog, pa.int32(), [1, 2], use_native)
builder = table.new_batch_write_builder().overwrite({'pt': '2'})
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': [3], 'pt': [1]}, schema=schema))
messages = writer.prepare_commit()
before = _files(table)
with patch.object(server, '_table_commit_handle', wraps=server._table_commit_handle) as publish:
with pytest.raises((ValueError, RuntimeError)):
commit.commit(messages)
publish.assert_not_called()
assert table.snapshot_manager().get_latest_snapshot().id == 1
assert _ids(table) == [1, 2]
assert _files(table) == before
finally:
writer.close()
commit.close()
def test_timestamp_spec_precision_is_independent_of_arrow_storage():
assert _partition_spec_value('1970-01-01 00:00:00.123456', AtomicType('TIMESTAMP(1)')) == (
datetime(1970, 1, 1, 0, 0, 0, 100000))
value = _partition_spec_value('1970-01-01 00:00:00.123456789', AtomicType('TIMESTAMP(9)'))
assert pa.scalar(value, type=pa.timestamp('ns')).value == 123456789
@pytest.mark.parametrize('sign', [-1, 1])
def test_numeric_timestamp_overflow_retains_millisecond_remainder(sign):
value = _partition_spec_value(str(sign * 18446744073709552), AtomicType('TIMESTAMP(0)'))
assert value == datetime(1970, 1, 1) + timedelta(milliseconds=sign * 384)
@pytest.mark.parametrize('use_native', _ENGINES)
@pytest.mark.parametrize('sign', [-1, 1])
def test_overflow_spec_does_not_truncate_another_partition(rest_catalog, use_native, sign):
catalog, _ = rest_catalog
table, _ = _table(catalog, pa.timestamp('s'),
[datetime(1970, 1, 1), datetime(1969, 12, 31, 23, 59, 59)], use_native)
commit = table.new_batch_write_builder().new_commit()
try:
commit.truncate_partitions([{'pt': str(sign * 18446744073709552)}])
assert _ids(table) == [1, 2]
assert table.snapshot_manager().get_latest_snapshot().id == 2
finally:
commit.close()
@pytest.mark.parametrize('data_type,text', [
(AtomicType('VARCHAR(2)', False), 'abc'),
(AtomicType('VARBINARY(2)', False), '中'),
(AtomicType('TINYINT'), '128'),
(AtomicType('SMALLINT'), '-32769'),
(AtomicType('BIGINT'), '9223372036854775808'),
])
def test_partition_spec_type_limits_are_validated(data_type, text):
with pytest.raises(ValueError):
_partition_spec_value(text, data_type)