blob: d8586b28d139adfe299254085ed2db27df859aeb [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.
import json
import time
from dataclasses import dataclass
from typing import Dict, List, Optional
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.file_io import FileIO
from pypaimon.common.json_util import json_field
from pypaimon.schema.data_types import DataField, current_highest_field_id
from pypaimon.schema.schema import Schema
@dataclass
class TableSchema:
PAIMON_07_VERSION = 1
PAIMON_08_VERSION = 2
CURRENT_VERSION = 3
FIELD_VERSION = "version"
FIELD_ID = "id"
FIELD_FIELDS = "fields"
FIELD_HIGHEST_FIELD_ID = "highestFieldId"
FIELD_PARTITION_KEYS = "partitionKeys"
FIELD_PRIMARY_KEYS = "primaryKeys"
FIELD_OPTIONS = "options"
FIELD_COMMENT = "comment"
FIELD_TIME_MILLIS = "timeMillis"
version: int = json_field(FIELD_VERSION, default=CURRENT_VERSION)
id: int = json_field(FIELD_ID, default=0)
fields: List[DataField] = json_field(FIELD_FIELDS, default_factory=list)
highest_field_id: int = json_field("highestFieldId", default=0)
partition_keys: List[str] = json_field(
FIELD_PARTITION_KEYS, default_factory=list)
primary_keys: List[str] = json_field(
FIELD_PRIMARY_KEYS, default_factory=list)
options: Dict[str, str] = json_field(FIELD_OPTIONS, default_factory=dict)
comment: Optional[str] = json_field(FIELD_COMMENT, default=None)
time_millis: int = json_field("timeMillis", default_factory=lambda: int(time.time() * 1000))
def cross_partition_update(self) -> bool:
if not self.primary_keys or not self.partition_keys:
return False
# Check if primary keys contain all partition keys
# Return True if they don't contain all (cross-partition update)
return not all(pk in self.primary_keys for pk in self.partition_keys)
@property
def bucket_keys(self) -> List[str]:
"""Resolve the effective bucket-key column names.
Resolution rule matches Java ``TableSchema.bucketKeys()``: prefer
the explicit ``bucket-key`` option; otherwise fall back to primary
keys with partition keys stripped (the same convention writers
use).
Validation is intentionally narrower than Java's
``originalBucketKeys()``: only ``unknown column name`` is checked
here. Java additionally enforces ``bucket-key`` ⊄ partition keys,
and (when primary keys are non-empty) ``bucket-key`` ⊆ primary
keys, but it does so once at schema construction. Doing the same
in a property would add per-read overhead and could surface
errors on tables already in the catalog. The narrow check here
is just enough to fail fast on the typo case.
"""
configured = self.options.get(CoreOptions.BUCKET_KEY.key())
if configured and configured.strip():
keys = [k.strip() for k in configured.split(',') if k.strip()]
field_names = {f.name for f in self.fields}
missing = [k for k in keys if k not in field_names]
if missing:
raise ValueError(
"bucket-key references unknown columns: {}".format(missing))
return keys
return [pk for pk in self.primary_keys if pk not in self.partition_keys]
@property
def logical_bucket_key_fields(self) -> List[DataField]:
"""The ``DataField``s for ``bucket_keys``, in the order they were
declared. Mirrors Java ``TableSchema.logicalBucketKeyType()``.
"""
field_map = {f.name: f for f in self.fields}
return [field_map[name] for name in self.bucket_keys]
def to_schema(self) -> Schema:
return Schema(
fields=self.fields,
partition_keys=self.partition_keys,
primary_keys=self.primary_keys,
options=self.options,
comment=self.comment
)
@staticmethod
def from_schema(schema_id: int, schema: Schema) -> "TableSchema":
fields: List[DataField] = schema.fields
partition_keys: List[str] = schema.partition_keys
primary_keys: List[str] = schema.primary_keys
options: Dict[str, str] = schema.options
highest_field_id: int = current_highest_field_id(fields)
return TableSchema(
TableSchema.CURRENT_VERSION,
schema_id,
fields,
highest_field_id,
partition_keys,
primary_keys,
options,
schema.comment
)
@staticmethod
def from_path(file_io: FileIO, schema_path: str):
try:
json_str = file_io.read_file_utf8(schema_path)
return TableSchema.from_json(json_str)
except FileNotFoundError as e:
raise RuntimeError(f"Schema file not found: {schema_path}") from e
except Exception as e:
raise RuntimeError(f"Failed to read schema from {schema_path}") from e
@staticmethod
def from_json(json_str: str):
try:
data = json.loads(json_str)
version = data.get(TableSchema.FIELD_VERSION, TableSchema.PAIMON_07_VERSION)
fields = [DataField.from_dict(field) for field in data[TableSchema.FIELD_FIELDS]]
options = data[TableSchema.FIELD_OPTIONS]
if version <= TableSchema.PAIMON_07_VERSION and CoreOptions.BUCKET.key() not in options:
options[CoreOptions.BUCKET.key()] = "1"
if version <= TableSchema.PAIMON_08_VERSION and CoreOptions.FILE_FORMAT.key() not in options:
options[CoreOptions.FILE_FORMAT.key()] = "orc"
return TableSchema(
version=version,
id=data[TableSchema.FIELD_ID],
fields=fields,
highest_field_id=data[TableSchema.FIELD_HIGHEST_FIELD_ID],
partition_keys=data[TableSchema.FIELD_PARTITION_KEYS],
primary_keys=data[TableSchema.FIELD_PRIMARY_KEYS],
options=options or {},
comment=data.get(TableSchema.FIELD_COMMENT),
time_millis=data.get(TableSchema.FIELD_TIME_MILLIS)
)
except json.JSONDecodeError as e:
raise RuntimeError(f"Invalid JSON format: {json_str}") from e
except KeyError as e:
raise RuntimeError(f"Missing required field in schema JSON: {e}") from e
except Exception as e:
raise RuntimeError(f"Failed to parse schema from JSON: {e}") from e
def copy(self, new_options: Optional[Dict[str, str]] = None) -> "TableSchema":
return TableSchema(
version=self.version,
id=self.id,
fields=self.fields,
highest_field_id=self.highest_field_id,
partition_keys=self.partition_keys,
primary_keys=self.primary_keys,
options=new_options,
comment=self.comment,
time_millis=self.time_millis
)