blob: e670563b1789805b281da1c7f095aaa94db72c27 [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.
"""Helpers shared across catalog implementations."""
import re
from typing import Optional
from pypaimon.api.api_response import PagedList, Partition
from pypaimon.catalog.catalog import Catalog
from pypaimon.common.identifier import Identifier
from pypaimon.table.table import Table
def encoded_branch_name(identifier: Identifier) -> Optional[str]:
"""The branch in the encoded name of ``identifier``, as in ``t$branch_dev``, or None.
``Identifier(db, t, branch='main')``, in any case, is encoded as and equal to
``db.t``, so it has none. ``t$branch_MAIN`` names a branch ``MAIN``.
"""
without_branch = Identifier(
identifier.get_database_name(), identifier.get_table_name(),
system_table=identifier.get_system_table_name())
return None if identifier == without_branch else identifier.get_branch_name()
def check_not_branch(identifier: Identifier, method: str) -> None:
"""Mirrors Java ``CatalogUtils.checkNotBranch``.
Unlike Java, ``Identifier(db, t, branch='main')`` is accepted, see
``encoded_branch_name``.
"""
if encoded_branch_name(identifier) is not None:
raise ValueError(
"Cannot '{}' for branch table '{}', please modify the table with the default branch."
.format(method, identifier.get_full_name()))
def check_not_system_database(name: str) -> None:
"""Mirrors Java ``CatalogUtils.checkNotSystemDatabase``."""
if name == Catalog.SYSTEM_DATABASE_NAME:
raise ValueError("Can't do operation on system database.")
def check_not_system_table(identifier: Identifier, method: str) -> None:
"""Mirrors Java ``CatalogUtils.checkNotSystemTable``."""
if identifier.get_database_name() == Catalog.SYSTEM_DATABASE_NAME or identifier.is_system_table():
raise ValueError(
"Cannot '{}' for system table '{}', please use data table."
.format(method, identifier.get_full_name()))
def list_partitions_from_file_system(
table: Table,
max_results: Optional[int] = None,
page_token: Optional[str] = None,
partition_name_pattern: Optional[str] = None,
) -> PagedList[Partition]:
"""Compute partitions from a table's own metadata (manifest entries).
Mirrors Java ``CatalogUtils.listPartitionsFromFileSystem``: the aggregation
is shared by the filesystem catalog and by the REST catalog, which falls
back to it when the server does not implement the partition-listing
endpoint.
"""
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.table.row.generic_row import GenericRowSerializer
snapshot = table.snapshot_manager().get_latest_snapshot()
if snapshot is None:
return PagedList(elements=[])
# Read all manifest entries (ADD - DELETE merged) and group by partition.
manifest_list_manager = ManifestListManager(table)
manifest_file_manager = ManifestFileManager(table)
manifest_files = manifest_list_manager.read_all(snapshot)
entries = manifest_file_manager.read_entries_parallel(manifest_files, drop_stats=True)
partition_map = {} # serialized partition row -> aggregated stats
path_factory = table.path_factory()
for entry in entries:
partition_key = GenericRowSerializer.to_bytes(entry.partition)
if partition_key not in partition_map:
partition_map[partition_key] = {
'spec': dict(zip(
table.partition_keys,
path_factory._canonical_partition(tuple(entry.partition.values)))),
'record_count': 0,
'file_size_in_bytes': 0,
'file_count': 0,
'last_file_creation_time': 0,
'buckets': set(),
}
stats = partition_map[partition_key]
stats['record_count'] += entry.file.row_count
stats['file_size_in_bytes'] += entry.file.file_size
stats['file_count'] += 1
if entry.file.creation_time is not None:
ct = entry.file.creation_time.get_millisecond()
if ct > stats['last_file_creation_time']:
stats['last_file_creation_time'] = ct
stats['buckets'].add(entry.bucket)
# Break display-name ties with the raw partition key for stable pagination.
sorted_partitions = sorted(partition_map.items(), key=lambda item: (
','.join(f'{k}={v}' for k, v in sorted(item[1]['spec'].items())), item[0]))
partitions = [
Partition(
spec=stats['spec'],
record_count=stats['record_count'],
file_size_in_bytes=stats['file_size_in_bytes'],
file_count=stats['file_count'],
last_file_creation_time=stats['last_file_creation_time'],
total_buckets=len(stats['buckets']),
)
for _, stats in sorted_partitions
]
# Apply pattern filter with proper regex escaping.
if partition_name_pattern:
# Escape special regex chars except '*', then replace '*' with '.*'.
escaped_pattern = re.escape(partition_name_pattern).replace(r'\*', '.*')
regex = re.compile(escaped_pattern)
partitions = [
p for p in partitions
if regex.fullmatch(','.join(f'{k}={v}' for k, v in p.spec.items()))
]
# Apply pagination.
start_index = 0
if page_token is not None:
try:
start_index = int(page_token)
except ValueError:
# Invalid token, start from beginning.
start_index = 0
end_index = len(partitions)
if max_results is not None and max_results > 0:
end_index = min(start_index + max_results, len(partitions))
result_partitions = partitions[start_index:end_index]
next_page_token = None
if max_results is not None and end_index < len(partitions):
next_page_token = str(end_index)
return PagedList(elements=result_partitions, next_page_token=next_page_token)