| ################################################################################ |
| # 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 sqlite3 |
| from contextlib import closing, contextmanager |
| from typing import Dict, List, Optional, Tuple, Union |
| from urllib.parse import parse_qs, urlparse |
| |
| from pypaimon.catalog.catalog import Catalog |
| from pypaimon.catalog.catalog_context import CatalogContext |
| from pypaimon.catalog.catalog_environment import CatalogEnvironment |
| from pypaimon.catalog.catalog_exception import ( |
| DatabaseAlreadyExistException, |
| DatabaseNotExistException, |
| TableAlreadyExistException, |
| TableNotExistException |
| ) |
| from pypaimon.catalog.database import Database |
| from pypaimon.common.file_io import FileIO |
| from pypaimon.common.identifier import Identifier |
| from pypaimon.common.options.config import CatalogOptions, JdbcCatalogOptions |
| from pypaimon.common.options.core_options import CoreOptions |
| from pypaimon.schema.schema import Schema |
| from pypaimon.schema.schema_change import SchemaChange |
| from pypaimon.schema.schema_manager import SchemaManager |
| from pypaimon.snapshot.snapshot import Snapshot |
| from pypaimon.snapshot.snapshot_commit import PartitionStatistics |
| from pypaimon.table.file_store_table import FileStoreTable |
| from pypaimon.table.table import Table |
| |
| |
| def _convert_qmark_placeholders(sql: str, placeholder: str) -> str: |
| if placeholder == "?": |
| return sql |
| |
| result = [] |
| in_single_quote = False |
| in_double_quote = False |
| index = 0 |
| while index < len(sql): |
| char = sql[index] |
| if char == "'" and not in_double_quote: |
| result.append(char) |
| if in_single_quote and index + 1 < len(sql) and sql[index + 1] == "'": |
| index += 1 |
| result.append(sql[index]) |
| else: |
| in_single_quote = not in_single_quote |
| elif char == '"' and not in_single_quote: |
| result.append(char) |
| if in_double_quote and index + 1 < len(sql) and sql[index + 1] == '"': |
| index += 1 |
| result.append(sql[index]) |
| else: |
| in_double_quote = not in_double_quote |
| elif char == "?" and not in_single_quote and not in_double_quote: |
| result.append(placeholder) |
| else: |
| result.append(char) |
| index += 1 |
| return "".join(result) |
| |
| |
| class _DbApiConnection: |
| def __init__(self, options: Dict[str, str]): |
| self.options = options |
| self.uri = options.get(CatalogOptions.URI.key()) |
| if not self.uri: |
| raise ValueError(f"Paimon '{CatalogOptions.URI.key()}' must be set for JDBC catalog") |
| self.protocol, self.placeholder, self.connection = self._connect(self.uri, options) |
| |
| def close(self): |
| self.connection.close() |
| |
| def execute(self, sql: str, args: Tuple = ()): |
| with closing(self.connection.cursor()) as cursor: |
| cursor.execute(self._sql(sql), args) |
| return cursor.rowcount |
| |
| def executemany(self, sql: str, args): |
| with closing(self.connection.cursor()) as cursor: |
| cursor.executemany(self._sql(sql), args) |
| return cursor.rowcount |
| |
| def fetch_all(self, sql: str, args: Tuple = ()): |
| with closing(self.connection.cursor()) as cursor: |
| cursor.execute(self._sql(sql), args) |
| return cursor.fetchall() |
| |
| def fetch_one(self, sql: str, args: Tuple = ()): |
| with closing(self.connection.cursor()) as cursor: |
| cursor.execute(self._sql(sql), args) |
| return cursor.fetchone() |
| |
| def _sql(self, sql: str) -> str: |
| return _convert_qmark_placeholders(sql, self.placeholder) |
| |
| @contextmanager |
| def transaction(self): |
| try: |
| yield |
| self.connection.commit() |
| except Exception: |
| self.connection.rollback() |
| raise |
| |
| @staticmethod |
| def _jdbc_properties(options: Dict[str, str]) -> Dict[str, str]: |
| result = {} |
| for key, value in options.items(): |
| if key.startswith("jdbc."): |
| result[key[len("jdbc."):]] = value |
| return result |
| |
| def _connect(self, uri: str, options: Dict[str, str]): |
| if uri.startswith("jdbc:sqlite:"): |
| return self._connect_sqlite(uri) |
| if uri.startswith("jdbc:mysql:"): |
| return self._connect_mysql(uri, options) |
| if uri.startswith("jdbc:postgresql:"): |
| return self._connect_postgresql(uri, options) |
| raise ValueError(f"Unsupported JDBC catalog URI for Python DB-API connection: {uri}") |
| |
| def _connect_sqlite(self, uri: str): |
| sqlite_uri = uri[len("jdbc:sqlite:"):] |
| if sqlite_uri.startswith("file:"): |
| connection = sqlite3.connect(sqlite_uri, uri=True, check_same_thread=False) |
| else: |
| connection = sqlite3.connect(sqlite_uri, check_same_thread=False) |
| return "sqlite", "?", connection |
| |
| def _connect_mysql(self, uri: str, options: Dict[str, str]): |
| try: |
| import pymysql |
| connector = "pymysql" |
| except ImportError: |
| try: |
| import mysql.connector as mysql_connector |
| connector = "mysql-connector" |
| except ImportError as e: |
| raise ImportError( |
| "PyPaimon JDBC catalog uses Python DB-API drivers and requires " |
| "pymysql or mysql-connector-python to connect to MySQL." |
| ) from e |
| |
| parsed = urlparse(uri[len("jdbc:"):]) |
| props = self._jdbc_properties(options) |
| query = {k: v[0] for k, v in parse_qs(parsed.query).items()} |
| props.update(query) |
| user = props.pop("user", props.pop("username", None)) |
| password = props.pop("password", None) |
| host = props.pop("host", parsed.hostname) |
| database = parsed.path.lstrip("/") or props.pop("database", "") |
| props.pop("database", None) |
| port_value = props.pop("port", None) |
| port = parsed.port or int(port_value or 3306) |
| if connector == "pymysql": |
| connection = pymysql.connect( |
| host=host, |
| port=port, |
| user=user, |
| password=password, |
| database=database, |
| autocommit=False, |
| **props |
| ) |
| else: |
| connection = mysql_connector.connect( |
| host=host, |
| port=port, |
| user=user, |
| password=password, |
| database=database, |
| **props |
| ) |
| return "mysql", "%s", connection |
| |
| def _connect_postgresql(self, uri: str, options: Dict[str, str]): |
| try: |
| import psycopg2 |
| connector = "psycopg2" |
| except ImportError: |
| try: |
| import psycopg |
| connector = "psycopg" |
| except ImportError as e: |
| raise ImportError( |
| "PyPaimon JDBC catalog uses Python DB-API drivers and requires " |
| "psycopg2 or psycopg to connect to PostgreSQL." |
| ) from e |
| |
| parsed = urlparse(uri[len("jdbc:"):]) |
| props = self._jdbc_properties(options) |
| query = {k: v[0] for k, v in parse_qs(parsed.query).items()} |
| props.update(query) |
| user = props.pop("user", props.pop("username", None)) |
| password = props.pop("password", None) |
| host = props.pop("host", parsed.hostname) |
| database = parsed.path.lstrip("/") or props.get("database") or props.get("dbname") or "" |
| props.pop("database", None) |
| props.pop("dbname", None) |
| port_value = props.pop("port", None) |
| port = parsed.port or int(port_value or 5432) |
| connect_kwargs = { |
| "host": host, |
| "port": port, |
| "user": user, |
| "password": password, |
| "dbname": database, |
| } |
| connect_kwargs.update(props) |
| if connector == "psycopg2": |
| connection = psycopg2.connect(**connect_kwargs) |
| else: |
| connection = psycopg.connect(**connect_kwargs) |
| return "postgresql", "%s", connection |
| |
| |
| class JdbcCatalog(Catalog): |
| CATALOG_TABLE_NAME = "paimon_tables" |
| DATABASE_PROPERTIES_TABLE_NAME = "paimon_database_properties" |
| TABLE_PROPERTIES_TABLE_NAME = "paimon_table_properties" |
| CATALOG_KEY = "catalog_key" |
| TABLE_DATABASE = "database_name" |
| TABLE_NAME = "table_name" |
| PROPERTY_KEY = "property_key" |
| PROPERTY_VALUE = "property_value" |
| DATABASE_EXISTS_PROPERTY = "exists" |
| |
| def __init__(self, context: CatalogContext): |
| catalog_options = context.options |
| if not catalog_options.contains(CatalogOptions.WAREHOUSE): |
| raise ValueError(f"Paimon '{CatalogOptions.WAREHOUSE.key()}' path must be set") |
| self.context = context |
| self.catalog_options = catalog_options |
| self.options = catalog_options.to_map() |
| self.warehouse = catalog_options.get(CatalogOptions.WAREHOUSE) |
| self.catalog_key = catalog_options.get(JdbcCatalogOptions.CATALOG_KEY) |
| self.file_io = FileIO.get(self.warehouse, self.catalog_options) |
| self.connection = _DbApiConnection(self.options) |
| self._initialize_catalog_tables() |
| |
| def close(self): |
| self.connection.close() |
| |
| def __enter__(self): |
| return self |
| |
| def __exit__(self, exc_type, exc_value, traceback): |
| self.close() |
| return False |
| |
| def _initialize_catalog_tables(self): |
| with self.connection.transaction(): |
| self.connection.execute( |
| "CREATE TABLE IF NOT EXISTS paimon_tables (" |
| "catalog_key VARCHAR(255) NOT NULL, " |
| "database_name VARCHAR(255) NOT NULL, " |
| "table_name VARCHAR(255) NOT NULL, " |
| "PRIMARY KEY (catalog_key, database_name, table_name))" |
| ) |
| self.connection.execute( |
| "CREATE TABLE IF NOT EXISTS paimon_database_properties (" |
| "catalog_key VARCHAR(255) NOT NULL, " |
| "database_name VARCHAR(255) NOT NULL, " |
| "property_key VARCHAR(255), " |
| "property_value VARCHAR(1000), " |
| "PRIMARY KEY (catalog_key, database_name, property_key))" |
| ) |
| self.connection.execute( |
| "CREATE TABLE IF NOT EXISTS paimon_table_properties (" |
| "catalog_key VARCHAR(255) NOT NULL, " |
| "database_name VARCHAR(255) NOT NULL, " |
| "table_name VARCHAR(255) NOT NULL, " |
| "property_key VARCHAR(255) NOT NULL, " |
| "property_value VARCHAR(1000), " |
| "PRIMARY KEY (catalog_key, database_name, table_name, property_key))" |
| ) |
| |
| def list_databases(self) -> List[str]: |
| table_rows = self.connection.fetch_all( |
| "SELECT DISTINCT database_name FROM paimon_tables WHERE catalog_key = ?", |
| (self.catalog_key,) |
| ) |
| property_rows = self.connection.fetch_all( |
| "SELECT DISTINCT database_name FROM paimon_database_properties WHERE catalog_key = ?", |
| (self.catalog_key,) |
| ) |
| databases = {row[0] for row in table_rows} |
| databases.update(row[0] for row in property_rows) |
| return sorted(databases) |
| |
| def get_database(self, name: str) -> Database: |
| if not self._database_exists(name): |
| raise DatabaseNotExistException(name) |
| properties = self._fetch_database_properties(name) |
| if Catalog.DB_LOCATION_PROP not in properties: |
| properties[Catalog.DB_LOCATION_PROP] = self.get_database_path(name) |
| properties.pop(self.DATABASE_EXISTS_PROPERTY, None) |
| return Database(name, properties) |
| |
| def create_database(self, name: str, ignore_if_exists: bool, properties: Optional[dict] = None): |
| if self._database_exists(name): |
| if not ignore_if_exists: |
| raise DatabaseAlreadyExistException(name) |
| return |
| create_props = {self.DATABASE_EXISTS_PROPERTY: "true"} |
| if properties: |
| create_props.update(properties) |
| if Catalog.DB_LOCATION_PROP not in create_props: |
| create_props[Catalog.DB_LOCATION_PROP] = self.get_database_path(name) |
| with self.connection.transaction(): |
| self._insert_database_properties(name, create_props) |
| |
| def drop_database(self, name: str, ignore_if_not_exists: bool = False, cascade: bool = False): |
| if not self._database_exists(name): |
| if not ignore_if_not_exists: |
| raise DatabaseNotExistException(name) |
| return |
| tables = self.list_tables(name) |
| if tables and not cascade: |
| raise ValueError(f"Database {name} is not empty. Use cascade=True to drop all tables first.") |
| if cascade: |
| for table in tables: |
| self.drop_table(Identifier.create(name, table), True) |
| with self.connection.transaction(): |
| self.connection.execute( |
| "DELETE FROM paimon_tables WHERE catalog_key = ? AND database_name = ?", |
| (self.catalog_key, name) |
| ) |
| self.connection.execute( |
| "DELETE FROM paimon_database_properties WHERE catalog_key = ? AND database_name = ?", |
| (self.catalog_key, name) |
| ) |
| self.connection.execute( |
| "DELETE FROM paimon_table_properties WHERE catalog_key = ? AND database_name = ?", |
| (self.catalog_key, name) |
| ) |
| |
| def alter_database(self, name: str, changes: list): |
| self.get_database(name) |
| from pypaimon.catalog.rest.property_change import PropertyChange |
| set_properties, remove_keys = PropertyChange.get_set_properties_to_remove_keys(changes) |
| current = self._fetch_database_properties(name) |
| with self.connection.transaction(): |
| update_args = [ |
| (value, self.catalog_key, name, key) |
| for key, value in set_properties.items() |
| if key in current |
| ] |
| insert_properties = { |
| key: value for key, value in set_properties.items() if key not in current |
| } |
| if update_args: |
| self.connection.executemany( |
| "UPDATE paimon_database_properties SET property_value = ? " |
| "WHERE catalog_key = ? AND database_name = ? AND property_key = ?", |
| update_args |
| ) |
| if insert_properties: |
| self._insert_database_properties(name, insert_properties) |
| for key in remove_keys: |
| self.connection.execute( |
| "DELETE FROM paimon_database_properties " |
| "WHERE catalog_key = ? AND database_name = ? AND property_key = ?", |
| (self.catalog_key, name, key) |
| ) |
| |
| def list_tables(self, database_name: str) -> List[str]: |
| self.get_database(database_name) |
| rows = self.connection.fetch_all( |
| "SELECT table_name FROM paimon_tables WHERE catalog_key = ? AND database_name = ?", |
| (self.catalog_key, database_name) |
| ) |
| return sorted(row[0] for row in rows) |
| |
| def get_table(self, identifier: Union[str, Identifier]) -> Table: |
| if not isinstance(identifier, Identifier): |
| identifier = Identifier.from_string(identifier) |
| if self.catalog_options.contains(CoreOptions.SCAN_FALLBACK_BRANCH): |
| raise ValueError(f"Unsupported CoreOption {CoreOptions.SCAN_FALLBACK_BRANCH}") |
| if not self._table_exists(identifier): |
| raise TableNotExistException(identifier) |
| table_path = self.get_table_path(identifier) |
| table_schema = self.get_table_schema(identifier) |
| from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader |
| catalog_environment = CatalogEnvironment( |
| identifier=identifier, |
| uuid=None, |
| catalog_loader=JdbcCatalogLoader(self.context), |
| supports_version_management=False |
| ) |
| return FileStoreTable(self.file_io, identifier, table_path, table_schema, catalog_environment) |
| |
| def create_table(self, identifier: Union[str, Identifier], schema: 'Schema', ignore_if_exists: bool): |
| if schema.options and schema.options.get(CoreOptions.AUTO_CREATE.key()): |
| raise ValueError(f"The value of {CoreOptions.AUTO_CREATE.key()} property should be False.") |
| if not isinstance(identifier, Identifier): |
| identifier = Identifier.from_string(identifier) |
| self.get_database(identifier.get_database_name()) |
| if self._table_exists(identifier): |
| if not ignore_if_exists: |
| raise TableAlreadyExistException(identifier) |
| return |
| if schema.options and CoreOptions.TYPE.key() in schema.options and schema.options.get( |
| CoreOptions.TYPE.key()) != "table": |
| raise ValueError(f"Table Type: {schema.options.get(CoreOptions.TYPE.key())}") |
| |
| table_path = self.get_table_path(identifier) |
| schema_manager = SchemaManager(self.file_io, table_path) |
| table_schema = schema_manager.create_table(schema) |
| try: |
| with self.connection.transaction(): |
| self.connection.execute( |
| "INSERT INTO paimon_tables (catalog_key, database_name, table_name) VALUES (?, ?, ?)", |
| (self.catalog_key, identifier.get_database_name(), identifier.get_table_name()) |
| ) |
| if self._sync_all_properties(): |
| self._insert_table_properties(identifier, self._collect_table_properties(table_schema)) |
| except Exception: |
| self.file_io.delete_directory_quietly(table_path) |
| raise |
| |
| def drop_table(self, identifier: Union[str, Identifier], ignore_if_not_exists: bool = False): |
| if not isinstance(identifier, Identifier): |
| identifier = Identifier.from_string(identifier) |
| if not self._table_exists(identifier): |
| if not ignore_if_not_exists: |
| raise TableNotExistException(identifier) |
| return |
| table_path = self.get_table_path(identifier) |
| with self.connection.transaction(): |
| self.connection.execute( |
| "DELETE FROM paimon_tables WHERE catalog_key = ? AND database_name = ? AND table_name = ?", |
| (self.catalog_key, identifier.get_database_name(), identifier.get_table_name()) |
| ) |
| self.connection.execute( |
| "DELETE FROM paimon_table_properties WHERE catalog_key = ? AND database_name = ? AND table_name = ?", |
| (self.catalog_key, identifier.get_database_name(), identifier.get_table_name()) |
| ) |
| self.file_io.delete_directory_quietly(table_path) |
| |
| def rename_table(self, source_identifier: Union[str, Identifier], target_identifier: Union[str, Identifier]): |
| if not isinstance(source_identifier, Identifier): |
| source_identifier = Identifier.from_string(source_identifier) |
| if not isinstance(target_identifier, Identifier): |
| target_identifier = Identifier.from_string(target_identifier) |
| if not self._table_exists(source_identifier): |
| raise TableNotExistException(source_identifier) |
| self.get_database(target_identifier.get_database_name()) |
| if self._table_exists(target_identifier): |
| raise TableAlreadyExistException(target_identifier) |
| |
| source_path = self.get_table_path(source_identifier) |
| target_path = self.get_table_path(target_identifier) |
| renamed_path = False |
| if self.file_io.exists(source_path): |
| self.file_io.rename(source_path, target_path) |
| renamed_path = True |
| try: |
| with self.connection.transaction(): |
| self.connection.execute( |
| "UPDATE paimon_tables SET database_name = ?, table_name = ? " |
| "WHERE catalog_key = ? AND database_name = ? AND table_name = ?", |
| ( |
| target_identifier.get_database_name(), |
| target_identifier.get_table_name(), |
| self.catalog_key, |
| source_identifier.get_database_name(), |
| source_identifier.get_table_name() |
| ) |
| ) |
| self.connection.execute( |
| "UPDATE paimon_table_properties SET database_name = ?, table_name = ? " |
| "WHERE catalog_key = ? AND database_name = ? AND table_name = ?", |
| ( |
| target_identifier.get_database_name(), |
| target_identifier.get_table_name(), |
| self.catalog_key, |
| source_identifier.get_database_name(), |
| source_identifier.get_table_name() |
| ) |
| ) |
| except Exception: |
| if renamed_path and self.file_io.exists(target_path): |
| self.file_io.rename(target_path, source_path) |
| raise |
| |
| def alter_table( |
| self, |
| identifier: Union[str, Identifier], |
| changes: List[SchemaChange], |
| ignore_if_not_exists: bool = False |
| ): |
| if not isinstance(identifier, Identifier): |
| identifier = Identifier.from_string(identifier) |
| if not self._table_exists(identifier): |
| if not ignore_if_not_exists: |
| raise TableNotExistException(identifier) |
| return |
| schema_manager = SchemaManager(self.file_io, self.get_table_path(identifier)) |
| table_schema = schema_manager.commit_changes(changes) |
| if self._sync_all_properties(): |
| with self.connection.transaction(): |
| self.connection.execute( |
| "DELETE FROM paimon_table_properties " |
| "WHERE catalog_key = ? AND database_name = ? AND table_name = ?", |
| (self.catalog_key, identifier.get_database_name(), identifier.get_table_name()) |
| ) |
| self._insert_table_properties(identifier, self._collect_table_properties(table_schema)) |
| |
| def get_table_schema(self, identifier: Identifier): |
| table_schema = SchemaManager(self.file_io, self.get_table_path(identifier)).latest() |
| if table_schema is None: |
| raise TableNotExistException(identifier) |
| return table_schema |
| |
| def get_database_path(self, name: str) -> str: |
| warehouse = self.warehouse.rstrip('/') |
| return f"{warehouse}/{name}{Catalog.DB_SUFFIX}" |
| |
| def get_table_path(self, identifier: Identifier) -> str: |
| db_path = self.get_database_path(identifier.get_database_name()) |
| return f"{db_path}/{identifier.get_table_name()}" |
| |
| def load_snapshot(self, identifier: Identifier): |
| raise NotImplementedError("JDBC catalog does not support load_snapshot") |
| |
| def commit_snapshot( |
| self, |
| identifier: Identifier, |
| table_uuid: Optional[str], |
| snapshot: Snapshot, |
| statistics: List[PartitionStatistics] |
| ) -> bool: |
| raise NotImplementedError("This catalog does not support commit catalog") |
| |
| def _database_exists(self, database_name: str) -> bool: |
| row = self.connection.fetch_one( |
| "SELECT database_name FROM paimon_tables " |
| "WHERE catalog_key = ? AND database_name = ? LIMIT 1", |
| (self.catalog_key, database_name) |
| ) |
| if row is not None: |
| return True |
| row = self.connection.fetch_one( |
| "SELECT database_name FROM paimon_database_properties " |
| "WHERE catalog_key = ? AND database_name = ? LIMIT 1", |
| (self.catalog_key, database_name) |
| ) |
| return row is not None |
| |
| def _table_exists(self, identifier: Identifier) -> bool: |
| row = self.connection.fetch_one( |
| "SELECT table_name FROM paimon_tables " |
| "WHERE catalog_key = ? AND database_name = ? AND table_name = ? LIMIT 1", |
| (self.catalog_key, identifier.get_database_name(), identifier.get_table_name()) |
| ) |
| return row is not None |
| |
| def _fetch_database_properties(self, database_name: str) -> Dict[str, str]: |
| rows = self.connection.fetch_all( |
| "SELECT property_key, property_value FROM paimon_database_properties " |
| "WHERE catalog_key = ? AND database_name = ?", |
| (self.catalog_key, database_name) |
| ) |
| return {row[0]: row[1] for row in rows} |
| |
| def _insert_database_properties(self, database_name: str, properties: Dict[str, str]): |
| if properties: |
| self.connection.executemany( |
| "INSERT INTO paimon_database_properties " |
| "(catalog_key, database_name, property_key, property_value) VALUES (?, ?, ?, ?)", |
| [(self.catalog_key, database_name, key, value) for key, value in properties.items()] |
| ) |
| |
| def _insert_table_properties(self, identifier: Identifier, properties: Dict[str, str]): |
| if properties: |
| self.connection.executemany( |
| "INSERT INTO paimon_table_properties " |
| "(catalog_key, database_name, table_name, property_key, property_value) " |
| "VALUES (?, ?, ?, ?, ?)", |
| [ |
| ( |
| self.catalog_key, |
| identifier.get_database_name(), |
| identifier.get_table_name(), |
| key, |
| value |
| ) |
| for key, value in properties.items() |
| ] |
| ) |
| |
| def _sync_all_properties(self) -> bool: |
| from pypaimon.common.options.options_utils import OptionsUtils |
| return OptionsUtils.convert_to_boolean( |
| self.catalog_options.get(CatalogOptions.SYNC_ALL_PROPERTIES)) |
| |
| @staticmethod |
| def _collect_table_properties(table_schema) -> Dict[str, str]: |
| properties = dict(table_schema.options or {}) |
| if table_schema.primary_keys: |
| properties["primary-key"] = ",".join(table_schema.primary_keys) |
| if table_schema.partition_keys: |
| properties["partition"] = ",".join(table_schema.partition_keys) |
| return properties |