| # 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. |
| """ |
| Metadata Extraction Tool |
| |
| Responsible for extracting table structures, relationships, and other metadata from the database. |
| """ |
| |
| from __future__ import annotations |
| |
| import json |
| import os |
| import re |
| import time |
| import uuid |
| from datetime import datetime, timedelta |
| from typing import TYPE_CHECKING, Any, Literal, cast, overload |
| |
| import pandas as pd |
| |
| # Import unified logging configuration |
| from .logger import get_logger |
| from .sql_security_utils import ( |
| SQLSecurityError, |
| quote_identifier, |
| validate_identifier, |
| validate_integer, |
| ) |
| |
| if TYPE_CHECKING: |
| from .db import DorisConnectionManager |
| from .security import AuthContext |
| |
| # Configure logging |
| logger = get_logger(__name__) |
| |
| METADATA_DB_NAME = "information_schema" |
| ENABLE_MULTI_DATABASE = ( |
| os.getenv("ENABLE_MULTI_DATABASE", "true").strip().lower() == "true" |
| ) |
| MULTI_DATABASE_NAMES = tuple( |
| name.strip() |
| for name in os.getenv("MULTI_DATABASE_NAMES", "").split(",") |
| if name.strip() |
| ) |
| |
| # Import local modules |
| |
| |
| class DorisOAuthMetadataError(RuntimeError): |
| """Structured metadata failure for Doris OAuth MCP tool responses.""" |
| |
| def __init__( |
| self, |
| message: str, |
| *, |
| error_code: str, |
| status_code: int, |
| ) -> None: |
| super().__init__(message) |
| self.error_code = error_code |
| self.status_code = status_code |
| |
| |
| class MetadataExtractor: |
| """Apache Doris Metadata Extractor""" |
| |
| def __init__( |
| self, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| connection_manager: DorisConnectionManager | None = None, |
| ) -> None: |
| """ |
| Initialize the metadata extractor |
| |
| Args: |
| db_name: Default database name, uses the currently connected database if not specified |
| catalog_name: Default catalog name for federation queries, uses the current catalog if not specified |
| connection_manager: DorisConnectionManager instance for database operations |
| """ |
| # Get configuration from environment variables |
| self.db_name = db_name or os.getenv("DB_DATABASE", "") |
| self.catalog_name = catalog_name # Store catalog name for federation support |
| self.metadata_db = METADATA_DB_NAME # Use constant |
| self.connection_manager = connection_manager |
| |
| # Caching system |
| self.metadata_cache: dict[str, object] = {} |
| self.metadata_cache_time: dict[str, datetime] = {} |
| self.cache_ttl = int( |
| os.getenv("METADATA_CACHE_TTL", "3600") |
| ) # Default cache 1 hour |
| |
| # Refresh time |
| self.last_refresh_time: datetime | None = None |
| |
| # Enable multi-database support - use variable imported from db.py |
| self.enable_multi_database = ENABLE_MULTI_DATABASE |
| |
| # Load table hierarchy matching configuration |
| self.enable_table_hierarchy = ( |
| os.getenv("ENABLE_TABLE_HIERARCHY", "false").lower() == "true" |
| ) |
| if self.enable_table_hierarchy: |
| self.table_hierarchy_patterns = self._load_table_hierarchy_patterns() |
| else: |
| self.table_hierarchy_patterns = [] |
| |
| # List of excluded system databases |
| self.excluded_databases = self._load_excluded_databases() |
| |
| # Session ID for database queries |
| self._session_id = f"metadata_extractor_{uuid.uuid4().hex[:8]}" |
| |
| def _current_auth_context(self) -> AuthContext | None: |
| try: |
| from .security import mcp_auth_context_var |
| |
| return mcp_auth_context_var.get() |
| except Exception: |
| return None |
| |
| def _is_doris_oauth_context( |
| self, |
| auth_context: AuthContext | None = None, |
| ) -> bool: |
| context = ( |
| auth_context if auth_context is not None else self._current_auth_context() |
| ) |
| return context is not None and context.auth_method == "doris_oauth" |
| |
| def _metadata_error_from_exception(self, exc: Exception) -> DorisOAuthMetadataError: |
| error_code = getattr(exc, "error_code", None) |
| status_code = getattr(exc, "status_code", None) |
| message = str(exc) or exc.__class__.__name__ |
| if error_code: |
| return DorisOAuthMetadataError( |
| message, |
| error_code=str(error_code), |
| status_code=int(status_code or 500), |
| ) |
| |
| mysql_error_code = None |
| if getattr(exc, "args", None): |
| try: |
| mysql_error_code = int(exc.args[0]) |
| except (TypeError, ValueError): |
| mysql_error_code = None |
| if mysql_error_code in {1044, 1045, 1049, 1142, 1227}: |
| return DorisOAuthMetadataError( |
| message, |
| error_code="DORIS_OAUTH_METADATA_PERMISSION_DENIED", |
| status_code=403, |
| ) |
| |
| lowered = message.lower() |
| if any( |
| marker in lowered |
| for marker in ( |
| "permission denied", |
| "access denied", |
| "not authorized", |
| "privilege", |
| ) |
| ): |
| return DorisOAuthMetadataError( |
| message, |
| error_code="DORIS_OAUTH_METADATA_PERMISSION_DENIED", |
| status_code=403, |
| ) |
| return DorisOAuthMetadataError( |
| message, |
| error_code="DORIS_OAUTH_METADATA_BACKEND_ERROR", |
| status_code=502, |
| ) |
| |
| def _reraise_if_doris_oauth_metadata_error(self, exc: Exception) -> None: |
| if isinstance(exc, DorisOAuthMetadataError): |
| raise exc |
| if self._is_doris_oauth_context(): |
| raise self._metadata_error_from_exception(exc) from exc |
| |
| def _doris_oauth_table_not_visible_error( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> DorisOAuthMetadataError: |
| qualified_name = ".".join( |
| part |
| for part in ( |
| catalog_name or self.catalog_name, |
| db_name or self.db_name, |
| table_name, |
| ) |
| if part |
| ) |
| return DorisOAuthMetadataError( |
| f"Doris OAuth metadata table is not visible or does not exist: {qualified_name or table_name}", |
| error_code="DORIS_OAUTH_METADATA_NOT_VISIBLE", |
| status_code=404, |
| ) |
| |
| def _raise_if_doris_oauth_table_not_visible( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> None: |
| if self._is_doris_oauth_context(): |
| raise self._doris_oauth_table_not_visible_error( |
| table_name, db_name, catalog_name |
| ) |
| |
| async def _ensure_table_visible_for_doris_oauth_metadata( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> None: |
| if not self._is_doris_oauth_context(): |
| return |
| |
| effective_db = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| query = """ |
| SELECT 1 AS TABLE_VISIBLE |
| FROM information_schema.tables |
| WHERE TABLE_SCHEMA = %s |
| AND TABLE_NAME = %s |
| LIMIT 1 |
| """ |
| result = await self._execute_query_with_catalog_async( |
| query, |
| effective_db, |
| effective_catalog, |
| params=(effective_db, table_name), |
| ) |
| if not result or not result[0]: |
| raise self._doris_oauth_table_not_visible_error( |
| table_name, effective_db, effective_catalog |
| ) |
| |
| def _load_excluded_databases(self) -> list[str]: |
| """ |
| Load the list of excluded databases configuration |
| |
| Returns: |
| List of excluded databases |
| """ |
| excluded_dbs_str = os.getenv( |
| "EXCLUDED_DATABASES", |
| '["information_schema", "mysql", "performance_schema", "sys", "doris_metadata"]', |
| ) |
| try: |
| excluded_dbs = json.loads(excluded_dbs_str) |
| if isinstance(excluded_dbs, list): |
| logger.info(f"Loaded excluded database list: {excluded_dbs}") |
| return excluded_dbs |
| else: |
| logger.warning( |
| "Excluded database list configuration is not in list format, using default value" |
| ) |
| except json.JSONDecodeError: |
| logger.warning( |
| "Error parsing excluded database list JSON, using default value" |
| ) |
| |
| # Default value |
| default_excluded_dbs = [ |
| "information_schema", |
| "mysql", |
| "performance_schema", |
| "sys", |
| "doris_metadata", |
| ] |
| return default_excluded_dbs |
| |
| def _load_table_hierarchy_patterns(self) -> list[str]: |
| """ |
| Load table hierarchy matching pattern configuration |
| |
| Returns: |
| List of table hierarchy matching regular expressions |
| """ |
| patterns_str = os.getenv( |
| "TABLE_HIERARCHY_PATTERNS", |
| '["^ads_.*$","^dim_.*$","^dws_.*$","^dwd_.*$","^ods_.*$","^tmp_.*$","^stg_.*$","^.*$"]', |
| ) |
| try: |
| patterns = json.loads(patterns_str) |
| if isinstance(patterns, list): |
| # Ensure all patterns are valid regular expressions |
| validated_patterns = [] |
| for pattern in patterns: |
| try: |
| re.compile(pattern) |
| validated_patterns.append(pattern) |
| except re.error: |
| logger.warning(f"Invalid regular expression pattern: {pattern}") |
| |
| logger.info( |
| f"Loaded table hierarchy matching patterns: {validated_patterns}" |
| ) |
| return validated_patterns |
| else: |
| logger.warning( |
| "Table hierarchy matching pattern configuration is not in list format, using default value" |
| ) |
| except json.JSONDecodeError: |
| logger.warning( |
| "Error parsing table hierarchy matching pattern JSON, using default value" |
| ) |
| |
| # Default value |
| default_patterns = [ |
| "^ads_.*$", |
| "^dim_.*$", |
| "^dws_.*$", |
| "^dwd_.*$", |
| "^ods_.*$", |
| "^.*$", |
| ] |
| return default_patterns |
| |
| def get_all_databases(self, catalog_name: str | None = None) -> list[str]: |
| """ |
| Get a list of all databases |
| |
| Args: |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| List of database names |
| """ |
| effective_catalog = catalog_name or self.catalog_name |
| cache_key = f"databases_{effective_catalog or 'default'}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(list[str], self.metadata_cache[cache_key]) |
| |
| try: |
| # Use information_schema.schemata table to get database list |
| query = """ |
| SELECT |
| SCHEMA_NAME |
| FROM |
| information_schema.schemata |
| WHERE |
| SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') |
| ORDER BY |
| SCHEMA_NAME |
| """ |
| |
| result = self._execute_query_with_catalog( |
| query, self.db_name, effective_catalog |
| ) |
| |
| if not result: |
| databases = [] |
| else: |
| databases = [db["SCHEMA_NAME"] for db in result] |
| logger.info( |
| f"Retrieved database list from catalog {effective_catalog or 'default'}: {databases}" |
| ) |
| |
| # Update cache |
| self.metadata_cache[cache_key] = databases |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return databases |
| except Exception as e: |
| logger.error(f"Error getting database list: {str(e)}") |
| return [] |
| |
| def get_all_target_databases(self) -> list[str]: |
| """ |
| Get all target databases |
| |
| If multi-database support is enabled, returns all databases from the configuration; |
| Otherwise, returns the current database |
| |
| Returns: |
| List of target databases |
| """ |
| if self.enable_multi_database: |
| # If configuration is empty, return current database and all databases in the system |
| if not MULTI_DATABASE_NAMES: |
| all_dbs = self.get_all_databases() |
| # Put the current database at the front |
| if self.db_name in all_dbs: |
| all_dbs.remove(self.db_name) |
| all_dbs = [self.db_name] + all_dbs |
| |
| # Filter out excluded databases |
| all_dbs = [db for db in all_dbs if db not in self.excluded_databases] |
| logger.info( |
| f"Multi-database list not configured, getting database list from system: {all_dbs}" |
| ) |
| return all_dbs |
| else: |
| # Ensure the current database is in the list and at the front |
| db_names = list( |
| MULTI_DATABASE_NAMES |
| ) # Copy to avoid modifying the original list |
| if self.db_name and self.db_name not in db_names: |
| db_names.insert(0, self.db_name) |
| elif self.db_name and self.db_name in db_names: |
| # If current database is in the list but not first, adjust position |
| db_names.remove(self.db_name) |
| db_names.insert(0, self.db_name) |
| |
| # Filter out excluded databases |
| db_names = [db for db in db_names if db not in self.excluded_databases] |
| logger.info(f"Using configured multi-database list: {db_names}") |
| return db_names |
| else: |
| # Return only the current database |
| if self.db_name in self.excluded_databases: |
| logger.warning( |
| f"Current database {self.db_name} is in the excluded list, metadata retrieval might not work properly" |
| ) |
| return [self.db_name] if self.db_name else [] |
| |
| def get_database_tables( |
| self, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[str]: |
| """ |
| Get a list of all tables in the database |
| |
| Args: |
| db_name: Database name, uses current database if None |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| List of table names |
| """ |
| db_name = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| if not db_name: |
| logger.warning("Database name not specified") |
| return [] |
| |
| try: |
| validate_identifier(db_name, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as exc: |
| logger.warning(f"Invalid identifier rejected: {exc}") |
| return [] |
| |
| cache_key = f"tables_{effective_catalog or 'default'}_{db_name}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(list[str], self.metadata_cache[cache_key]) |
| |
| try: |
| # Use information_schema.tables table to get table list |
| # SQL sink audit: deprecated sync path validates both identifiers |
| # before the fixed metadata query reaches its legacy executor. |
| query = f""" |
| SELECT |
| TABLE_NAME |
| FROM |
| information_schema.tables |
| WHERE |
| TABLE_SCHEMA = '{db_name}' |
| AND TABLE_TYPE = 'BASE TABLE' |
| """ # nosec B608 |
| |
| result = self._execute_query_with_catalog(query, db_name, effective_catalog) |
| logger.info( |
| "%s.%s.information_schema.tables query completed", |
| effective_catalog or "default", |
| db_name, |
| ) |
| |
| if not result: |
| tables = [] |
| else: |
| tables = [table["TABLE_NAME"] for table in result] |
| logger.info( |
| f"Table names retrieved from {effective_catalog or 'default'}.{db_name}.information_schema.tables: {tables}" |
| ) |
| |
| # Sort tables by hierarchy matching (if enabled) |
| if self.enable_table_hierarchy and tables: |
| tables = self._sort_tables_by_hierarchy(tables) |
| |
| # Update cache |
| self.metadata_cache[cache_key] = tables |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return tables |
| except Exception as e: |
| logger.error(f"Error getting table list: {str(e)}") |
| return [] |
| |
| async def get_all_tables_and_columns(self) -> dict[str, Any]: |
| """ |
| Get information for all tables and columns |
| |
| Returns: |
| Dict[str, Any]: Dictionary containing information for all tables and columns |
| """ |
| cache_key = f"all_tables_columns_{self.db_name}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(dict[str, Any], self.metadata_cache[cache_key]) |
| |
| try: |
| result = {} |
| tables = self.get_database_tables(self.db_name) |
| |
| for table_name in tables: |
| schema = await self.get_table_schema(table_name, self.db_name) |
| if schema: |
| columns = schema.get("columns", []) |
| column_names = [ |
| col.get("name") for col in columns if col.get("name") |
| ] |
| column_types = { |
| col.get("name"): col.get("type") |
| for col in columns |
| if col.get("name") and col.get("type") |
| } |
| column_comments = { |
| col.get("name"): col.get("comment") |
| for col in columns |
| if col.get("name") |
| } |
| |
| result[table_name] = { |
| "comment": schema.get("comment", ""), |
| "columns": column_names, |
| "column_types": column_types, |
| "column_comments": column_comments, |
| } |
| |
| # Update cache |
| self.metadata_cache[cache_key] = result |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return result |
| except Exception as e: |
| logger.error(f"Error getting all tables and columns information: {str(e)}") |
| return {} |
| |
| def _sort_tables_by_hierarchy(self, tables: list[str]) -> list[str]: |
| """ |
| Sort tables based on hierarchy matching patterns |
| |
| Args: |
| tables: List of table names |
| |
| Returns: |
| Sorted list of table names |
| """ |
| if not self.enable_table_hierarchy or not self.table_hierarchy_patterns: |
| return tables |
| |
| # Group tables by pattern priority |
| table_groups = [] |
| remaining_tables = set(tables) |
| |
| for pattern in self.table_hierarchy_patterns: |
| matching_tables = [] |
| regex = re.compile(pattern) |
| |
| for table in list(remaining_tables): |
| if regex.match(table): |
| matching_tables.append(table) |
| remaining_tables.remove(table) |
| |
| if matching_tables: |
| # Within each group, sort alphabetically |
| matching_tables.sort() |
| table_groups.append(matching_tables) |
| |
| # Add remaining tables to the end |
| if remaining_tables: |
| table_groups.append(sorted(remaining_tables)) |
| |
| # Flatten the groups |
| return [table for group in table_groups for table in group] |
| |
| def get_all_tables_from_all_databases(self) -> dict[str, list[str]]: |
| """ |
| Get all tables from all target databases |
| |
| Returns: |
| Mapping from database name to list of table names |
| """ |
| all_tables = {} |
| target_dbs = self.get_all_target_databases() |
| |
| for db_name in target_dbs: |
| tables = self.get_database_tables(db_name) |
| if tables: |
| all_tables[db_name] = tables |
| |
| return all_tables |
| |
| def find_tables_by_pattern( |
| self, pattern: str, db_name: str | None = None |
| ) -> list[tuple[str, str]]: |
| """ |
| Find matching tables in the database based on a pattern |
| |
| Args: |
| pattern: Table name pattern (regular expression) |
| db_name: Database name, searches all target databases if None |
| |
| Returns: |
| List of matching (database_name, table_name) tuples |
| """ |
| try: |
| regex = re.compile(pattern) |
| except re.error: |
| logger.error(f"Invalid regular expression pattern: {pattern}") |
| return [] |
| |
| matches = [] |
| |
| if db_name: |
| # Search only in the specified database |
| tables = self.get_database_tables(db_name) |
| matches = [(db_name, table) for table in tables if regex.match(table)] |
| else: |
| # Search in all target databases |
| all_tables = self.get_all_tables_from_all_databases() |
| |
| for db, tables in all_tables.items(): |
| db_matches = [(db, table) for table in tables if regex.match(table)] |
| matches.extend(db_matches) |
| |
| return matches |
| |
| async def get_table_schema( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """ |
| Get the schema information for a table |
| |
| Args: |
| table_name: Table name |
| db_name: Database name, uses current database if None |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| Table schema information, including column names, types, nullability, defaults, comments, etc. |
| """ |
| db_name = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| if not db_name: |
| logger.warning("Database name not specified") |
| return {} |
| |
| # SECURITY FIX: Validate identifiers to prevent SQL injection |
| try: |
| validate_identifier(table_name, "table name") |
| validate_identifier(db_name, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected in get_table_schema: {e}") |
| return {} |
| |
| cache_key = f"schema_{effective_catalog or 'default'}_{db_name}_{table_name}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(dict[str, Any], self.metadata_cache[cache_key]) |
| |
| try: |
| # Use information_schema.columns table to get table schema (async) |
| query = """ |
| SELECT |
| COLUMN_NAME, |
| DATA_TYPE, |
| IS_NULLABLE, |
| COLUMN_DEFAULT, |
| COLUMN_COMMENT, |
| ORDINAL_POSITION, |
| COLUMN_KEY, |
| EXTRA |
| FROM |
| information_schema.columns |
| WHERE |
| TABLE_SCHEMA = %s |
| AND TABLE_NAME = %s |
| ORDER BY |
| ORDINAL_POSITION |
| """ |
| |
| result = await self._execute_query_with_catalog_async( |
| query, |
| db_name, |
| effective_catalog, |
| params=(db_name, table_name), |
| ) |
| |
| if not result: |
| logger.warning( |
| f"Table {effective_catalog or 'default'}.{db_name}.{table_name} does not exist or has no columns" |
| ) |
| return {} |
| |
| # Create structured table schema information |
| columns = [] |
| for col in result: |
| column_info = { |
| "name": col.get("COLUMN_NAME", ""), |
| "type": col.get("DATA_TYPE", ""), |
| "nullable": col.get("IS_NULLABLE", "") == "YES", |
| "default": col.get("COLUMN_DEFAULT", ""), |
| "comment": col.get("COLUMN_COMMENT", "") or "", |
| "position": col.get("ORDINAL_POSITION", ""), |
| "key": col.get("COLUMN_KEY", "") or "", |
| "extra": col.get("EXTRA", "") or "", |
| } |
| columns.append(column_info) |
| |
| # Get table comment (async) |
| table_comment = await self.get_table_comment_async( |
| table_name, db_name, effective_catalog |
| ) |
| |
| # Build complete structure |
| schema = { |
| "name": table_name, |
| "database": db_name, |
| "comment": table_comment, |
| "columns": columns, |
| "create_time": datetime.now().isoformat(), |
| } |
| |
| # Get table type information (async) |
| try: |
| table_type_query = """ |
| SELECT |
| TABLE_TYPE, |
| ENGINE |
| FROM |
| information_schema.tables |
| WHERE |
| TABLE_SCHEMA = %s |
| AND TABLE_NAME = %s |
| """ |
| table_type_result = await self._execute_query_async( |
| table_type_query, |
| params=(db_name, table_name), |
| ) |
| if table_type_result: |
| schema["table_type"] = table_type_result[0].get("TABLE_TYPE", "") |
| schema["engine"] = table_type_result[0].get("ENGINE", "") |
| except Exception as e: |
| logger.warning(f"Error getting table type information: {str(e)}") |
| |
| # Update cache |
| self.metadata_cache[cache_key] = schema |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return schema |
| except Exception as e: |
| logger.error(f"Error getting table schema: {str(e)}") |
| return {} |
| |
| # Deprecated: sync method (kept for compatibility, will be removed) |
| def get_table_comment( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> str: |
| """ |
| Get the comment for a table |
| |
| Args: |
| table_name: Table name |
| db_name: Database name, uses current database if None |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| Table comment |
| """ |
| db_name = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| if not db_name: |
| logger.warning("Database name not specified") |
| return "" |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| validate_identifier(db_name, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return "" |
| |
| cache_key = ( |
| f"table_comment_{effective_catalog or 'default'}_{db_name}_{table_name}" |
| ) |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(str, self.metadata_cache[cache_key]) |
| |
| try: |
| # Use information_schema.tables table to get table comment |
| # SQL sink audit: deprecated sync path validates table, database |
| # and catalog identifiers before its legacy executor. |
| query = f""" |
| SELECT |
| TABLE_COMMENT |
| FROM |
| information_schema.tables |
| WHERE |
| TABLE_SCHEMA = '{db_name}' |
| AND TABLE_NAME = '{table_name}' |
| """ # nosec B608 |
| |
| result = self._execute_query_with_catalog(query, db_name, effective_catalog) |
| |
| if not result or not result[0]: |
| comment = "" |
| else: |
| comment = str(result[0].get("TABLE_COMMENT", "") or "") |
| |
| # Update cache |
| self.metadata_cache[cache_key] = comment |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return comment |
| except Exception as e: |
| logger.error(f"Error getting table comment: {str(e)}") |
| return "" |
| |
| # Deprecated: sync method (kept for compatibility, will be removed) |
| def get_column_comments( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, str]: |
| """ |
| Get comments for all columns in a table |
| |
| Args: |
| table_name: Table name |
| db_name: Database name, uses current database if None |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| Dictionary of column names and comments |
| """ |
| db_name = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| if not db_name: |
| logger.warning("Database name not specified") |
| return {} |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| validate_identifier(db_name, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return {} |
| |
| cache_key = ( |
| f"column_comments_{effective_catalog or 'default'}_{db_name}_{table_name}" |
| ) |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(dict[str, str], self.metadata_cache[cache_key]) |
| |
| try: |
| # Use information_schema.columns table to get column comments |
| # SQL sink audit: deprecated sync path validates table, database |
| # and catalog identifiers before its legacy executor. |
| query = f""" |
| SELECT |
| COLUMN_NAME, |
| COLUMN_COMMENT |
| FROM |
| information_schema.columns |
| WHERE |
| TABLE_SCHEMA = '{db_name}' |
| AND TABLE_NAME = '{table_name}' |
| ORDER BY |
| ORDINAL_POSITION |
| """ # nosec B608 |
| |
| result = self._execute_query_with_catalog(query, db_name, effective_catalog) |
| |
| comments: dict[str, str] = {} |
| for col in result: |
| column_name = str(col.get("COLUMN_NAME", "") or "") |
| column_comment = str(col.get("COLUMN_COMMENT", "") or "") |
| if column_name: |
| comments[column_name] = column_comment |
| |
| # Update cache |
| self.metadata_cache[cache_key] = comments |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return comments |
| except Exception as e: |
| logger.error(f"Error getting column comments: {str(e)}") |
| return {} |
| |
| # Deprecated: sync method (kept for compatibility, will be removed) |
| def get_table_indexes( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[dict[str, Any]]: |
| """ |
| Get the index information for a table |
| |
| Args: |
| table_name: Table name |
| db_name: Database name, uses the database specified during initialization if None |
| catalog_name: Catalog name for federation queries, uses instance catalog if None |
| |
| Returns: |
| List[Dict[str, Any]]: List of index information |
| """ |
| db_name = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| if not db_name: |
| logger.error("Database name not specified") |
| return [] |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| validate_identifier(db_name, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| cache_key = f"indexes_{effective_catalog or 'default'}_{db_name}_{table_name}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(list[dict[str, Any]], self.metadata_cache[cache_key]) |
| |
| try: |
| # Build query with catalog prefix if specified (identifiers already validated) |
| safe_table = quote_identifier(table_name, "table name") |
| safe_db = quote_identifier(db_name, "database name") |
| if effective_catalog: |
| safe_catalog = quote_identifier(effective_catalog, "catalog name") |
| _query = f"SHOW INDEX FROM {safe_catalog}.{safe_db}.{safe_table}" |
| logger.info("Using three-part naming for index query") |
| else: |
| _query = f"SHOW INDEX FROM {safe_db}.{safe_table}" |
| |
| try: |
| # NOTE: Deprecated sync path retained for compatibility; use async variant instead. |
| # Deprecated sync path removed; return empty indexes on failure |
| result: list[dict[str, Any]] = [] |
| indexes: list[dict[str, Any]] = [] |
| current_index: dict[str, Any] | None = None |
| if result: |
| for r in result: |
| try: |
| index_name = r.get("Key_name") |
| column_name = r.get("Column_name") |
| if ( |
| current_index is None |
| or current_index.get("name") != index_name |
| ): |
| if current_index is not None: |
| indexes.append(current_index) |
| current_index = { |
| "name": index_name, |
| "columns": [column_name] if column_name else [], |
| "unique": r.get("Non_unique", 1) == 0, |
| "type": r.get("Index_type", ""), |
| } |
| else: |
| if column_name: |
| current_index["columns"].append(column_name) |
| except Exception as row_error: |
| logger.warning( |
| f"Failed to process index row data: {row_error}" |
| ) |
| continue |
| if current_index is not None: |
| indexes.append(current_index) |
| except Exception as df_error: |
| logger.warning(f"Sync index query (deprecated) failed: {df_error}") |
| indexes = [] |
| |
| # Update cache |
| self.metadata_cache[cache_key] = indexes |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return indexes |
| except Exception as e: |
| logger.error(f"Error getting index information: {str(e)}") |
| return [] |
| |
| async def get_table_relationships(self) -> list[dict[str, Any]]: |
| """ |
| Infer table relationships from table comments and naming patterns |
| |
| Returns: |
| List[Dict[str, Any]]: List of table relationship information |
| """ |
| cache_key = f"relationships_{self.db_name}" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(list[dict[str, Any]], self.metadata_cache[cache_key]) |
| |
| try: |
| # Get all tables |
| tables = await self.get_database_tables_async(self.db_name) |
| relationships: list[dict[str, Any]] = [] |
| |
| # Simple foreign key naming convention detection |
| # Example: If a table has a column named xxx_id and another table named xxx exists, it might be a foreign key relationship |
| for table_name in tables: |
| schema = await self.get_table_schema(table_name, self.db_name) |
| columns = schema.get("columns", []) |
| |
| for column in columns: |
| column_name = column["name"] |
| if column_name.endswith("_id"): |
| # Possible foreign key table name |
| ref_table_name = column_name[:-3] # Remove _id suffix |
| |
| # Check if the possible table exists |
| if ref_table_name in tables: |
| # Find possible primary key column |
| ref_schema = await self.get_table_schema( |
| ref_table_name, self.db_name |
| ) |
| ref_columns = ref_schema.get("columns", []) |
| |
| # Assume primary key column name is id |
| if any(col["name"] == "id" for col in ref_columns): |
| relationships.append( |
| { |
| "table": table_name, |
| "column": column_name, |
| "references_table": ref_table_name, |
| "references_column": "id", |
| "relationship_type": "many-to-one", |
| "confidence": "medium", # Low confidence, based on naming convention |
| } |
| ) |
| |
| # Update cache |
| self.metadata_cache[cache_key] = relationships |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return relationships |
| except Exception as e: |
| logger.error(f"Error inferring table relationships: {str(e)}") |
| return [] |
| |
| # Deprecated: sync method (kept for compatibility, will be removed) |
| def get_recent_audit_logs(self, days: int = 7, limit: int = 100) -> pd.DataFrame: |
| """ |
| Get recent audit logs |
| |
| Args: |
| days: Get audit logs for the last N days |
| limit: Maximum number of records to return |
| |
| Returns: |
| pd.DataFrame: Audit log DataFrame |
| """ |
| try: |
| # Deprecated sync path removed; this method is deprecated overall |
| df = pd.DataFrame() |
| return df |
| except Exception as e: |
| logger.error(f"Error getting audit logs: {str(e)}") |
| return pd.DataFrame() |
| |
| async def get_catalog_list(self) -> list[dict[str, Any]]: |
| """ |
| Get a list of all catalogs in Doris with detailed information |
| |
| Returns: |
| List[Dict[str, Any]]: List of catalog information including CatalogId, CatalogName, Type, IsCurrent, CreateTime, LastUpdateTime, Comment |
| """ |
| cache_key = "catalogs" |
| if ( |
| cache_key in self.metadata_cache |
| and ( |
| datetime.now() - self.metadata_cache_time.get(cache_key, datetime.min) |
| ).total_seconds() |
| < self.cache_ttl |
| ): |
| return cast(list[dict[str, Any]], self.metadata_cache[cache_key]) |
| |
| try: |
| # Use SHOW CATALOGS command to get catalog list |
| query = "SHOW CATALOGS" |
| result = await self._execute_query_async(query) |
| |
| if not result: |
| catalogs: list[dict[str, Any]] = [] |
| else: |
| # Extract catalog information from the result |
| # SHOW CATALOGS returns: CatalogId, CatalogName, Type, IsCurrent, CreateTime, LastUpdateTime, Comment |
| catalogs = [] |
| for row in result: |
| if isinstance(row, dict): |
| catalog_info = { |
| "catalog_id": row.get("CatalogId", ""), |
| "catalog_name": row.get("CatalogName", ""), |
| "type": row.get("Type", ""), |
| "is_current": row.get("IsCurrent", ""), |
| "create_time": row.get("CreateTime", ""), |
| "last_update_time": row.get("LastUpdateTime", ""), |
| "comment": row.get("Comment", ""), |
| } |
| catalogs.append(catalog_info) |
| |
| logger.info(f"Retrieved catalog list: {catalogs}") |
| |
| # Update cache |
| self.metadata_cache[cache_key] = catalogs |
| self.metadata_cache_time[cache_key] = datetime.now() |
| |
| return catalogs |
| except Exception as e: |
| logger.error(f"Error getting catalog list: {str(e)}") |
| return [] |
| |
| def extract_sql_comments(self, sql: str) -> str: |
| """ |
| Extract comments from SQL |
| |
| Args: |
| sql: SQL query |
| |
| Returns: |
| str: Extracted comments |
| """ |
| # Extract single-line comments |
| single_line_comments = re.findall(r"--\s*(.*?)(?:\n|$)", sql) |
| |
| # Extract multi-line comments |
| multi_line_comments = re.findall(r"/\*(.*?)\*/", sql, re.DOTALL) |
| |
| # Merge all comments |
| all_comments = single_line_comments + multi_line_comments |
| return "\n".join(comment.strip() for comment in all_comments if comment.strip()) |
| |
| def extract_common_sql_patterns(self, limit: int = 50) -> list[dict[str, Any]]: |
| """ |
| Extract common SQL patterns |
| |
| Args: |
| limit: Maximum number of audit logs to retrieve |
| |
| Returns: |
| List[Dict[str, Any]]: List of SQL pattern information, including pattern, type, frequency, etc. |
| """ |
| try: |
| # Get audit logs |
| audit_logs = self.get_recent_audit_logs(days=30, limit=limit) |
| if audit_logs.empty: |
| # If audit logs cannot be retrieved, return some default patterns |
| default_patterns = [ |
| { |
| "pattern": "SELECT * FROM {table} WHERE {condition}", |
| "type": "SELECT", |
| "frequency": 1, |
| }, |
| { |
| "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", |
| "type": "SELECT", |
| "frequency": 1, |
| }, |
| ] |
| return default_patterns |
| |
| # Group and process by SQL type |
| patterns_by_type: dict[str, list[dict[str, Any]]] = {} |
| for _, row in audit_logs.iterrows(): |
| sql = row["stmt"] |
| if not sql: |
| continue |
| |
| # Determine SQL type |
| sql_type = self._get_sql_type(sql) |
| if not sql_type: |
| continue |
| |
| # Simplify SQL |
| simplified_sql = self._simplify_sql(sql) |
| |
| # Extract involved tables |
| tables = self._extract_tables_from_sql(sql) |
| |
| # Extract SQL comments |
| comments = self.extract_sql_comments(sql) |
| |
| # Initialize if it's a new pattern |
| if sql_type not in patterns_by_type: |
| patterns_by_type[sql_type] = [] |
| |
| # Check if a similar pattern exists |
| found_similar = False |
| for pattern in patterns_by_type[sql_type]: |
| if self._are_sqls_similar( |
| simplified_sql, pattern["simplified_sql"] |
| ): |
| pattern["count"] += 1 |
| pattern["examples"].append(sql) |
| if comments: |
| pattern["comments"].append(comments) |
| found_similar = True |
| break |
| |
| # If no similar pattern found, add new pattern |
| if not found_similar: |
| patterns_by_type[sql_type].append( |
| { |
| "simplified_sql": simplified_sql, |
| "examples": [sql], |
| "comments": [comments] if comments else [], |
| "count": 1, |
| "tables": tables, |
| } |
| ) |
| |
| # Convert grouped patterns to the required output format |
| result_patterns: list[dict[str, Any]] = [] |
| |
| # Sort by frequency and convert format |
| for sql_type, type_patterns in patterns_by_type.items(): |
| sorted_patterns = sorted( |
| type_patterns, key=lambda x: x["count"], reverse=True |
| ) |
| |
| # Extract top 3 patterns and convert to expected format |
| for pattern in sorted_patterns[:3]: |
| # Create output consistent with the format used in _update_sql_patterns_for_all_databases |
| result_patterns.append( |
| { |
| "pattern": pattern["simplified_sql"], |
| "type": sql_type, |
| "frequency": pattern["count"], |
| "examples": json.dumps( |
| pattern["examples"][:3], ensure_ascii=False |
| ), |
| "comments": json.dumps( |
| pattern["comments"][:3], ensure_ascii=False |
| ) |
| if pattern["comments"] |
| else "[]", |
| "tables": json.dumps(pattern["tables"], ensure_ascii=False), |
| } |
| ) |
| |
| # If no patterns found, return default values |
| if not result_patterns: |
| default_patterns = [ |
| { |
| "pattern": "SELECT * FROM {table} WHERE {condition}", |
| "type": "SELECT", |
| "frequency": 1, |
| "examples": "[]", |
| "comments": "[]", |
| "tables": "[]", |
| }, |
| { |
| "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", |
| "type": "SELECT", |
| "frequency": 1, |
| "examples": "[]", |
| "comments": "[]", |
| "tables": "[]", |
| }, |
| ] |
| return default_patterns |
| |
| return result_patterns |
| |
| except Exception as e: |
| logger.error(f"Error extracting SQL patterns: {str(e)}") |
| # Return some default patterns to ensure subsequent processing doesn't fail |
| default_patterns = [ |
| { |
| "pattern": "SELECT * FROM {table} WHERE {condition}", |
| "type": "SELECT", |
| "frequency": 1, |
| "examples": "[]", |
| "comments": "[]", |
| "tables": "[]", |
| }, |
| { |
| "pattern": "SELECT {columns} FROM {table} GROUP BY {group_by} ORDER BY {order_by} LIMIT {limit}", |
| "type": "SELECT", |
| "frequency": 1, |
| "examples": "[]", |
| "comments": "[]", |
| "tables": "[]", |
| }, |
| ] |
| return default_patterns |
| |
| def _simplify_sql(self, sql: str) -> str: |
| """ |
| Simplify SQL for better pattern recognition |
| |
| Args: |
| sql: SQL query |
| |
| Returns: |
| str: Simplified SQL |
| """ |
| # Remove comments |
| sql = re.sub(r"--.*?(\n|$)", " ", sql) |
| sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) |
| |
| # Replace string and numeric constants |
| sql = re.sub(r"'[^']*'", "'?'", sql) |
| sql = re.sub(r"\b\d+\b", "?", sql) |
| |
| # Replace contents of IN clauses |
| sql = re.sub(r"IN\s*\([^)]+\)", "IN (?)", sql, flags=re.IGNORECASE) |
| |
| # Remove excess whitespace |
| sql = re.sub(r"\s+", " ", sql).strip() |
| |
| return sql |
| |
| def _get_sql_type(self, sql: str) -> str: |
| """Return the leading SQL keyword after comments are removed.""" |
| without_comments = re.sub(r"--.*?(\n|$)", " ", sql) |
| without_comments = re.sub( |
| r"/\*.*?\*/", |
| " ", |
| without_comments, |
| flags=re.DOTALL, |
| ) |
| match = re.search(r"\b([A-Za-z]+)\b", without_comments) |
| return match.group(1).upper() if match else "" |
| |
| def _are_sqls_similar(self, first_sql: str, second_sql: str) -> bool: |
| """Compare normalized SQL patterns without case or whitespace noise.""" |
| |
| def normalize(value: str) -> str: |
| return re.sub(r"\s+", " ", value).strip().upper() |
| |
| return normalize(first_sql) == normalize(second_sql) |
| |
| def _extract_tables_from_sql(self, sql: str) -> list[str]: |
| """ |
| Extract table names from SQL |
| |
| Args: |
| sql: SQL query |
| |
| Returns: |
| List[str]: List of table names |
| """ |
| # This is a very simplified implementation |
| # Real applications require more complex SQL parsing |
| tables = set() |
| |
| # Find table names after FROM clause |
| from_matches = re.finditer(r"\bFROM\s+`?(\w+)`?", sql, re.IGNORECASE) |
| for match in from_matches: |
| tables.add(match.group(1)) |
| |
| # Find table names after JOIN clause |
| join_matches = re.finditer(r"\bJOIN\s+`?(\w+)`?", sql, re.IGNORECASE) |
| for match in join_matches: |
| tables.add(match.group(1)) |
| |
| # Find table names after INSERT INTO |
| insert_matches = re.finditer(r"\bINSERT\s+INTO\s+`?(\w+)`?", sql, re.IGNORECASE) |
| for match in insert_matches: |
| tables.add(match.group(1)) |
| |
| # Find table names after UPDATE |
| update_matches = re.finditer(r"\bUPDATE\s+`?(\w+)`?", sql, re.IGNORECASE) |
| for match in update_matches: |
| tables.add(match.group(1)) |
| |
| # Find table names after DELETE FROM |
| delete_matches = re.finditer(r"\bDELETE\s+FROM\s+`?(\w+)`?", sql, re.IGNORECASE) |
| for match in delete_matches: |
| tables.add(match.group(1)) |
| |
| return list(tables) |
| |
| def get_table_partition_info(self, db_name: str, table_name: str) -> dict[str, Any]: |
| """ |
| Get partition information for a table |
| |
| Args: |
| db_name: Database name |
| table_name: Table name |
| |
| Returns: |
| Dict: Partition information |
| """ |
| try: |
| # Deprecated sync path removed |
| partitions: list[dict[str, Any]] = [] |
| |
| if not partitions: |
| return {} |
| |
| partition_info: dict[str, Any] = {"has_partitions": True, "partitions": []} |
| |
| for part in partitions: |
| partition_info["partitions"].append( |
| { |
| "name": part.get("PARTITION_NAME", ""), |
| "expression": part.get("PARTITION_EXPRESSION", ""), |
| "description": part.get("PARTITION_DESCRIPTION", ""), |
| "rows": part.get("TABLE_ROWS", 0), |
| } |
| ) |
| |
| return partition_info |
| except Exception as e: |
| logger.error( |
| f"Error getting partition information for table {db_name}.{table_name}: {str(e)}" |
| ) |
| return {} |
| |
| def _execute_query_with_catalog( |
| self, |
| query: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[dict[str, Any]]: |
| """Reject the removed synchronous query path with an explicit error.""" |
| raise RuntimeError( |
| "Synchronous metadata queries are not supported; use the async API" |
| ) |
| |
| async def _execute_query_with_catalog_async( |
| self, |
| query: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| params: tuple[Any, ...] | None = None, |
| ) -> list[dict[str, Any]]: |
| """ |
| Async version of _execute_query_with_catalog to avoid cross-event-loop issues. |
| |
| When catalog_name is provided and the SQL targets information_schema, we rewrite |
| the SQL to use three-part naming: `{catalog}.information_schema` and execute it |
| via the same running event loop. |
| """ |
| try: |
| if catalog_name and "information_schema" in query.lower(): |
| safe_catalog = quote_identifier(catalog_name, "catalog name") |
| modified_query = query.replace( |
| "information_schema", |
| f"{safe_catalog}.information_schema", |
| ) |
| logger.info( |
| "Prepared catalog-qualified query for %s", |
| catalog_name, |
| ) |
| return await self._execute_query_async( |
| modified_query, |
| db_name, |
| params=params, |
| ) |
| else: |
| return await self._execute_query_async(query, db_name, params=params) |
| except Exception as e: |
| logger.error(f"Error executing async query with catalog: {str(e)}") |
| raise |
| |
| @overload |
| async def _execute_query_async( |
| self, |
| query: str, |
| db_name: str | None = None, |
| return_dataframe: Literal[False] = False, |
| params: tuple[Any, ...] | None = None, |
| ) -> list[dict[str, Any]]: ... |
| |
| @overload |
| async def _execute_query_async( |
| self, |
| query: str, |
| db_name: str | None = None, |
| return_dataframe: Literal[True] = True, |
| params: tuple[Any, ...] | None = None, |
| ) -> pd.DataFrame: ... |
| |
| async def _execute_query_async( |
| self, |
| query: str, |
| db_name: str | None = None, |
| return_dataframe: bool = False, |
| params: tuple[Any, ...] | None = None, |
| ) -> list[dict[str, Any]] | pd.DataFrame: |
| """ |
| Execute database query asynchronously |
| |
| Args: |
| query: SQL query to execute |
| db_name: Database name to use (optional) |
| return_dataframe: Whether to return a pandas DataFrame instead of list |
| |
| Returns: |
| Query result data (list of dictionaries or pandas DataFrame) |
| """ |
| auth_context = self._current_auth_context() |
| try: |
| if self.connection_manager: |
| # Use the injected connection manager directly (async) |
| result = await self.connection_manager.execute_query( |
| self._session_id, |
| query, |
| params, |
| auth_context, |
| ) |
| |
| data = result.data |
| |
| # Convert to DataFrame if requested |
| if return_dataframe and data: |
| import pandas as pd |
| |
| return pd.DataFrame(data) |
| elif return_dataframe: |
| import pandas as pd |
| |
| return pd.DataFrame() |
| else: |
| return data |
| else: |
| if self._is_doris_oauth_context(auth_context): |
| raise DorisOAuthMetadataError( |
| "Doris OAuth metadata connection manager is unavailable", |
| error_code="DORIS_OAUTH_POOL_MISSING", |
| status_code=401, |
| ) |
| # Fallback: Return empty result |
| logger.warning("No connection manager provided, returning empty result") |
| if return_dataframe: |
| import pandas as pd |
| |
| return pd.DataFrame() |
| else: |
| return [] |
| except Exception as e: |
| logger.error(f"Error executing query: {str(e)}") |
| if self._is_doris_oauth_context(auth_context): |
| raise self._metadata_error_from_exception(e) from e |
| # Return empty result instead of raising exception to prevent cascade failures |
| if return_dataframe: |
| import pandas as pd |
| |
| return pd.DataFrame() |
| else: |
| return [] |
| |
| # Removed sync _execute_query; use async methods exclusively |
| |
| async def get_table_schema_async( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[dict[str, Any]]: |
| """Asynchronously get table schema information""" |
| try: |
| # Use async query method |
| effective_catalog = catalog_name or self.catalog_name |
| effective_db = db_name or self.db_name |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| if effective_db: |
| validate_identifier(effective_db, "database name") |
| if effective_catalog and effective_catalog != "internal": |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| # Build query statement using safe identifiers |
| safe_table = quote_identifier(table_name, "table name") |
| safe_db = ( |
| quote_identifier(effective_db, "database name") |
| if effective_db |
| else None |
| ) |
| |
| if effective_catalog and effective_catalog != "internal": |
| safe_catalog = quote_identifier(effective_catalog, "catalog name") |
| query = ( |
| "SHOW FULL COLUMNS FROM " |
| f"{safe_catalog}.{safe_db}.{safe_table}" |
| ) |
| else: |
| query = f"SHOW FULL COLUMNS FROM {safe_db}.{safe_table}" |
| |
| # Execute async query |
| result = await self._execute_query_async(query, db_name) |
| |
| if not result: |
| self._raise_if_doris_oauth_table_not_visible( |
| table_name, effective_db, effective_catalog |
| ) |
| return [] |
| |
| # Process results |
| schema = [] |
| for row in result: |
| if isinstance(row, dict): |
| schema.append( |
| { |
| "column_name": row.get("Field", ""), |
| "data_type": row.get("Type", ""), |
| "is_nullable": row.get("Null", "NO") == "YES", |
| "default_value": row.get("Default", None), |
| "comment": row.get("Comment", ""), |
| "key": row.get("Key", ""), |
| "extra": row.get("Extra", ""), |
| } |
| ) |
| |
| return schema |
| |
| except Exception as e: |
| logger.error(f"Failed to get table schema: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return [] |
| |
| async def get_all_databases_async( |
| self, |
| catalog_name: str | None = None, |
| ) -> list[str]: |
| """Asynchronously get all database list""" |
| try: |
| effective_catalog = catalog_name or self.catalog_name |
| |
| # SECURITY FIX: Validate catalog name if provided |
| if effective_catalog and effective_catalog != "internal": |
| try: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid catalog name rejected: {e}") |
| return [] |
| safe_catalog = quote_identifier(effective_catalog, "catalog name") |
| query = f"SHOW DATABASES FROM {safe_catalog}" |
| else: |
| query = "SHOW DATABASES" |
| |
| result = await self._execute_query_async(query) |
| |
| if not result: |
| return [] |
| |
| # Extract database names |
| databases = [] |
| for row in result: |
| if isinstance(row, dict): |
| # Get the value of the first field (usually Database field) |
| db_name = list(row.values())[0] if row else None |
| if db_name: |
| databases.append(db_name) |
| |
| return databases |
| |
| except Exception as e: |
| logger.error(f"Failed to get database list: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return [] |
| |
| async def get_database_tables_async( |
| self, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[str]: |
| """Asynchronously get table list in database""" |
| try: |
| effective_catalog = catalog_name or self.catalog_name |
| effective_db = db_name or self.db_name |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| if effective_db: |
| validate_identifier(effective_db, "database name") |
| if effective_catalog and effective_catalog != "internal": |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| safe_db = ( |
| quote_identifier(effective_db, "database name") |
| if effective_db |
| else None |
| ) |
| |
| if effective_catalog and effective_catalog != "internal": |
| safe_catalog = quote_identifier(effective_catalog, "catalog name") |
| query = f"SHOW TABLES FROM {safe_catalog}.{safe_db}" |
| else: |
| query = f"SHOW TABLES FROM {safe_db}" |
| |
| result = await self._execute_query_async(query, effective_db) |
| |
| if not result: |
| return [] |
| |
| # Extract table names |
| tables = [] |
| for row in result: |
| if isinstance(row, dict): |
| # Get the value of the first field (usually Tables_in_xxx field) |
| table_name = list(row.values())[0] if row else None |
| if table_name: |
| tables.append(table_name) |
| |
| return tables |
| |
| except Exception as e: |
| logger.error(f"Failed to get table list: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return [] |
| |
| async def get_catalog_list_async(self) -> list[str]: |
| """Asynchronously get catalog list""" |
| try: |
| query = "SHOW CATALOGS" |
| result = await self._execute_query_async(query) |
| |
| if not result: |
| return [] |
| |
| # Extract catalog names |
| catalogs = [] |
| for row in result: |
| if isinstance(row, dict): |
| # SHOW CATALOGS returns fields including: CatalogId, CatalogName, Type, IsCurrent, CreateTime, LastUpdateTime, Comment |
| # We need to get the CatalogName field (second field) |
| if "CatalogName" in row: |
| catalog_name = row["CatalogName"] |
| else: |
| # If no CatalogName field, try to get the second field |
| values = list(row.values()) |
| catalog_name = ( |
| values[1] |
| if len(values) > 1 |
| else values[0] |
| if values |
| else None |
| ) |
| |
| if catalog_name: |
| catalogs.append(str(catalog_name)) |
| |
| return catalogs |
| |
| except Exception as e: |
| logger.error(f"Failed to get catalog list: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return [] |
| |
| async def get_table_comment_async( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> str: |
| """Async version: get the comment for a table.""" |
| try: |
| effective_db = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| if effective_db: |
| validate_identifier(effective_db, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return "" |
| |
| query = """ |
| SELECT |
| TABLE_COMMENT |
| FROM |
| information_schema.tables |
| WHERE |
| TABLE_SCHEMA = %s |
| AND TABLE_NAME = %s |
| """ |
| |
| result = await self._execute_query_with_catalog_async( |
| query, |
| effective_db, |
| effective_catalog, |
| params=(effective_db, table_name), |
| ) |
| if not result or not result[0]: |
| self._raise_if_doris_oauth_table_not_visible( |
| table_name, effective_db, effective_catalog |
| ) |
| return "" |
| return result[0].get("TABLE_COMMENT", "") or "" |
| except Exception as e: |
| logger.error(f"Failed to get table comment asynchronously: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return "" |
| |
| async def get_column_comments_async( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, str]: |
| """Async version: get comments for all columns in a table.""" |
| try: |
| effective_db = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| if effective_db: |
| validate_identifier(effective_db, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return {} |
| |
| query = """ |
| SELECT |
| COLUMN_NAME, |
| COLUMN_COMMENT |
| FROM |
| information_schema.columns |
| WHERE |
| TABLE_SCHEMA = %s |
| AND TABLE_NAME = %s |
| ORDER BY |
| ORDINAL_POSITION |
| """ |
| |
| rows = await self._execute_query_with_catalog_async( |
| query, |
| effective_db, |
| effective_catalog, |
| params=(effective_db, table_name), |
| ) |
| if not rows: |
| await self._ensure_table_visible_for_doris_oauth_metadata( |
| table_name, |
| effective_db, |
| effective_catalog, |
| ) |
| return {} |
| comments: dict[str, str] = {} |
| for col in rows: |
| name = col.get("COLUMN_NAME", "") |
| if name: |
| comments[name] = col.get("COLUMN_COMMENT", "") or "" |
| return comments |
| except Exception as e: |
| logger.error(f"Failed to get column comments asynchronously: {e}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return {} |
| |
| async def get_table_indexes_async( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> list[dict[str, Any]]: |
| """Async version: get index information for a table.""" |
| try: |
| effective_db = db_name or self.db_name |
| effective_catalog = catalog_name or self.catalog_name |
| |
| # SECURITY FIX: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| if effective_db: |
| validate_identifier(effective_db, "database name") |
| if effective_catalog: |
| validate_identifier(effective_catalog, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid identifier rejected: {e}") |
| return [] |
| |
| # Build query with catalog prefix if specified (using safe identifiers) |
| safe_table = quote_identifier(table_name, "table name") |
| safe_db = ( |
| quote_identifier(effective_db, "database name") |
| if effective_db |
| else None |
| ) |
| |
| if effective_catalog: |
| safe_catalog = quote_identifier(effective_catalog, "catalog name") |
| query = f"SHOW INDEX FROM {safe_catalog}.{safe_db}.{safe_table}" |
| logger.info("Using three-part naming for async index query") |
| else: |
| query = f"SHOW INDEX FROM {safe_db}.{safe_table}" |
| |
| rows = await self._execute_query_async(query, effective_db) |
| indexes: list[dict[str, Any]] = [] |
| if rows: |
| # Group by Key_name |
| current_index: dict[str, Any] | None = None |
| for r in rows: |
| try: |
| index_name = r.get("Key_name") |
| column_name = r.get("Column_name") |
| if ( |
| current_index is None |
| or current_index.get("name") != index_name |
| ): |
| if current_index is not None: |
| indexes.append(current_index) |
| current_index = { |
| "name": index_name, |
| "columns": [column_name] if column_name else [], |
| "unique": r.get("Non_unique", 1) == 0, |
| "type": r.get("Index_type", ""), |
| } |
| else: |
| if column_name: |
| current_index["columns"].append(column_name) |
| except Exception as row_error: |
| logger.warning( |
| f"Failed to process async index row data: {row_error}" |
| ) |
| continue |
| if current_index is not None: |
| indexes.append(current_index) |
| else: |
| await self._ensure_table_visible_for_doris_oauth_metadata( |
| table_name, |
| effective_db, |
| effective_catalog, |
| ) |
| |
| return indexes |
| except Exception as e: |
| logger.error(f"Error getting index information asynchronously: {str(e)}") |
| self._reraise_if_doris_oauth_metadata_error(e) |
| return [] |
| |
| async def get_recent_audit_logs_async( |
| self, |
| days: int = 7, |
| limit: int = 100, |
| ) -> pd.DataFrame: |
| """Async version: get recent audit logs and return a pandas DataFrame.""" |
| try: |
| safe_days = validate_integer(days, "days", minimum=1, maximum=3650) |
| safe_limit = validate_integer(limit, "limit", minimum=1, maximum=10000) |
| start_date = datetime.now() - timedelta(days=safe_days) |
| query = """ |
| SELECT client_ip, user, db, time, stmt_id, stmt, state, error_code |
| FROM `__internal_schema`.`audit_log` |
| WHERE `time` >= %s |
| AND state = 'EOF' AND error_code = 0 |
| AND `stmt` NOT LIKE 'SHOW%' |
| AND `stmt` NOT LIKE 'DESC%' |
| AND `stmt` NOT LIKE 'EXPLAIN%' |
| AND `stmt` NOT LIKE 'SELECT 1%' |
| ORDER BY time DESC |
| LIMIT %s |
| """ |
| rows = await self._execute_query_async( |
| query, |
| params=(start_date, safe_limit), |
| ) |
| import pandas as pd |
| |
| return pd.DataFrame(rows or []) |
| except Exception as e: |
| logger.error(f"Error getting audit logs asynchronously: {str(e)}") |
| import pandas as pd |
| |
| return pd.DataFrame() |
| |
| # ==================== Business layer methods (original metadata_tools.py functionality) ==================== |
| |
| def _format_response( |
| self, |
| success: bool, |
| result: Any = None, |
| error: str | None = None, |
| message: str = "", |
| error_code: str | None = None, |
| status_code: int | None = None, |
| ) -> dict[str, Any]: |
| """Format response result""" |
| response_data = { |
| "success": success, |
| "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), |
| } |
| if success and result is not None: |
| response_data["result"] = result |
| response_data["message"] = message or "Operation successful" |
| elif not success: |
| response_data["error"] = error or "Unknown error" |
| response_data["message"] = message or "Operation failed" |
| if error_code: |
| response_data["error_code"] = error_code |
| if status_code: |
| response_data["status_code"] = status_code |
| |
| return response_data |
| |
| def _format_metadata_error_response( |
| self, exc: Exception, message: str |
| ) -> dict[str, Any]: |
| return self._format_response( |
| success=False, |
| error=str(exc), |
| message=message, |
| error_code=getattr(exc, "error_code", None), |
| status_code=getattr(exc, "status_code", None), |
| ) |
| |
| async def exec_query_for_mcp( |
| self, |
| sql: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| max_rows: int = 100, |
| timeout: int = 30, |
| *, |
| max_bytes: int | None = None, |
| ) -> dict[str, Any]: |
| """ |
| Execute SQL query and return results, supports catalog federation queries |
| Unified interface for MCP tools |
| |
| FIX for Issue #62 Bug 1: Now retrieves auth_context from context variable to support token-bound database configuration |
| FIX for Issue #62 Bug 3: Now uses db_name and catalog_name parameters to switch database context |
| """ |
| logger.info( |
| "Executing SQL query for DB=%s, catalog=%s, max_rows=%s, timeout=%s", |
| db_name, |
| catalog_name, |
| max_rows, |
| timeout, |
| ) |
| |
| try: |
| if not sql: |
| return self._format_response( |
| success=False, |
| error="No SQL statement provided", |
| message="Please provide SQL statement to execute", |
| ) |
| |
| # SECURITY FIX: Validate catalog_name and db_name to prevent SQL injection. |
| # The query executor performs context switching and the target SQL on |
| # the same routed connection when either context parameter is supplied. |
| if catalog_name: |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid catalog name rejected: {e}") |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError as e: |
| logger.warning(f"Invalid database name rejected: {e}") |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| # FIX: Try to get auth_context from context variable (set by HTTP middleware) |
| # This allows token-bound database configuration to work |
| # CRITICAL: Use the global ContextVar from security.py to ensure same instance is used everywhere |
| auth_context = None |
| try: |
| from .security import mcp_auth_context_var |
| |
| # Get auth_context from the global context variable |
| # This will be set by the HTTP request handler in main.py |
| auth_context = mcp_auth_context_var.get() |
| |
| if auth_context: |
| logger.debug( |
| f"Retrieved auth_context from context variable with token: {bool(hasattr(auth_context, 'token') and auth_context.token)}" |
| ) |
| else: |
| logger.debug( |
| "No auth_context found in context variable, using default" |
| ) |
| except Exception as ctx_error: |
| logger.debug( |
| f"Could not retrieve auth_context from context variable: {ctx_error}" |
| ) |
| auth_context = None |
| |
| if self.connection_manager is None: |
| if self._is_doris_oauth_context(auth_context): |
| raise DorisOAuthMetadataError( |
| "Doris OAuth metadata connection manager is unavailable", |
| error_code="DORIS_OAUTH_POOL_MISSING", |
| status_code=401, |
| ) |
| raise RuntimeError("Database connection manager is unavailable") |
| |
| # Import query executor |
| from .query_executor import execute_sql_query |
| |
| # Call execute_sql_query to execute query with auth_context |
| exec_result = await execute_sql_query( |
| sql=sql, |
| connection_manager=self.connection_manager, |
| limit=max_rows, |
| max_bytes=max_bytes, |
| timeout=timeout, |
| db_name=db_name, |
| catalog_name=catalog_name, |
| auth_context=auth_context, # FIX: Pass auth_context with token |
| ) |
| |
| return exec_result |
| |
| except Exception as e: |
| logger.error(f"Failed to execute SQL query: {str(e)}", exc_info=True) |
| return self._format_response( |
| success=False, |
| error=str(e), |
| message="Error occurred while executing SQL query", |
| ) |
| |
| async def get_table_schema_for_mcp( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get detailed schema information for specified table (columns, types, comments, etc.) - MCP interface""" |
| logger.info( |
| f"Getting table schema: Table: {table_name}, DB: {db_name}, Catalog: {catalog_name}" |
| ) |
| |
| if not table_name: |
| return self._format_response( |
| success=False, error="Missing table_name parameter" |
| ) |
| |
| # SECURITY: Validate identifiers before processing |
| try: |
| validate_identifier(table_name, "table name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid table name: {table_name}", |
| message="Table name contains invalid characters", |
| ) |
| |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| if catalog_name and catalog_name != "internal": |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| try: |
| schema = await self.get_table_schema_async( |
| table_name=table_name, db_name=db_name, catalog_name=catalog_name |
| ) |
| |
| if not schema: |
| return self._format_response( |
| success=False, |
| error="Table does not exist or has no columns", |
| message=f"Unable to get schema for table {catalog_name or 'default'}.{db_name or self.db_name}.{table_name}", |
| ) |
| |
| return self._format_response(success=True, result=schema) |
| except Exception as e: |
| logger.error(f"Failed to get table schema: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting table schema" |
| ) |
| |
| async def get_db_table_list_for_mcp( |
| self, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get list of all table names in specified database - MCP interface""" |
| logger.info( |
| f"Getting database table list: DB: {db_name}, Catalog: {catalog_name}" |
| ) |
| |
| # SECURITY: Validate identifiers |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| if catalog_name and catalog_name != "internal": |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| try: |
| tables = await self.get_database_tables_async( |
| db_name=db_name, catalog_name=catalog_name |
| ) |
| return self._format_response(success=True, result=tables) |
| except Exception as e: |
| logger.error(f"Failed to get database table list: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting database table list" |
| ) |
| |
| async def get_db_list_for_mcp( |
| self, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get list of all database names on server - MCP interface""" |
| logger.info(f"Getting database list: Catalog: {catalog_name}") |
| |
| try: |
| databases = await self.get_all_databases_async(catalog_name=catalog_name) |
| return self._format_response(success=True, result=databases) |
| except Exception as e: |
| logger.error(f"Failed to get database list: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting database list" |
| ) |
| |
| async def get_table_comment_for_mcp( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get comment information for specified table - MCP interface""" |
| logger.info( |
| f"Getting table comment: Table: {table_name}, DB: {db_name}, Catalog: {catalog_name}" |
| ) |
| |
| if not table_name: |
| return self._format_response( |
| success=False, error="Missing table_name parameter" |
| ) |
| |
| # SECURITY: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid table name: {table_name}", |
| message="Table name contains invalid characters", |
| ) |
| |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| if catalog_name and catalog_name != "internal": |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| try: |
| comment = await self.get_table_comment_async( |
| table_name=table_name, db_name=db_name, catalog_name=catalog_name |
| ) |
| return self._format_response(success=True, result=comment) |
| except Exception as e: |
| logger.error(f"Failed to get table comment: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting table comment" |
| ) |
| |
| async def get_table_column_comments_for_mcp( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get comment information for all columns in specified table - MCP interface""" |
| logger.info( |
| f"Getting table column comments: Table: {table_name}, DB: {db_name}, Catalog: {catalog_name}" |
| ) |
| |
| if not table_name: |
| return self._format_response( |
| success=False, error="Missing table_name parameter" |
| ) |
| |
| # SECURITY: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid table name: {table_name}", |
| message="Table name contains invalid characters", |
| ) |
| |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| if catalog_name and catalog_name != "internal": |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| try: |
| comments = await self.get_column_comments_async( |
| table_name=table_name, db_name=db_name, catalog_name=catalog_name |
| ) |
| return self._format_response(success=True, result=comments) |
| except Exception as e: |
| logger.error( |
| f"Failed to get table column comments: {str(e)}", exc_info=True |
| ) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting table column comments" |
| ) |
| |
| async def get_table_indexes_for_mcp( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get index information for specified table - MCP interface""" |
| logger.info( |
| f"Getting table indexes: Table: {table_name}, DB: {db_name}, Catalog: {catalog_name}" |
| ) |
| |
| if not table_name: |
| return self._format_response( |
| success=False, error="Missing table_name parameter" |
| ) |
| |
| # SECURITY: Validate identifiers |
| try: |
| validate_identifier(table_name, "table name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid table name: {table_name}", |
| message="Table name contains invalid characters", |
| ) |
| |
| if db_name: |
| try: |
| validate_identifier(db_name, "database name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid database name: {db_name}", |
| message="Database name contains invalid characters", |
| ) |
| |
| if catalog_name and catalog_name != "internal": |
| try: |
| validate_identifier(catalog_name, "catalog name") |
| except SQLSecurityError: |
| return self._format_response( |
| success=False, |
| error=f"Invalid catalog name: {catalog_name}", |
| message="Catalog name contains invalid characters", |
| ) |
| |
| try: |
| indexes = await self.get_table_indexes_async( |
| table_name=table_name, db_name=db_name, catalog_name=catalog_name |
| ) |
| return self._format_response(success=True, result=indexes) |
| except Exception as e: |
| logger.error(f"Failed to get table indexes: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting table indexes" |
| ) |
| |
| def _serialize_datetime_objects(self, data: Any) -> Any: |
| """Serialize datetime objects to JSON compatible format""" |
| if isinstance(data, list): |
| return [self._serialize_datetime_objects(item) for item in data] |
| elif isinstance(data, dict): |
| return { |
| key: self._serialize_datetime_objects(value) |
| for key, value in data.items() |
| } |
| elif hasattr(data, "isoformat"): # datetime, date, time objects |
| return data.isoformat() |
| elif hasattr(data, "strftime"): # pandas Timestamp objects |
| return data.strftime("%Y-%m-%d %H:%M:%S") |
| else: |
| return data |
| |
| async def get_recent_audit_logs_for_mcp( |
| self, days: int = 7, limit: int = 100 |
| ) -> dict[str, Any]: |
| """Get recent audit log records - MCP interface""" |
| logger.info(f"Getting audit logs: Days: {days}, Limit: {limit}") |
| |
| try: |
| logs_df = await self.get_recent_audit_logs_async(days=days, limit=limit) |
| |
| # Convert DataFrame to JSON format |
| if hasattr(logs_df, "to_dict"): |
| try: |
| logs_data = logs_df.to_dict("records") |
| except Exception as e: |
| logger.warning( |
| f"DataFrame.to_dict failed, trying manual conversion: {e}" |
| ) |
| # Manually convert DataFrame to records format |
| logs_data = [] |
| if not logs_df.empty: |
| for _, row in logs_df.iterrows(): |
| logs_data.append(dict(row)) |
| # Serialize datetime objects |
| logs_data = self._serialize_datetime_objects(logs_data) |
| else: |
| logs_data = self._serialize_datetime_objects(logs_df) |
| |
| return self._format_response(success=True, result=logs_data) |
| except Exception as e: |
| logger.error(f"Failed to get audit logs: {str(e)}", exc_info=True) |
| return self._format_response( |
| success=False, |
| error=str(e), |
| message="Error occurred while getting audit logs", |
| ) |
| |
| async def get_catalog_list_for_mcp(self) -> dict[str, Any]: |
| """Get Doris catalog list - MCP interface""" |
| logger.info("Getting catalog list") |
| |
| try: |
| catalogs = await self.get_catalog_list_async() |
| return self._format_response( |
| success=True, |
| result=catalogs, |
| message="Successfully retrieved catalog list", |
| ) |
| except Exception as e: |
| logger.error(f"Failed to get catalog list: {str(e)}", exc_info=True) |
| return self._format_metadata_error_response( |
| e, "Error occurred while getting catalog list" |
| ) |
| |
| |
| # ==================== Compatibility aliases ==================== |
| |
| |
| # For backward compatibility, create MetadataManager alias |
| class MetadataManager: |
| """ |
| Metadata manager - backward compatibility class |
| Actually a wrapper for MetadataExtractor |
| """ |
| |
| def __init__( |
| self, |
| connection_manager: DorisConnectionManager | None = None, |
| ) -> None: |
| self.extractor = MetadataExtractor(connection_manager=connection_manager) |
| |
| async def exec_query( |
| self, |
| sql: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| max_rows: int = 100, |
| timeout: int = 30, |
| *, |
| max_bytes: int | None = None, |
| ) -> dict[str, Any]: |
| """Execute SQL query and return results, supports catalog federation queries""" |
| return await self.extractor.exec_query_for_mcp( |
| sql, |
| db_name, |
| catalog_name, |
| max_rows, |
| timeout, |
| max_bytes=max_bytes, |
| ) |
| |
| async def get_table_schema( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get detailed schema information for specified table (columns, types, comments, etc.)""" |
| return await self.extractor.get_table_schema_for_mcp( |
| table_name, db_name, catalog_name |
| ) |
| |
| async def get_db_table_list( |
| self, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get list of all table names in specified database""" |
| return await self.extractor.get_db_table_list_for_mcp(db_name, catalog_name) |
| |
| async def get_db_list( |
| self, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get list of all database names on server""" |
| return await self.extractor.get_db_list_for_mcp(catalog_name) |
| |
| async def get_table_comment( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get comment information for specified table""" |
| return await self.extractor.get_table_comment_for_mcp( |
| table_name, db_name, catalog_name |
| ) |
| |
| async def get_table_column_comments( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get comment information for all columns in specified table""" |
| return await self.extractor.get_table_column_comments_for_mcp( |
| table_name, db_name, catalog_name |
| ) |
| |
| async def get_table_indexes( |
| self, |
| table_name: str, |
| db_name: str | None = None, |
| catalog_name: str | None = None, |
| ) -> dict[str, Any]: |
| """Get index information for specified table""" |
| return await self.extractor.get_table_indexes_for_mcp( |
| table_name, db_name, catalog_name |
| ) |
| |
| async def get_recent_audit_logs( |
| self, days: int = 7, limit: int = 100 |
| ) -> dict[str, Any]: |
| """Get recent audit log records""" |
| return await self.extractor.get_recent_audit_logs_for_mcp(days, limit) |
| |
| async def get_catalog_list(self) -> dict[str, Any]: |
| """Get Doris catalog list""" |
| return await self.extractor.get_catalog_list_for_mcp() |