| # 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 logging |
| import re |
| import threading |
| import time |
| import uuid |
| from http.server import BaseHTTPRequestHandler, HTTPServer |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING |
| from urllib.parse import urlparse |
| |
| if TYPE_CHECKING: |
| from pypaimon.catalog.rest.rest_token import RESTToken |
| |
| from pypaimon.api.api_request import (AlterDatabaseRequest, AlterTableRequest, |
| CreateBranchRequest, |
| CreateDatabaseRequest, |
| CreateTableRequest, CreateTagRequest, |
| RenameBranchRequest, |
| RenameTableRequest) |
| from pypaimon.api.api_response import (ConfigResponse, GetDatabaseResponse, |
| GetFunctionResponse, |
| GetTableResponse, GetTagResponse, |
| ListBranchesResponse, |
| ListDatabasesResponse, |
| ListFunctionDetailsResponse, |
| ListFunctionsGloballyResponse, |
| ListFunctionsResponse, |
| ListPartitionsResponse, ListTablesResponse, |
| ListTagsResponse, |
| PagedList, Partition, |
| RESTResponse, ErrorResponse) |
| from pypaimon.api.resource_paths import ResourcePaths |
| from pypaimon.api.rest_util import RESTUtil |
| from pypaimon.catalog.catalog_exception import (BranchAlreadyExistException, |
| BranchNotExistException, |
| DatabaseNoPermissionException, |
| DatabaseNotExistException, |
| TableNoPermissionException, |
| TableNotExistException, DatabaseAlreadyExistException, |
| TableAlreadyExistException, |
| FunctionNotExistException, |
| FunctionAlreadyExistException, |
| DefinitionAlreadyExistException, |
| DefinitionNotExistException, |
| TagNotExistException, |
| TagAlreadyExistException) |
| from pypaimon.catalog.rest.table_metadata import TableMetadata |
| from pypaimon.common.identifier import Identifier |
| from pypaimon.api.typedef import RESTAuthParameter |
| from pypaimon.common.json_util import JSON |
| from pypaimon import Schema |
| from pypaimon.schema.schema_change import Actions, SchemaChange |
| from pypaimon.schema.schema_manager import SchemaManager |
| from pypaimon.schema.table_schema import TableSchema |
| |
| # Constants |
| DEFAULT_MAX_RESULTS = 100 |
| AUTHORIZATION_HEADER_KEY = "Authorization" |
| |
| # REST API parameter constants |
| DATABASE_NAME_PATTERN = "databaseNamePattern" |
| TABLE_NAME_PATTERN = "tableNamePattern" |
| TABLE_TYPE = "tableType" |
| VIEW_NAME_PATTERN = "viewNamePattern" |
| FUNCTION_NAME_PATTERN = "functionNamePattern" |
| PARTITION_NAME_PATTERN = "partitionNamePattern" |
| TAG_NAME_PREFIX = "tagNamePrefix" |
| MAX_RESULTS = "maxResults" |
| PAGE_TOKEN = "pageToken" |
| |
| # Core options |
| PATH = "path" |
| TYPE = "type" |
| WAREHOUSE = "warehouse" |
| SNAPSHOT_CLEAN_EMPTY_DIRECTORIES = "snapshot.clean-empty-directories" |
| |
| # Table types |
| FORMAT_TABLE = "FORMAT_TABLE" |
| OBJECT_TABLE = "OBJECT_TABLE" |
| |
| |
| def _dict_to_schema_change(change_dict: dict) -> SchemaChange: |
| from pypaimon.schema.schema_change import ( |
| SetOption, RemoveOption, UpdateComment, AddColumn, RenameColumn, |
| DropColumn, UpdateColumnType, UpdateColumnNullability, |
| UpdateColumnComment, UpdateColumnDefaultValue, UpdateColumnPosition, Move, MoveType |
| ) |
| |
| action = change_dict.get(Actions.FIELD_ACTION) |
| if action == Actions.SET_OPTION_ACTION: |
| return SetOption(key=change_dict["key"], value=change_dict["value"]) |
| elif action == Actions.REMOVE_OPTION_ACTION: |
| return RemoveOption(key=change_dict["key"]) |
| elif action == Actions.UPDATE_COMMENT_ACTION: |
| return UpdateComment(comment=change_dict.get("comment")) |
| elif action == Actions.ADD_COLUMN_ACTION: |
| from pypaimon.schema.data_types import DataTypeParser |
| data_type_value = change_dict.get("dataType") or change_dict.get(AddColumn.FIELD_DATA_TYPE) |
| if data_type_value is None: |
| raise ValueError(f"Missing dataType field in AddColumn change: {change_dict}") |
| data_type = DataTypeParser.parse_data_type(data_type_value) |
| move = None |
| if "move" in change_dict and change_dict["move"] is not None: |
| move_dict = change_dict["move"] |
| if isinstance(move_dict, dict): |
| move_type_str = move_dict.get("type") or move_dict.get(Move.FIELD_TYPE) |
| if move_type_str is None: |
| raise ValueError(f"Missing type field in Move: {move_dict}") |
| move_type = MoveType(move_type_str) |
| field_name = move_dict.get("fieldName") or move_dict.get(Move.FIELD_FIELD_NAME) |
| if field_name is None: |
| raise ValueError(f"Missing fieldName field in Move: {move_dict}") |
| reference_field = ( |
| move_dict.get("referenceFieldName") or |
| move_dict.get(Move.FIELD_REFERENCE_FIELD_NAME) |
| ) |
| move = Move( |
| field_name=field_name, |
| reference_field_name=reference_field, |
| type=move_type |
| ) |
| field_names = change_dict.get("fieldNames") or change_dict.get(AddColumn.FIELD_FIELD_NAMES) |
| if field_names is None: |
| raise ValueError(f"Missing fieldNames field in AddColumn change: {change_dict}") |
| return AddColumn( |
| field_names=field_names, |
| data_type=data_type, |
| comment=change_dict.get("comment") or change_dict.get(AddColumn.FIELD_COMMENT), |
| move=move |
| ) |
| elif action == Actions.RENAME_COLUMN_ACTION: |
| return RenameColumn(field_names=change_dict["fieldNames"], new_name=change_dict["newName"]) |
| elif action == Actions.DROP_COLUMN_ACTION: |
| return DropColumn(field_names=change_dict["fieldNames"]) |
| elif action == Actions.UPDATE_COLUMN_TYPE_ACTION: |
| from pypaimon.schema.data_types import DataTypeParser |
| new_type = DataTypeParser.parse_data_type(change_dict["newDataType"]) |
| return UpdateColumnType( |
| field_names=change_dict["fieldNames"], |
| new_data_type=new_type, |
| keep_nullability=change_dict.get("keepNullability", False) |
| ) |
| elif action == Actions.UPDATE_COLUMN_NULLABILITY_ACTION: |
| return UpdateColumnNullability( |
| field_names=change_dict["fieldNames"], |
| new_nullability=change_dict["newNullability"] |
| ) |
| elif action == Actions.UPDATE_COLUMN_COMMENT_ACTION: |
| return UpdateColumnComment( |
| field_names=change_dict["fieldNames"], |
| new_comment=change_dict.get("newComment") |
| ) |
| elif action == Actions.UPDATE_COLUMN_DEFAULT_VALUE_ACTION: |
| return UpdateColumnDefaultValue( |
| field_names=change_dict["fieldNames"], |
| new_default_value=change_dict["newDefaultValue"] |
| ) |
| elif action == Actions.UPDATE_COLUMN_POSITION_ACTION: |
| move_dict = change_dict.get("move") or change_dict.get(UpdateColumnPosition.FIELD_MOVE) |
| if move_dict is None: |
| raise ValueError(f"Missing move field in UpdateColumnPosition change: {change_dict}") |
| if not isinstance(move_dict, dict): |
| raise ValueError(f"move field must be a dict in UpdateColumnPosition change: {change_dict}") |
| move_type_str = move_dict.get("type") or move_dict.get(Move.FIELD_TYPE) |
| if move_type_str is None: |
| raise ValueError(f"Missing type field in Move: {move_dict}") |
| move_type = MoveType(move_type_str) |
| field_name = move_dict.get("fieldName") or move_dict.get(Move.FIELD_FIELD_NAME) |
| if field_name is None: |
| raise ValueError(f"Missing fieldName field in Move: {move_dict}") |
| move = Move( |
| field_name=field_name, |
| reference_field_name=move_dict.get("referenceFieldName") or move_dict.get(Move.FIELD_REFERENCE_FIELD_NAME), |
| type=move_type |
| ) |
| return UpdateColumnPosition(move=move) |
| else: |
| raise ValueError(f"Unknown schema change action: {action}") |
| |
| |
| class RESTCatalogServer: |
| """Mock REST server for testing""" |
| |
| def __init__(self, data_path: str, auth_provider, config: ConfigResponse, warehouse: str, |
| role_name: str = None, token_json: str = None): |
| self.logger = logging.getLogger(__name__) |
| self.warehouse = warehouse |
| self.config_response = config |
| |
| # Initialize resource paths |
| prefix = config.defaults.get("prefix") |
| self.resource_paths = ResourcePaths(prefix=prefix) |
| self.database_uri = self.resource_paths.databases() |
| |
| # Initialize storage |
| self.database_store: Dict[str, GetDatabaseResponse] = {} |
| self.table_metadata_store: Dict[str, TableMetadata] = {} |
| self.table_latest_snapshot_store: Dict[str, str] = {} |
| self.table_partitions_store: Dict[str, List] = {} |
| self.function_store: Dict[str, Dict] = {} # key: "db.func_name", value: GetFunctionResponse-like dict |
| # Tag store: key = full table name, value = {tag_name: GetTagResponse}. |
| self.tag_store: Dict[str, Dict[str, GetTagResponse]] = {} |
| # Branch store: key = full table name, value = set of branch names. |
| self.branch_store: Dict[str, set] = {} |
| self.no_permission_databases: List[str] = [] |
| self.no_permission_tables: List[str] = [] |
| self.table_token_store: Dict[str, "RESTToken"] = {} |
| |
| # Initialize mock catalog (simplified) |
| self.data_path = data_path |
| self.auth_provider = auth_provider |
| self.role_name = role_name |
| self.token_json = token_json |
| |
| # HTTP server setup |
| self.server = None |
| self.server_thread = None |
| self.port = 0 |
| |
| def start(self) -> None: |
| """Start the mock server""" |
| handler = self._create_request_handler() |
| self.server = HTTPServer(('localhost', 0), handler) |
| self.port = self.server.server_port |
| |
| self.server_thread = threading.Thread(target=self.server.serve_forever) |
| self.server_thread.daemon = True |
| self.server_thread.start() |
| |
| self.logger.info(f"Mock REST server started on port {self.port}") |
| |
| def get_url(self) -> str: |
| """Get server URL""" |
| return f"http://localhost:{self.port}" |
| |
| def shutdown(self) -> None: |
| """Shutdown the server""" |
| if self.server: |
| self.server.shutdown() |
| self.server.server_close() |
| if self.server_thread: |
| self.server_thread.join() |
| |
| def _create_request_handler(self): |
| """Create HTTP request handler""" |
| server_instance = self |
| |
| class RequestHandler(BaseHTTPRequestHandler): |
| def do_GET(self): |
| self._handle_request('GET') |
| |
| def do_POST(self): |
| self._handle_request('POST') |
| |
| def do_DELETE(self): |
| self._handle_request('DELETE') |
| |
| def _handle_request(self, method: str): |
| try: |
| # Parse request |
| parsed_url = urlparse(self.path) |
| resource_path = parsed_url.path |
| parameters = self._parse_query_params(parsed_url.query) |
| |
| # Get request body |
| content_length = int(self.headers.get('Content-Length', 0)) |
| data = self.rfile.read(content_length).decode('utf-8') if content_length > 0 else "" |
| |
| # Get headers (case-insensitive from HTTPMessage) |
| auth_token = self.headers.get(AUTHORIZATION_HEADER_KEY) |
| headers = dict(self.headers) |
| |
| # Handle authentication |
| if not self._authenticate(auth_token, resource_path, parameters, method, data): |
| self._send_response(401, "Unauthorized") |
| return |
| |
| # Route request |
| response, status_code = server_instance._route_request( |
| method, resource_path, parameters, data, headers |
| ) |
| |
| self._send_response(status_code, response) |
| |
| except Exception as e: |
| server_instance.logger.error(f"Request handling error: {e}") |
| self._send_response(500, str(e)) |
| |
| def _parse_query_params(self, query: str) -> Dict[str, str]: |
| """Parse query parameters""" |
| if not query: |
| return {} |
| |
| params = {} |
| for pair in query.split('&'): |
| if '=' in pair: |
| key, value = pair.split('=', 1) |
| params[key.strip()] = RESTUtil.decode_string(value.strip()) |
| return params |
| |
| def _authenticate(self, token: str, path: str, params: Dict[str, str], |
| method: str, data: str) -> bool: |
| """Authenticate request by verifying Authorization header.""" |
| if server_instance.auth_provider is None: |
| return True |
| if path.startswith("/ram/security-credential"): |
| return True |
| if not token: |
| return False |
| rest_auth_parameter = RESTAuthParameter( |
| method=method, |
| path=path, |
| data=data or "", |
| parameters=params or {}, |
| ) |
| from pypaimon.api.auth.base import RESTAuthFunction |
| auth_fn = RESTAuthFunction({}, server_instance.auth_provider) |
| expected_headers = auth_fn(rest_auth_parameter) |
| expected_token = expected_headers.get( |
| AUTHORIZATION_HEADER_KEY, "") |
| return token == expected_token |
| |
| def _send_response(self, status_code: int, body: str): |
| """Send HTTP response""" |
| self.send_response(status_code) |
| self.send_header('Content-Type', 'application/json') |
| self.end_headers() |
| self.wfile.write(body.encode('utf-8')) |
| |
| def log_message(self, format, *args): |
| """Override to use our logger""" |
| server_instance.logger.debug(format % args) |
| |
| return RequestHandler |
| |
| def _route_request(self, method: str, resource_path: str, parameters: Dict[str, str], |
| data: str, headers: Dict[str, str]) -> Tuple[str, int]: |
| """Route HTTP request to appropriate handler""" |
| try: |
| # Config endpoint |
| # Config endpoint |
| if resource_path.startswith(self.resource_paths.config()): |
| warehouse_param = parameters.get(WAREHOUSE) |
| if warehouse_param == self.warehouse: |
| return self._mock_response(self.config_response, 200) |
| |
| # ecs role |
| if resource_path == '/ram/security-credential/': |
| return self._mock_response(self.role_name, 200) |
| |
| if resource_path == f'/ram/security-credential/{self.role_name}': |
| return self._mock_response(self.token_json, 200) |
| |
| # Databases endpoint |
| if resource_path == self.resource_paths.databases() or resource_path.startswith(self.database_uri + "?"): |
| return self._databases_api_handler(method, data, parameters) |
| |
| if resource_path == self.resource_paths.rename_table(): |
| rename_request = JSON.from_json(data, RenameTableRequest) |
| source_table = rename_request.source |
| destination_table = rename_request.destination |
| source = self.table_metadata_store.get(source_table.get_full_name()) |
| self.table_metadata_store.update({destination_table.get_full_name(): source}) |
| source_table_dir = (Path(self.data_path) / self.warehouse |
| / source_table.get_database_name() / source_table.get_object_name()) |
| destination_table_dir = (Path(self.data_path) / self.warehouse |
| / destination_table.get_database_name() / destination_table.get_object_name()) |
| if not source_table_dir.exists(): |
| destination_table_dir.mkdir(parents=True) |
| else: |
| source_table_dir.rename(destination_table_dir) |
| return self._mock_response("", 200) |
| |
| # Global functions endpoint (catalog-scoped) |
| if resource_path == self.resource_paths.functions() and method == "GET": |
| return self._functions_globally_handle(parameters) |
| |
| database = resource_path.split("/")[4] |
| # Database-specific endpoints |
| if resource_path.startswith(self.resource_paths.database(database)): |
| """Handle database-specific resource requests""" |
| # Extract database name and resource path |
| path_parts = resource_path[len(self.database_uri) + 1:].split('/') |
| database_name = RESTUtil.decode_string(path_parts[0]) |
| |
| # Check database permissions |
| if database_name in self.no_permission_databases: |
| raise DatabaseNoPermissionException(database_name) |
| |
| if database_name not in self.database_store: |
| raise DatabaseNotExistException(database_name) |
| |
| # Handle different resource types |
| if len(path_parts) == 1: |
| # Database operations |
| return self._database_handle(method, data, database_name) |
| |
| elif len(path_parts) == 2: |
| # Collection operations (tables, views, functions) |
| resource_type = path_parts[1] |
| |
| if resource_type.startswith(ResourcePaths.TABLES): |
| return self._tables_handle(method, data, database_name, parameters) |
| elif resource_type == ResourcePaths.FUNCTIONS: |
| return self._functions_handle(method, data, database_name, parameters) |
| elif resource_type == ResourcePaths.FUNCTION_DETAILS: |
| return self._function_details_handle(database_name, parameters) |
| |
| elif len(path_parts) >= 3: |
| # Individual resource operations |
| resource_type = path_parts[1] |
| resource_name = RESTUtil.decode_string(path_parts[2]) |
| identifier = Identifier.create(database_name, resource_name) |
| |
| if resource_type == ResourcePaths.TABLES: |
| return self._handle_table_resource(method, path_parts, identifier, data, parameters) |
| elif resource_type == ResourcePaths.PARTITIONS: |
| return self._table_partitions_handle(method, identifier, parameters) |
| elif resource_type == ResourcePaths.FUNCTIONS: |
| return self._function_handle(method, data, identifier) |
| |
| return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) |
| |
| return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) |
| |
| except DatabaseNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_DATABASE, e.database, str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except TableNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TABLE, e.identifier.get_table_name(), str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except DatabaseNoPermissionException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_DATABASE, e.database, str(e), 403 |
| ) |
| return self._mock_response(response, 403) |
| except TableNoPermissionException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TABLE, e.identifier.get_table_name(), str(e), 403 |
| ) |
| return self._mock_response(response, 403) |
| except DatabaseAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_DATABASE, e.database, str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except TableAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TABLE, e.identifier.get_full_name(), str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except FunctionNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_FUNCTION, e.identifier.get_object_name(), str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except FunctionAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_FUNCTION, e.identifier.get_full_name(), str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except TagNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TAG, e.tag, str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except TagAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TAG, e.tag, str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except BranchNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_BRANCH, e.branch, str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except BranchAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_BRANCH, e.branch, str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except DefinitionAlreadyExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_DEFINITION, e.name, str(e), 409 |
| ) |
| return self._mock_response(response, 409) |
| except DefinitionNotExistException as e: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_DEFINITION, e.name, str(e), 404 |
| ) |
| return self._mock_response(response, 404) |
| except Exception as e: |
| self.logger.error(f"Unexpected error: {e}") |
| response = ErrorResponse(None, None, str(e), 500) |
| return self._mock_response(response, 500) |
| |
| def _handle_table_resource(self, method: str, path_parts: List[str], |
| identifier: Identifier, data: str, |
| parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle table-specific resource requests.""" |
| # The branch (if any) is encoded into the object name as |
| # "$branch_<name>" — see Identifier. Strip it for table lookup, and |
| # surface the branch separately for routes that care (e.g. commit). |
| branch_part = identifier.get_branch_name() |
| if branch_part is not None: |
| lookup_identifier = Identifier.create( |
| identifier.get_database_name(), identifier.get_table_name()) |
| else: |
| lookup_identifier = identifier |
| |
| # Check table permissions using the base identifier |
| if lookup_identifier.get_full_name() in self.no_permission_tables: |
| raise TableNoPermissionException(lookup_identifier) |
| |
| if len(path_parts) == 3: |
| # Basic table operations (GET, DELETE, etc.) |
| return self._table_handle(method, data, lookup_identifier) |
| elif len(path_parts) == 4: |
| # Extended operations (e.g., commit, token, snapshot) |
| operation = path_parts[3] |
| if operation == "commit": |
| return self._table_commit_handle(method, data, lookup_identifier, branch_part) |
| elif operation == "token": |
| return self._table_token_handle(method, lookup_identifier) |
| elif operation == "rollback": |
| return self._table_rollback_handle(method, data, lookup_identifier) |
| elif operation == "snapshot": |
| return self._table_snapshot_handle(method, lookup_identifier) |
| elif operation == ResourcePaths.PARTITIONS: |
| return self._table_partitions_handle(method, lookup_identifier, parameters) |
| elif operation == ResourcePaths.TAGS: |
| return self._tags_handle(method, data, lookup_identifier, parameters) |
| elif operation == ResourcePaths.BRANCHES: |
| return self._branches_handle(method, data, lookup_identifier) |
| else: |
| return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) |
| elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.TAGS: |
| tag_name = RESTUtil.decode_string(path_parts[4]) |
| return self._tag_handle(method, lookup_identifier, tag_name) |
| elif len(path_parts) == 5 and path_parts[3] == ResourcePaths.BRANCHES: |
| branch_name = RESTUtil.decode_string(path_parts[4]) |
| return self._branch_handle(method, lookup_identifier, branch_name) |
| elif len(path_parts) == 6 and path_parts[3] == ResourcePaths.BRANCHES: |
| branch_name = RESTUtil.decode_string(path_parts[4]) |
| sub = path_parts[5] |
| if sub == ResourcePaths.RENAME: |
| return self._branch_rename_handle(method, data, lookup_identifier, branch_name) |
| if sub == ResourcePaths.FORWARD: |
| return self._branch_forward_handle(method, lookup_identifier, branch_name) |
| return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) |
| return self._mock_response(ErrorResponse(None, None, "Not Found", 404), 404) |
| |
| # ======================= Function Handlers =============================== |
| |
| def _functions_handle(self, method: str, data: str, database_name: str, |
| parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle database-scoped function list / create.""" |
| if method == "GET": |
| function_name_pattern = parameters.get(FUNCTION_NAME_PATTERN) |
| functions = [ |
| key.split(".", 1)[1] |
| for key in self.function_store.keys() |
| if key.startswith(database_name + ".") |
| and (not function_name_pattern or self._match_name_pattern(key.split(".", 1)[1], function_name_pattern)) |
| ] |
| return self._generate_final_list_functions_response(parameters, functions) |
| elif method == "POST": |
| import json as json_module |
| request_dict = json_module.loads(data) |
| func_name = request_dict.get("name") |
| key = f"{database_name}.{func_name}" |
| if key in self.function_store: |
| identifier = Identifier.create(database_name, func_name) |
| raise FunctionAlreadyExistException(identifier) |
| self.function_store[key] = GetFunctionResponse( |
| uuid=str(uuid.uuid4()), |
| name=func_name, |
| input_params=request_dict.get("inputParams"), |
| return_params=request_dict.get("returnParams"), |
| deterministic=request_dict.get("deterministic", False), |
| definitions=request_dict.get("definitions"), |
| comment=request_dict.get("comment"), |
| options=request_dict.get("options", {}), |
| owner="owner", |
| created_at=1, |
| created_by="owner", |
| updated_at=1, |
| updated_by="owner", |
| ) |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _function_handle(self, method: str, data: str, identifier: Identifier) -> Tuple[str, int]: |
| """Handle individual function operations (GET, POST alter, DELETE).""" |
| key = identifier.get_full_name() |
| if method == "GET": |
| if key not in self.function_store: |
| raise FunctionNotExistException(identifier) |
| return self._mock_response(self.function_store[key], 200) |
| elif method == "POST": |
| # Alter function |
| if key not in self.function_store: |
| raise FunctionNotExistException(identifier) |
| import json as json_module |
| request_dict = json_module.loads(data) |
| changes = request_dict.get("changes", []) |
| self._apply_function_changes(identifier, changes) |
| return self._mock_response("", 200) |
| elif method == "DELETE": |
| if key not in self.function_store: |
| raise FunctionNotExistException(identifier) |
| del self.function_store[key] |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _function_details_handle(self, database_name: str, |
| parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle function details listing.""" |
| function_name_pattern = parameters.get(FUNCTION_NAME_PATTERN) |
| details = [] |
| for key, resp in self.function_store.items(): |
| if key.startswith(database_name + "."): |
| func_name = key.split(".", 1)[1] |
| if not function_name_pattern or self._match_name_pattern(func_name, function_name_pattern): |
| details.append(resp) |
| return self._generate_final_list_function_details_response(parameters, details) |
| |
| def _functions_globally_handle(self, parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle catalog-scoped function listing.""" |
| database_name_pattern = parameters.get(DATABASE_NAME_PATTERN) |
| function_name_pattern = parameters.get(FUNCTION_NAME_PATTERN) |
| identifiers = [] |
| for key in self.function_store.keys(): |
| db_name, func_name = key.split(".", 1) |
| if database_name_pattern and not self._match_name_pattern(db_name, database_name_pattern): |
| continue |
| if function_name_pattern and not self._match_name_pattern(func_name, function_name_pattern): |
| continue |
| identifiers.append(Identifier.create(db_name, func_name)) |
| return self._generate_final_list_functions_globally_response(parameters, identifiers) |
| |
| def _apply_function_changes(self, identifier: Identifier, changes: List[Dict]) -> None: |
| """Apply function changes to the function store, mirroring Java mock server logic.""" |
| from pypaimon.function.function_change import Actions |
| key = identifier.get_full_name() |
| func_resp = self.function_store[key] |
| |
| # Work with mutable copies |
| options = dict(func_resp.options) if func_resp.options else {} |
| definitions = dict(func_resp.definitions) if func_resp.definitions else {} |
| comment = func_resp.comment |
| |
| for change in changes: |
| action = change.get("action") |
| if action == Actions.SET_OPTION: |
| options[change["key"]] = change["value"] |
| elif action == Actions.REMOVE_OPTION: |
| options.pop(change["key"], None) |
| elif action == Actions.UPDATE_COMMENT: |
| comment = change.get("comment") |
| elif action == Actions.ADD_DEFINITION: |
| name = change["name"] |
| if name in definitions: |
| raise DefinitionAlreadyExistException(identifier, name) |
| definitions[name] = change["definition"] |
| elif action == Actions.UPDATE_DEFINITION: |
| name = change["name"] |
| if name not in definitions: |
| raise DefinitionNotExistException(identifier, name) |
| definitions[name] = change["definition"] |
| elif action == Actions.DROP_DEFINITION: |
| name = change["name"] |
| if name not in definitions: |
| raise DefinitionNotExistException(identifier, name) |
| del definitions[name] |
| |
| self.function_store[key] = GetFunctionResponse( |
| uuid=func_resp.uuid, |
| name=func_resp.name, |
| input_params=func_resp.input_params, |
| return_params=func_resp.return_params, |
| deterministic=func_resp.deterministic, |
| definitions=definitions, |
| comment=comment, |
| options=options, |
| owner=func_resp.owner, |
| created_at=func_resp.created_at, |
| created_by=func_resp.created_by, |
| updated_at=func_resp.updated_at, |
| updated_by=func_resp.updated_by, |
| ) |
| |
| def _generate_final_list_functions_response(self, parameters: Dict[str, str], |
| functions: List[str]) -> Tuple[str, int]: |
| if functions: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged = self._build_paged_entities(functions, max_results, page_token) |
| response = ListFunctionsResponse( |
| functions=paged.elements, |
| next_page_token=paged.next_page_token |
| ) |
| else: |
| response = ListFunctionsResponse(functions=[], next_page_token=None) |
| return self._mock_response(response, 200) |
| |
| def _generate_final_list_function_details_response(self, parameters: Dict[str, str], |
| details: List) -> Tuple[str, int]: |
| if details: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged = self._build_paged_entities(details, max_results, page_token) |
| response = ListFunctionDetailsResponse( |
| function_details=paged.elements, |
| next_page_token=paged.next_page_token, |
| ) |
| else: |
| response = ListFunctionDetailsResponse(function_details=[], next_page_token=None) |
| return self._mock_response(response, 200) |
| |
| def _generate_final_list_functions_globally_response(self, parameters: Dict[str, str], |
| identifiers: List) -> Tuple[str, int]: |
| if identifiers: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged = self._build_paged_entities(identifiers, max_results, page_token) |
| response = ListFunctionsGloballyResponse( |
| functions=paged.elements, |
| next_page_token=paged.next_page_token, |
| ) |
| else: |
| response = ListFunctionsGloballyResponse(functions=[], next_page_token=None) |
| return self._mock_response(response, 200) |
| |
| def _table_partitions_handle( |
| self, method: str, identifier: Identifier, parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle table partitions listing""" |
| if method != "GET": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| partitions = self._list_partitions(identifier, parameters) |
| return self._generate_final_list_partitions_response(parameters, partitions) |
| |
| # ======================= Tag Handlers ==================================== |
| |
| def _tags_handle(self, method: str, data: str, identifier: Identifier, |
| parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle the table-scoped tags collection (POST create / GET list-paged).""" |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| if method == "POST": |
| request = JSON.from_json(data, CreateTagRequest) |
| store = self.tag_store.setdefault(identifier.get_full_name(), {}) |
| if request.tag_name in store: |
| raise TagAlreadyExistException(request.tag_name) |
| snapshot = self._resolve_tag_snapshot(identifier, request.snapshot_id) |
| store[request.tag_name] = GetTagResponse( |
| tag_name=request.tag_name, |
| snapshot=snapshot, |
| tag_create_time=int(time.time() * 1000), |
| tag_time_retained=request.time_retained, |
| ) |
| return self._mock_response("", 200) |
| |
| if method == "GET": |
| tags = list(self.tag_store.get(identifier.get_full_name(), {}).keys()) |
| tag_name_prefix = parameters.get(TAG_NAME_PREFIX) |
| if tag_name_prefix: |
| tags = [t for t in tags if t.startswith(tag_name_prefix)] |
| if tags: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged = self._build_paged_entities(tags, max_results, page_token) |
| response = ListTagsResponse( |
| tags=paged.elements, next_page_token=paged.next_page_token) |
| else: |
| response = ListTagsResponse(tags=[], next_page_token=None) |
| return self._mock_response(response, 200) |
| |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _tag_handle(self, method: str, identifier: Identifier, |
| tag_name: str) -> Tuple[str, int]: |
| """Handle a single tag (GET / DELETE).""" |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| store = self.tag_store.get(identifier.get_full_name(), {}) |
| |
| if method == "GET": |
| if tag_name not in store: |
| raise TagNotExistException(tag_name) |
| return self._mock_response(store[tag_name], 200) |
| if method == "DELETE": |
| if tag_name not in store: |
| raise TagNotExistException(tag_name) |
| del store[tag_name] |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _resolve_tag_snapshot(self, identifier: Identifier, |
| snapshot_id: Optional[int]): |
| """Look up the snapshot to embed in GetTagResponse. |
| |
| When ``snapshot_id`` is None, fall back to the table's latest snapshot. |
| Mirrors Java behavior of ``TagManager`` resolving the latest snapshot |
| when no explicit id is supplied. |
| """ |
| try: |
| table = self._get_file_table(identifier) |
| snapshot_manager = table.snapshot_manager() |
| if snapshot_id is None: |
| return snapshot_manager.get_latest_snapshot() |
| return snapshot_manager.get_snapshot_by_id(snapshot_id) |
| except Exception: |
| return None |
| |
| # ======================= Branch Handlers ================================ |
| |
| def _branches_handle(self, method: str, data: str, |
| identifier: Identifier) -> Tuple[str, int]: |
| """Handle the table-scoped branches collection (POST create / GET list).""" |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| if method == "POST": |
| request = JSON.from_json(data, CreateBranchRequest) |
| # Mock simplification: ``from_tag`` existence is NOT validated here. |
| # The real Java REST server checks against TagManager and returns |
| # 404+TAG when the tag is missing. pypaimon's mock doesn't track |
| # tag-to-branch dependencies; a TODO for full validation lives |
| # with the Tag CRUD work in #7746. |
| store = self.branch_store.setdefault(identifier.get_full_name(), set()) |
| if request.branch in store: |
| raise BranchAlreadyExistException(request.branch) |
| store.add(request.branch) |
| return self._mock_response("", 200) |
| |
| if method == "GET": |
| store = self.branch_store.get(identifier.get_full_name(), set()) |
| response = ListBranchesResponse(branches=sorted(store)) |
| return self._mock_response(response, 200) |
| |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _branch_handle(self, method: str, identifier: Identifier, |
| branch_name: str) -> Tuple[str, int]: |
| """Handle a single branch DELETE.""" |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| store = self.branch_store.get(identifier.get_full_name(), set()) |
| |
| if method == "DELETE": |
| if branch_name not in store: |
| raise BranchNotExistException(branch_name) |
| store.discard(branch_name) |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _branch_rename_handle(self, method: str, data: str, identifier: Identifier, |
| from_branch: str) -> Tuple[str, int]: |
| if method != "POST": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| store = self.branch_store.setdefault(identifier.get_full_name(), set()) |
| if from_branch not in store: |
| raise BranchNotExistException(from_branch) |
| request = JSON.from_json(data, RenameBranchRequest) |
| if request.to_branch in store: |
| raise BranchAlreadyExistException(request.to_branch) |
| store.discard(from_branch) |
| store.add(request.to_branch) |
| return self._mock_response("", 200) |
| |
| def _branch_forward_handle(self, method: str, identifier: Identifier, |
| branch_name: str) -> Tuple[str, int]: |
| if method != "POST": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| store = self.branch_store.get(identifier.get_full_name(), set()) |
| if branch_name not in store: |
| raise BranchNotExistException(branch_name) |
| # Mock no-op: real Java fast-forward moves the main branch ref to the |
| # target branch's snapshot. Mock just acknowledges the request. |
| return self._mock_response("", 200) |
| |
| def _databases_api_handler(self, method: str, data: str, |
| parameters: Dict[str, str]) -> Tuple[str, int]: |
| """Handle databases API requests""" |
| if method == "GET": |
| database_name_pattern = parameters.get(DATABASE_NAME_PATTERN) |
| databases = [ |
| db_name for db_name in self.database_store.keys() |
| if not database_name_pattern or self._match_name_pattern(db_name, database_name_pattern) |
| ] |
| return self._generate_final_list_databases_response(parameters, databases) |
| if method == "POST": |
| create_database = JSON.from_json(data, CreateDatabaseRequest) |
| if create_database.name in self.database_store: |
| raise DatabaseAlreadyExistException(create_database.name) |
| self.database_store.update({ |
| create_database.name: self.mock_database(create_database.name, create_database.options) |
| }) |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _database_handle(self, method: str, data: str, database_name: str) -> Tuple[str, int]: |
| """Handle individual database operations""" |
| if database_name not in self.database_store: |
| raise DatabaseNotExistException(database_name) |
| |
| database = self.database_store[database_name] |
| |
| if method == "GET": |
| response = database |
| return self._mock_response(response, 200) |
| |
| elif method == "POST": |
| request_body = JSON.from_json(data, AlterDatabaseRequest) |
| removals = request_body.removals or [] |
| updates = request_body.updates or {} |
| options = dict(database.options) if database.options else {} |
| options.update(updates) |
| for key in removals: |
| options.pop(key, None) |
| self.database_store[database_name] = self.mock_database( |
| database_name, options) |
| return self._mock_response("", 200) |
| |
| elif method == "DELETE": |
| del self.database_store[database_name] |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _tables_handle(self, method: str = None, data: str = None, database_name: str = None, |
| parameters: Dict[str, str] = None) -> Tuple[str, int]: |
| """Handle tables operations""" |
| if parameters is None: |
| parameters = {} |
| |
| if database_name: |
| # Database-specific tables |
| if method == "GET": |
| tables = self._list_tables(database_name, parameters) |
| return self._generate_final_list_tables_response(parameters, tables) |
| elif method == "POST": |
| create_table = JSON.from_json(data, CreateTableRequest) |
| if create_table.identifier.get_full_name() in self.table_metadata_store: |
| raise TableAlreadyExistException(create_table.identifier) |
| table_metadata = self._create_table_metadata( |
| create_table.identifier, 0, create_table.schema, str(uuid.uuid4()), False |
| ) |
| self.table_metadata_store.update({create_table.identifier.get_full_name(): table_metadata}) |
| table_dir = ( |
| Path(self.data_path) / self.warehouse / database_name / |
| create_table.identifier.get_object_name() / 'schema' |
| ) |
| if not table_dir.exists(): |
| table_dir.mkdir(parents=True) |
| with open(table_dir / "schema-0", "w") as f: |
| f.write(JSON.to_json(table_metadata.schema, indent=2)) |
| return self._mock_response("", 200) |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _table_handle(self, method: str, data: str, identifier: Identifier) -> Tuple[str, int]: |
| """Handle individual table operations""" |
| if method == "GET": |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| table_metadata = self.table_metadata_store[identifier.get_full_name()] |
| table_path = (f'file://{self.data_path}/{self.warehouse}/' |
| f'{identifier.get_database_name()}/{identifier.get_object_name()}') |
| schema = table_metadata.schema.to_schema() |
| response = self.mock_table(identifier, table_metadata, table_path, schema) |
| return self._mock_response(response, 200) |
| elif method == "POST": |
| # Alter table |
| request_body = JSON.from_json(data, AlterTableRequest) |
| self._alter_table_impl(identifier, request_body.changes) |
| return self._mock_response("", 200) |
| elif method == "DELETE": |
| # Drop table |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| else: |
| del self.table_metadata_store[identifier.get_full_name()] |
| if identifier.get_full_name() in self.table_latest_snapshot_store: |
| del self.table_latest_snapshot_store[identifier.get_full_name()] |
| if identifier.get_full_name() in self.table_partitions_store: |
| del self.table_partitions_store[identifier.get_full_name()] |
| |
| return self._mock_response("", 200) |
| |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| def _table_token_handle(self, method: str, identifier: Identifier) -> Tuple[str, int]: |
| if method != "GET": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| from pypaimon.api.api_response import GetTableTokenResponse |
| |
| token_key = identifier.get_full_name() |
| if token_key in self.table_token_store: |
| rest_token = self.table_token_store[token_key] |
| response = GetTableTokenResponse( |
| token=rest_token.token, |
| expires_at_millis=rest_token.expire_at_millis |
| ) |
| else: |
| default_token = { |
| "akId": "akId" + str(int(time.time() * 1000)), |
| "akSecret": "akSecret" + str(int(time.time() * 1000)) |
| } |
| response = GetTableTokenResponse( |
| token=default_token, |
| expires_at_millis=int(time.time() * 1000) + 3600_000 # 1 hour from now |
| ) |
| |
| return self._mock_response(response, 200) |
| |
| def set_table_token(self, identifier: Identifier, token: "RESTToken") -> None: |
| self.table_token_store[identifier.get_full_name()] = token |
| |
| def get_table_token(self, identifier: Identifier) -> Optional["RESTToken"]: |
| return self.table_token_store.get(identifier.get_full_name()) |
| |
| def reset_table_token(self, identifier: Identifier) -> None: |
| if identifier.get_full_name() in self.table_token_store: |
| del self.table_token_store[identifier.get_full_name()] |
| |
| def _table_commit_handle(self, method: str, data: str, identifier: Identifier, |
| branch: str = None) -> Tuple[str, int]: |
| """Handle table commit operations""" |
| if method != "POST": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| # Check if table exists |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| try: |
| # Parse the commit request |
| from pypaimon.api.api_request import CommitTableRequest |
| from pypaimon.api.api_response import CommitTableResponse |
| |
| commit_request = JSON.from_json(data, CommitTableRequest) |
| |
| # Basic validation |
| if not commit_request.snapshot: |
| return self._mock_response( |
| ErrorResponse("SNAPSHOT", None, "Snapshot is required for commit operation", 400), 400 |
| ) |
| |
| table = self._get_file_table(identifier) |
| current_snapshot = table.snapshot_manager().get_latest_snapshot() |
| current_snapshot_uuid = ( |
| current_snapshot.uuid if current_snapshot else None |
| ) |
| if current_snapshot_uuid != commit_request.base_snapshot_uuid: |
| return self._mock_response( |
| CommitTableResponse(success=False), 200 |
| ) |
| |
| # Write snapshot to file system |
| self._write_snapshot_files(identifier, commit_request.snapshot, commit_request.statistics) |
| |
| self.logger.info(f"Successfully committed snapshot for table {identifier.get_full_name()}, " |
| f"branch: {branch or 'main'}") |
| self.logger.info(f"Snapshot ID: {commit_request.snapshot.id}") |
| self.logger.info(f"Statistics count: {len(commit_request.statistics) if commit_request.statistics else 0}") |
| |
| # Create success response |
| response = CommitTableResponse(success=True) |
| return self._mock_response(response, 200) |
| |
| except Exception as e: |
| self.logger.error(f"Error in commit operation: {e}") |
| import traceback |
| self.logger.error(f"Traceback: {traceback.format_exc()}") |
| return self._mock_response( |
| ErrorResponse(None, None, f"Commit failed: {str(e)}", 500), 500 |
| ) |
| |
| def _table_rollback_handle(self, method: str, data: str, |
| identifier: Identifier) -> Tuple[str, int]: |
| """Handle table rollback operations""" |
| if method != "POST": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| try: |
| import json as json_module |
| from pypaimon.table.instant import Instant, SnapshotInstant, TagInstant |
| |
| request_dict = json_module.loads(data) |
| instant_dict = request_dict.get("instant") |
| from_snapshot = request_dict.get("fromSnapshot") |
| |
| instant = Instant.from_dict(instant_dict) |
| |
| if isinstance(instant, SnapshotInstant): |
| return self._rollback_table_by_snapshot( |
| identifier, instant.snapshot_id, from_snapshot) |
| elif isinstance(instant, TagInstant): |
| return self._rollback_table_by_tag(identifier, instant.tag_name) |
| else: |
| return self._mock_response( |
| ErrorResponse(None, None, "Unknown instant type", 400), 400) |
| |
| except Exception as e: |
| self.logger.error(f"Error in rollback operation: {e}") |
| import traceback |
| self.logger.error(f"Traceback: {traceback.format_exc()}") |
| return self._mock_response( |
| ErrorResponse(None, None, f"Rollback failed: {str(e)}", 500), 500) |
| |
| def _rollback_table_by_snapshot(self, identifier: Identifier, snapshot_id: int, |
| from_snapshot: Optional[int]) -> Tuple[str, int]: |
| """Rollback table to a specific snapshot ID by delegating to table.rollback_to().""" |
| table = self._get_file_table(identifier) |
| |
| snapshot_mgr = table.snapshot_manager() |
| snapshot = snapshot_mgr.get_snapshot_by_id(snapshot_id) |
| if snapshot is None: |
| return self._mock_response( |
| ErrorResponse(ErrorResponse.RESOURCE_TYPE_SNAPSHOT, |
| str(snapshot_id), "", 404), 404) |
| |
| latest = snapshot_mgr.get_latest_snapshot() |
| if latest is None: |
| return self._mock_response( |
| ErrorResponse(None, None, "No latest snapshot found", 500), 500) |
| |
| if from_snapshot is not None and from_snapshot != latest.id: |
| return self._mock_response( |
| ErrorResponse(None, None, |
| f"Latest snapshot {latest.id} is not {from_snapshot}", |
| 500), 500) |
| |
| table.rollback_to(snapshot_id) |
| return self._mock_response("", 200) |
| |
| def _rollback_table_by_tag(self, identifier: Identifier, |
| tag_name: str) -> Tuple[str, int]: |
| """Rollback table to a specific tag by delegating to table.rollback_to().""" |
| table = self._get_file_table(identifier) |
| |
| tag_mgr = table.tag_manager() |
| if not tag_mgr.tag_exists(tag_name): |
| return self._mock_response( |
| ErrorResponse(ErrorResponse.RESOURCE_TYPE_TAG, |
| tag_name, "", 404), 404) |
| |
| table.rollback_to(tag_name) |
| return self._mock_response("", 200) |
| |
| def _table_snapshot_handle(self, method: str, identifier: Identifier) -> Tuple[str, int]: |
| """Handle table snapshot operations. |
| |
| Args: |
| method: HTTP method |
| identifier: Table identifier |
| |
| Returns: |
| Tuple of (response JSON, HTTP status code) |
| """ |
| if method != "GET": |
| return self._mock_response(ErrorResponse(None, None, "Method Not Allowed", 405), 405) |
| |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| table_metadata = self.table_metadata_store[identifier.get_full_name()] |
| if table_metadata.is_external: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_TABLE, |
| identifier.get_full_name(), |
| "external paimon table does not support get table snapshot in rest server", |
| 501) |
| return self._mock_response(response, 404) |
| |
| # Get the table and snapshot manager to retrieve snapshot |
| table = self._get_file_table(identifier) |
| snapshot_manager = table.snapshot_manager() |
| |
| # Get latest snapshot |
| snapshot = snapshot_manager.get_latest_snapshot() |
| |
| if snapshot is None: |
| response = ErrorResponse( |
| ErrorResponse.RESOURCE_TYPE_SNAPSHOT, |
| identifier.get_database_name(), |
| "No Snapshot", |
| 404) |
| return self._mock_response(response, 404) |
| |
| from pypaimon.api.api_response import GetTableSnapshotResponse |
| from pypaimon.snapshot.table_snapshot import TableSnapshot |
| |
| table_snapshot = TableSnapshot( |
| snapshot=snapshot, |
| record_count=snapshot.total_record_count, |
| file_size_in_bytes=0, |
| file_count=0, |
| last_file_creation_time=snapshot.time_millis |
| ) |
| response = GetTableSnapshotResponse(table_snapshot) |
| return self._mock_response(response, 200) |
| |
| def _get_file_table(self, identifier: Identifier): |
| """Construct a FileStoreTable from the metadata store. |
| |
| loads the schema from the metadata store, builds a CatalogEnvironment |
| (without catalog loader so rollback goes through local file cleanup), |
| and returns a FileStoreTable. |
| """ |
| from pypaimon.catalog.catalog_environment import CatalogEnvironment |
| from pypaimon.common.file_io import FileIO |
| from pypaimon.common.options.options import Options |
| from pypaimon.table.file_store_table import FileStoreTable |
| |
| table_metadata = self.table_metadata_store.get(identifier.get_full_name()) |
| if table_metadata is None: |
| raise TableNotExistException(identifier) |
| |
| table_schema = table_metadata.schema |
| table_path = ( |
| f'file://{self.data_path}/{self.warehouse}/' |
| f'{identifier.get_database_name()}/{identifier.get_object_name()}') |
| |
| catalog_env = CatalogEnvironment( |
| identifier=identifier, |
| uuid=table_metadata.uuid, |
| catalog_loader=None, |
| supports_version_management=False |
| ) |
| |
| file_io = FileIO.get(table_path, Options({})) |
| return FileStoreTable(file_io, identifier, table_path, table_schema, catalog_env) |
| |
| def _write_snapshot_files(self, identifier: Identifier, snapshot, statistics): |
| """Write snapshot and related files to the file system""" |
| import os |
| |
| # Construct table path: {warehouse}/{database}/{table} |
| table_path = os.path.join(self.data_path, self.warehouse, identifier.get_database_name(), |
| identifier.get_object_name()) |
| |
| # Create directory structure |
| snapshot_dir = os.path.join(table_path, "snapshot") |
| |
| os.makedirs(snapshot_dir, exist_ok=True) |
| |
| # Write snapshot file (snapshot-{id}) |
| snapshot_file = os.path.join(snapshot_dir, f"snapshot-{snapshot.id}") |
| with open(snapshot_file, 'w') as f: |
| f.write(JSON.to_json(snapshot)) |
| |
| # Write LATEST file |
| latest_file = os.path.join(snapshot_dir, "LATEST") |
| with open(latest_file, 'w') as f: |
| f.write(str(snapshot.id)) |
| |
| # Create partition directories based on statistics |
| if statistics: |
| for stat in statistics: |
| if hasattr(stat, 'spec') and stat.spec: |
| # Extract partition information from spec |
| partition_parts = [] |
| for key, value in stat.spec.items(): |
| partition_parts.append(f"{key}={value}") |
| |
| if partition_parts: |
| partition_dir = os.path.join(table_path, *partition_parts) |
| os.makedirs(partition_dir, exist_ok=True) |
| |
| # If no statistics provided, create default partition directories for test |
| if not statistics: |
| # Create default partitions that the test expects |
| default_partitions = ["dt=p1", "dt=p2"] |
| for partition in default_partitions: |
| partition_dir = os.path.join(table_path, partition) |
| os.makedirs(partition_dir, exist_ok=True) |
| |
| self.logger.info(f"Created snapshot files at: {snapshot_dir}") |
| |
| # Utility methods |
| def _mock_response(self, response: Union[RESTResponse, str], http_code: int) -> Tuple[str, int]: |
| """Create mock response""" |
| if isinstance(response, str): |
| return response, http_code |
| |
| try: |
| return JSON.to_json(response), http_code |
| except Exception as e: |
| self.logger.error(f"Failed to serialize response: {e}") |
| return str(e), 500 |
| |
| def _get_max_results(self, parameters: Dict[str, str]) -> int: |
| """Get max results from parameters""" |
| max_results_str = parameters.get(MAX_RESULTS) |
| if max_results_str: |
| try: |
| max_results = int(max_results_str) |
| return min(max_results, DEFAULT_MAX_RESULTS) if max_results > 0 else DEFAULT_MAX_RESULTS |
| except ValueError: |
| raise ValueError(f"Invalid maxResults value: {max_results_str}") |
| return DEFAULT_MAX_RESULTS |
| |
| def _build_paged_entities(self, entities: List[Any], max_results: int, |
| page_token: Optional[str], desc: bool = False) -> PagedList: |
| """Build paged entities""" |
| # Sort entities |
| sorted_entities = sorted(entities, key=self._get_paged_key, reverse=desc) |
| |
| # Apply pagination |
| paged_entities = [] |
| for entity in sorted_entities: |
| if len(paged_entities) < max_results: |
| if not page_token or self._get_paged_key(entity) > page_token: |
| paged_entities.append(entity) |
| else: |
| break |
| |
| # Determine next page token |
| next_page_token = None |
| if len(paged_entities) == max_results and len(sorted_entities) > max_results: |
| next_page_token = self._get_paged_key(paged_entities[-1]) |
| |
| return PagedList(elements=paged_entities, next_page_token=next_page_token) |
| |
| def _get_paged_key(self, entity: Any) -> str: |
| """Get paging key for entity""" |
| if isinstance(entity, str): |
| return entity |
| elif isinstance(entity, Partition): |
| return "/".join(f"{k}={v}" for k, v in sorted(entity.spec.items())) |
| elif hasattr(entity, 'get_name'): |
| return entity.get_name() |
| elif hasattr(entity, 'get_full_name'): |
| return entity.get_full_name() |
| elif hasattr(entity, 'name'): |
| return entity.name |
| else: |
| return str(entity) |
| |
| def _match_name_pattern(self, name: str, pattern: str) -> bool: |
| """Match name against SQL pattern""" |
| if not pattern: |
| raise ValueError("Pattern cannot be empty") |
| regex_pattern = self._sql_pattern_to_regex(pattern) |
| return re.match(regex_pattern, name) is not None |
| |
| def _sql_pattern_to_regex(self, pattern: str) -> str: |
| """Convert SQL pattern to regex""" |
| regex = [] |
| escaped = False |
| |
| for char in pattern: |
| if escaped: |
| regex.append(re.escape(char)) |
| escaped = False |
| elif char == '\\': |
| escaped = True |
| elif char == '%': |
| regex.append('.*') |
| elif char == '_': |
| regex.append('.') |
| else: |
| regex.append(re.escape(char)) |
| |
| return '^' + ''.join(regex) + '$' |
| |
| def _alter_table_impl(self, identifier: Identifier, changes: List) -> None: |
| if identifier.get_full_name() not in self.table_metadata_store: |
| raise TableNotExistException(identifier) |
| |
| schema_changes = [] |
| for change in changes: |
| if isinstance(change, dict): |
| try: |
| schema_changes.append(_dict_to_schema_change(change)) |
| except (KeyError, TypeError) as e: |
| raise ValueError(f"Failed to convert change dict to SchemaChange: {change}, error: {e}") from e |
| else: |
| schema_changes.append(change) |
| |
| table_metadata = self.table_metadata_store[identifier.get_full_name()] |
| |
| table_path = ( |
| Path(self.data_path) / self.warehouse / |
| identifier.get_database_name() / identifier.get_object_name() |
| ) |
| schema_manager = SchemaManager(self._get_file_io(), str(table_path)) |
| new_schema = schema_manager.commit_changes(schema_changes) |
| |
| updated_metadata = TableMetadata( |
| schema=new_schema, |
| is_external=table_metadata.is_external, |
| uuid=table_metadata.uuid |
| ) |
| self.table_metadata_store[identifier.get_full_name()] = updated_metadata |
| |
| def _get_file_io(self): |
| """Get FileIO instance for SchemaManager""" |
| from pypaimon.common.file_io import FileIO |
| from pypaimon.common.options import Options |
| warehouse_path = str(Path(self.data_path) / self.warehouse) |
| options = Options({"warehouse": warehouse_path}) |
| return FileIO.get(warehouse_path, options) |
| |
| def _create_table_metadata(self, identifier: Identifier, schema_id: int, |
| schema: Schema, uuid_str: str, is_external: bool) -> TableMetadata: |
| """Create table metadata""" |
| options = schema.options.copy() |
| |
| fields = schema.fields |
| if schema.primary_keys: |
| pk_set = set(schema.primary_keys) |
| for field in fields: |
| if field.name in pk_set: |
| field.type.nullable = False |
| |
| table_schema = TableSchema( |
| version=TableSchema.CURRENT_VERSION, |
| id=schema_id, |
| fields=schema.fields, |
| highest_field_id=len(schema.fields) - 1, |
| partition_keys=schema.partition_keys, |
| primary_keys=schema.primary_keys, |
| options=options, |
| comment=schema.comment |
| ) |
| |
| return TableMetadata( |
| schema=table_schema, |
| is_external=is_external, |
| uuid=uuid_str or str(uuid.uuid4()) |
| ) |
| |
| # List methods |
| def _list_tables(self, database_name: str, parameters: Dict[str, str]) -> List[str]: |
| """List tables in database""" |
| table_name_pattern = parameters.get(TABLE_NAME_PATTERN) |
| table_type = parameters.get(TABLE_TYPE) |
| tables = [] |
| |
| for full_name, metadata in self.table_metadata_store.items(): |
| identifier = Identifier.from_string(full_name) |
| metadata_table_type = ( |
| metadata.schema.options.get(TYPE, "table") |
| if metadata and metadata.schema and metadata.schema.options |
| else "table" |
| ) |
| table_type_matches = ( |
| not table_type |
| or metadata_table_type == table_type |
| ) |
| if (identifier.get_database_name() == database_name and |
| table_type_matches and |
| (not table_name_pattern or self._match_name_pattern(identifier.get_table_name(), |
| table_name_pattern))): |
| tables.append(identifier.get_table_name()) |
| |
| return tables |
| |
| def _list_partitions(self, identifier: Identifier, parameters: Dict[str, str]) -> List[Partition]: |
| """List partitions for a table from the partitions store.""" |
| partition_name_pattern = parameters.get(PARTITION_NAME_PATTERN) |
| partitions = self.table_partitions_store.get(identifier.get_full_name(), []) |
| if partition_name_pattern: |
| partitions = [ |
| p for p in partitions |
| if self._match_partition_name_pattern(p, partition_name_pattern) |
| ] |
| return partitions |
| |
| def _match_partition_name_pattern(self, partition: Partition, pattern: str) -> bool: |
| """Match partition spec against a name pattern.""" |
| partition_name = "/".join(f"{k}={v}" for k, v in sorted(partition.spec.items())) |
| return self._match_name_pattern(partition_name, pattern) |
| |
| # Response generation methods |
| def _generate_final_list_databases_response(self, parameters: Dict[str, str], |
| databases: List[str]) -> Tuple[str, int]: |
| """Generate final list databases response""" |
| if databases: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged_dbs = self._build_paged_entities(databases, max_results, page_token) |
| response = ListDatabasesResponse( |
| databases=paged_dbs.elements, |
| next_page_token=paged_dbs.next_page_token |
| ) |
| else: |
| response = ListDatabasesResponse(databases=[], next_page_token=None) |
| |
| return self._mock_response(response, 200) |
| |
| def _generate_final_list_tables_response(self, parameters: Dict[str, str], |
| tables: List[str]) -> Tuple[str, int]: |
| """Generate final list tables response""" |
| if tables: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged_tables = self._build_paged_entities(tables, max_results, page_token) |
| response = ListTablesResponse( |
| tables=paged_tables.elements, |
| next_page_token=paged_tables.next_page_token |
| ) |
| else: |
| response = ListTablesResponse(tables=[], next_page_token=None) |
| |
| return self._mock_response(response, 200) |
| |
| def _generate_final_list_partitions_response( |
| self, parameters: Dict[str, str], partitions: List[Partition]) -> Tuple[str, int]: |
| """Generate final list partitions response""" |
| if partitions: |
| max_results = self._get_max_results(parameters) |
| page_token = parameters.get(PAGE_TOKEN) |
| paged_partitions = self._build_paged_entities(partitions, max_results, page_token) |
| response = ListPartitionsResponse( |
| partitions=paged_partitions.elements, |
| next_page_token=paged_partitions.next_page_token |
| ) |
| else: |
| response = ListPartitionsResponse(partitions=[], next_page_token=None) |
| |
| return self._mock_response(response, 200) |
| |
| def add_no_permission_database(self, database: str) -> None: |
| """Add no permission database""" |
| self.no_permission_databases.append(database) |
| |
| def add_no_permission_table(self, identifier: Identifier) -> None: |
| """Add no permission table""" |
| self.no_permission_tables.append(identifier.get_full_name()) |
| |
| def mock_database(self, name: str, options: Dict[str, str]) -> GetDatabaseResponse: |
| return GetDatabaseResponse( |
| id=str(uuid.uuid4()), |
| name=name, |
| location=f"{self.data_path}/{name}", |
| options=options, |
| owner="owner", |
| created_at=int(time.time()) * 1000, |
| created_by="created", |
| updated_at=int(time.time()) * 1000, |
| updated_by="updated" |
| ) |
| |
| def mock_table(self, identifier: Identifier, table_metadata: TableMetadata, path: str, |
| schema: Schema) -> GetTableResponse: |
| return GetTableResponse( |
| id=str(table_metadata.uuid), |
| name=identifier.get_object_name(), |
| path=path, |
| is_external=table_metadata.is_external, |
| schema_id=table_metadata.schema.id, |
| schema=schema, |
| owner="owner", |
| created_at=1, |
| created_by="created", |
| updated_at=1, |
| updated_by="updated" |
| ) |