| # 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. |
| |
| from typing import Dict, Iterable, Optional |
| |
| import pyarrow as pa |
| |
| from pypaimon import CatalogFactory, Schema as PaimonSchema |
| from pypaimon.catalog.catalog import Catalog |
| from pypaimon.catalog.catalog_exception import ( |
| DatabaseNotExistException, |
| TableAlreadyExistException, |
| TableNotExistException, |
| ) |
| from pypaimon.multimodal.table import MultimodalTable, _to_arrow_table |
| |
| _DEFAULT_OPTIONS = { |
| "row-tracking.enabled": "true", |
| "data-evolution.enabled": "true", |
| "deletion-vectors.enabled": "true", |
| "blob-as-descriptor": "true", |
| "global-index.search-mode": "full", |
| "vector.file.format": "vortex", |
| } |
| |
| _DEFAULT_DATABASE = Catalog.DEFAULT_DATABASE |
| |
| |
| def connect( |
| *, |
| database: str = _DEFAULT_DATABASE, |
| options: Optional[Dict[str, str]] = None): |
| """Connect to a Paimon catalog through the multimodal facade.""" |
| return MultimodalConnection( |
| _resolve_catalog(options), |
| database=database, |
| ) |
| |
| |
| class MultimodalConnection: |
| """High-level entry point backed by a Paimon catalog and database.""" |
| |
| def __init__(self, catalog, database: str = _DEFAULT_DATABASE): |
| if not database: |
| raise ValueError("database is required.") |
| self.catalog = catalog |
| self.database = database |
| |
| def create_table( |
| self, |
| name: str, |
| data=None, |
| schema=None, |
| options: Optional[Dict[str, str]] = None, |
| partitioned: Optional[Iterable[str]] = None, |
| ignore_if_exists: bool = False): |
| """Create a multimodal table and optionally add initial data.""" |
| identifier = self._identifier(name) |
| already_exists = _table_exists(self.catalog, identifier) |
| paimon_schema = _to_paimon_schema(schema, data, options, partitioned) |
| |
| self._create_database_for(identifier) |
| try: |
| self.catalog.create_table( |
| identifier, paimon_schema, ignore_if_exists) |
| except TableAlreadyExistException: |
| if not ignore_if_exists: |
| raise |
| |
| table = self.get_table(name) |
| if data is not None and not already_exists: |
| table.add(data) |
| return table |
| |
| def get_table(self, name: str): |
| identifier = self._identifier(name) |
| raw_table = self.catalog.get_table(identifier) |
| _validate_multimodal_table(raw_table, identifier) |
| return MultimodalTable( |
| self.catalog, |
| identifier, |
| raw_table, |
| ) |
| |
| def drop_table(self, name: str, ignore_if_not_exists: bool = False): |
| self.catalog.drop_table( |
| self._identifier(name), |
| ignore_if_not_exists=ignore_if_not_exists, |
| ) |
| |
| def _identifier(self, name: str) -> str: |
| return _resolve_identifier(name, self.database) |
| |
| def _create_database_for(self, identifier: str): |
| database_name = _database_name(identifier) |
| if database_name is not None: |
| self.catalog.create_database(database_name, ignore_if_exists=True) |
| |
| |
| def _resolve_catalog(options): |
| resolved_options = {} |
| if options: |
| resolved_options.update(options) |
| if not resolved_options: |
| raise ValueError("options is required.") |
| return CatalogFactory.create(resolved_options) |
| |
| |
| def _table_exists(catalog, identifier: str) -> bool: |
| try: |
| catalog.get_table(identifier) |
| return True |
| except (DatabaseNotExistException, TableNotExistException): |
| return False |
| |
| |
| def _validate_multimodal_table(table, identifier: str): |
| table_schema = table.table_schema |
| options = table_schema.options |
| if str(options.get("data-evolution.enabled", "false")).lower() != "true": |
| raise ValueError( |
| "Table %s is not a data-evolution table; " |
| "data-evolution.enabled must be true." % identifier) |
| if table_schema.primary_keys: |
| raise ValueError( |
| "Table %s has primary keys %s; multimodal tables must not have " |
| "primary keys." % (identifier, table_schema.primary_keys)) |
| |
| |
| def _resolve_identifier(name: str, database: str) -> str: |
| if not name: |
| raise ValueError("table name is required.") |
| if "." in name: |
| return name |
| return "%s.%s" % (database, name) |
| |
| |
| def _to_paimon_schema(schema, data, options, partitioned): |
| if schema is None: |
| if data is None: |
| raise ValueError("schema or data is required.") |
| pa_schema, inferred_options = _infer_pyarrow_schema(data) |
| else: |
| pa_schema = _to_pyarrow_schema(schema) |
| inferred_options = {} |
| return PaimonSchema.from_pyarrow_schema( |
| pa_schema, |
| partition_keys=_normalize_partitioned(partitioned), |
| options=_merge_options(inferred_options, options), |
| ) |
| |
| |
| def _merge_options(*option_groups): |
| table_options = dict(_DEFAULT_OPTIONS) |
| for options in option_groups: |
| if options: |
| table_options.update({str(k): str(v) for k, v in options.items()}) |
| return table_options |
| |
| |
| def _normalize_partitioned(partitioned): |
| if partitioned is None: |
| return [] |
| if isinstance(partitioned, str): |
| return [partitioned] |
| return list(partitioned) |
| |
| |
| def _to_pyarrow_schema(schema): |
| if isinstance(schema, pa.Schema): |
| return schema |
| raise ValueError("schema must be a pyarrow.Schema.") |
| |
| |
| def _infer_pyarrow_schema(data): |
| table = _to_arrow_table(data) |
| if table.num_columns == 0: |
| raise ValueError("Cannot infer schema from empty data.") |
| return table.schema, {} |
| |
| |
| def _database_name(identifier: str): |
| parts = identifier.split(".") |
| if len(parts) == 2 and parts[0]: |
| return parts[0] |
| return None |