blob: beab8bba26369de943396cc44ab14508d15a44f9 [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 partition metadata uses the same manifest view and Java casts as Python."""
from datetime import date, datetime, time
from decimal import Decimal
import json
import os
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.tests import native_plan_rest_test
from pypaimon.tests.native_system_table_test import _canonical, _read, _system
rest_catalog = native_plan_rest_test.rest_catalog
pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='Native system-table reader required')]
def _create(catalog, schema, partition_keys, options=None):
settings = {'write.native.enabled': 'false', 'commit.native.enabled': 'false'}
settings.update(options or {})
catalog.create_table('default.part_metadata', Schema.from_pyarrow_schema(
schema, partition_keys=partition_keys, options=settings), False)
return catalog.get_table('default.part_metadata')
def _append(table, rows, schema):
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()
def _assert_parity(table):
expected = _read(_system(table, 'partitions', False))
native = _system(table, 'partitions', True)
with patch.object(type(native), '_build_arrow_table',
side_effect=AssertionError('Python partition aggregation ran')):
actual = _read(native)
assert actual.schema == expected.schema
assert _canonical(actual, 'partitions') == _canonical(expected, 'partitions')
return actual.to_pylist()
@pytest.mark.parametrize('typ,value,expected', [
(pa.bool_(), True, 'true'),
(pa.decimal128(10, 2), Decimal('1.20'), '1.20'),
(pa.date32(), date(2024, 2, 29), '2024-02-29'),
(pa.time32('ms'), time(12, 34, 56, 123000), '12:34:56'),
(pa.timestamp('us'), datetime(2024, 2, 29, 12, 34, 56, 123456), '2024-02-29 12:34:56.123456'),
(pa.string(), 'a/b=c', 'a/b=c'),
(pa.float32(), 1.401298464324817e-45, '1.4E-45'),
(pa.float64(), 5e-324, '4.9E-324'),
(pa.binary(), b'hello', 'hello'),
(pa.binary(), b'\xed\xa0\x80', '\ufffd'),
(pa.string(), '\u00a0', '\u00a0'),
(pa.string(), '\u0085', '\u0085'),
(pa.string(), '\u001c', 'MISSING'),
])
def test_partition_values_are_java_casts(rest_catalog, typ, value, expected):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', typ)])
table = _create(catalog, schema, ['p'], {
'partition.default-name': 'MISSING', 'partition.legacy-name': 'false'})
_append(table, [{'id': 1, 'p': value}, {'id': 2, 'p': None}], schema)
rows = _assert_parity(table)
assert sorted(row['partition'] for row in rows) == sorted(['p=' + expected, 'p=MISSING'])
assert [row['total_buckets'] for row in rows] == [-1, -1]
assert [row['deleted_record_count'] for row in rows] == [0, 0]
def test_unpartitioned_metadata_uses_empty_string(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32())])
table = _create(catalog, schema, [])
_append(table, [{'id': 1}, {'id': 2}], schema)
rows = _assert_parity(table)
assert len(rows) == 1
assert rows[0]['partition'] == ''
assert rows[0]['record_count'] == 2
def test_declared_bucket_count_does_not_count_only_occupied_buckets(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
table = _create(catalog, schema, ['p'], {'bucket': '8', 'bucket-key': 'id'})
_append(table, [{'id': 1, 'p': 'one'}], schema)
rows = _assert_parity(table)
assert rows[0]['file_count'] == 1
assert rows[0]['total_buckets'] == 8
def test_rendered_partition_collisions_are_not_aggregated(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.string()), ('p2', pa.string())])
table = _create(catalog, schema, ['p', 'p2'])
_append(table, [{'id': 1, 'p': 'a/p2=b', 'p2': 'c'},
{'id': 2, 'p': 'a', 'p2': 'b/p2=c'}], schema)
rows = _assert_parity(table)
assert [row['partition'] for row in rows] == ['p=a/p2=b/p2=c'] * 2
assert [row['record_count'] for row in rows] == [1, 1]
def test_deletion_vector_counts_read_avro_metadata_and_keep_unknown(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
table = _create(catalog, schema, ['p'], {'deletion-vectors.enabled': 'true'})
_append(table, [{'id': 1, 'p': 'known'}, {'id': 2, 'p': 'unknown'},
{'id': 3, 'p': 'zero'}], schema)
entries = []
for partition, bucket, counts in [
('known', 0, [1, 2]), ('known', 1, [3]),
('unknown', 0, [None]), ('unknown', 1, [5])]:
ranges = {str(i): DeletionVectorMeta(str(i), 0, 1, count) for i, count in enumerate(counts)}
entries.append(IndexManifestEntry(
0, GenericRow([partition], table.partition_keys_fields), bucket,
IndexFileMeta('DELETION_VECTORS', partition + str(bucket), 1, 1, dv_ranges=ranges)))
# Only manifest metadata is needed; no deletion-vector payload is opened.
table = _with_index_entries(table, entries)
rows = _assert_parity(table)
assert {row['partition']: row['deleted_record_count'] for row in rows} == {
'p=known': 6, 'p=unknown': None, 'p=zero': 0}
def _with_index_entries(table, entries):
name = IndexManifestFile(table).write(entries)
snapshot = table.snapshot_manager().get_latest_snapshot()
path = table.snapshot_manager().snapshot_dir + '/snapshot-' + str(snapshot.id)
with table.file_io.new_input_stream(path) as stream:
data = json.loads(stream.read())
data['indexManifest'] = name
table.file_io.overwrite_file_utf8(path, json.dumps(data))
return table._copy_with_snapshot(table.snapshot_manager().get_latest_snapshot())
@pytest.mark.parametrize('typ,value,canonical,legacy', [
(pa.string(), 'MISSING', None, True),
(pa.string(), 'MISSING', None, False),
(pa.string(), '\x1c', None, True),
(pa.string(), '\x1c', None, False),
# Java byte[].toString has no stable legacy spec; exercise its SQL cast.
(pa.binary(), b'\xed\xa0\x80', '\ufffd'.encode('utf-8'), False),
(pa.time32('ms'), time(12, 34, 56, 123000), time(12, 34, 56), True),
(pa.time32('ms'), time(12, 34, 56, 123000), time(12, 34, 56), False),
])
def test_deletion_lookup_uses_java_partition_spec_round_trip(rest_catalog, typ, value, canonical, legacy):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', typ)])
table = _create(catalog, schema, ['p'], {
'deletion-vectors.enabled': 'true', 'partition.default-name': 'MISSING',
'partition.legacy-name': str(legacy).lower()})
_append(table, [{'id': i, 'p': value} for i in range(3)]
+ [{'id': i + 3, 'p': canonical} for i in range(3)], schema)
entries = []
for partition, count in [(value, 1), (canonical, 3)]:
ranges = {'file': DeletionVectorMeta('file', 0, 1, count)}
entries.append(IndexManifestEntry(
0, GenericRow([partition], table.partition_keys_fields), 0,
IndexFileMeta('DELETION_VECTORS', str(count), 1, 1, dv_ranges=ranges)))
table = _with_index_entries(table, entries)
rows = _assert_parity(table)
assert len(rows) == 2
# A legacy TIME spec is an integer millisecond count, so Java keeps its
# sub-second digits even when the declared TIME precision is zero.
expected = [1, 3] if legacy and pa.types.is_time(typ) else [3, 3]
assert sorted(row['deleted_record_count'] for row in rows) == expected
def test_default_legacy_time_partition_keeps_deletion_counts(rest_catalog):
from pypaimon.tests.deletion_vector_path_test import _delete
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.time32('ms'))])
table = _create(catalog, schema, ['p'], {
'deletion-vectors.enabled': 'true', 'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'})
_append(table, [{'id': i, 'p': time(12, 34, 56, 120000)} for i in range(2)], schema)
_delete(table, [0])
rows = _assert_parity(table)
assert [(row['partition'], row['deleted_record_count']) for row in rows] == [('p=12:34:56', 1)]
@pytest.mark.parametrize('legacy,instant,expected,deleted', [
(True, '2024-01-01T00:00:00+00:00', '2024-01-01 00:00:00.000', 0),
(False, '2024-01-01T00:00:00+00:00', '2023-12-31 19:00:00.000', 1),
(True, '2024-11-03T06:30:00+00:00', '2024-11-03 06:30:00.000', 0),
(False, '2024-11-03T06:30:00+00:00', '2024-11-03 01:30:00.000', 0),
])
def test_temporal_ltz_partition_specs_match_java_in_new_york(rest_catalog, legacy, instant, expected, deleted):
from pypaimon.tests.deletion_vector_path_test import _delete
# Native caches the process system zone. CI runs this golden case in a
# separate process initialized with New York, as for temporal cast tests.
if os.environ.get('TZ') != 'America/New_York':
pytest.skip('Java golden values require TZ=America/New_York')
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.timestamp('ms', tz='UTC'))])
table = _create(catalog, schema, ['p'], {
'deletion-vectors.enabled': 'true', 'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true', 'partition.legacy-name': str(legacy).lower()})
_append(table, [{'id': i, 'p': datetime.fromisoformat(instant)} for i in range(2)], schema)
_delete(table, [0])
rows = _assert_parity(table)
assert [(row['partition'], row['deleted_record_count']) for row in rows] == [('p=' + expected, deleted)]
@pytest.mark.parametrize('native', [False, True])
def test_partition_split_is_captured_before_later_commits(rest_catalog, native):
catalog, _ = rest_catalog
schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
table = _create(catalog, schema, ['p'])
_append(table, [{'id': 1, 'p': 'old'}], schema)
system = _system(table, 'partitions', native)
builder = system.new_read_builder()
splits = builder.new_scan().plan().splits()
_append(table, [{'id': 2, 'p': 'new'}], schema)
rows = builder.new_read().to_arrow(splits).to_pylist()
assert [row['partition'] for row in rows] == ['p=old']
assert len(_assert_parity(table)) == 2