import asyncio import contextlib import enum import functools import logging import pickle import re import time from collections.abc import Generator, Sequence from typing import TYPE_CHECKING, Any, cast from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from Levenshtein import distance # ty:ignore[unresolved-import] from llama_index.core.base.llms.types import ChatMessage, MessageRole from pandas import DataFrame from pydantic import BaseModel, Field from sqlalchemy import Connection, Engine, create_engine, inspect, text from sqlalchemy.exc import ProgrammingError, SQLAlchemyError from private_gpt.components.cache import Cache, MemoryCache from private_gpt.components.chat.models.chat_config_models import ( CondensationConfig, ResolvedChatRequest, ResolvedSystemConfig, ) from private_gpt.components.chat.processors.chat_history.memory.utils.content import ( messages_to_history_str, ) from private_gpt.components.database.function_inspector import ( DatabaseFunctionsInspector, ) from private_gpt.components.database.inspected_schema import InspectedSchema from private_gpt.components.database.inspector_interface import ( DatabaseObjectInspector, InspectedDatabaseObject, ) from private_gpt.components.database.procedure_inspector import ( DatabaseProcedureInspector, ) from private_gpt.components.database.table_inspector import DatabaseTableInspector from private_gpt.components.database.view_inspector import DatabaseViewInspector from private_gpt.components.llm.llm_component import LLMComponent from private_gpt.di import get_global_injector from private_gpt.events.models import TextBlock from private_gpt.utils.dependencies import format_missing_dependency_message if TYPE_CHECKING: from sqlalchemy import Row logger = logging.getLogger(__name__) try: import sqlglot # type: ignore[import-not-found] # ty:ignore[unresolved-import] from sqlglot import ( # ty:ignore[unresolved-import] Dialects, # type: ignore[import-not-found] ) from sqlglot.errors import ( # ty:ignore[unresolved-import] ParseError, # type: ignore[import-not-found] ) except (ImportError, ModuleNotFoundError) as e: raise ImportError( format_missing_dependency_message( "Database query", extras=( "database-postgres", "database-mysql", "database-mssql", "database-db2", "database", ), ) ) from e @functools.cache def _load_ibm_db() -> Any: try: import ibm_db # type: ignore[import-not-found] # ty:ignore[unresolved-import] except ImportError as e: raise ImportError( format_missing_dependency_message( "DB2 database query", extras=("database-db2", "database"), ) ) from e return ibm_db class ErrorType(enum.StrEnum): UNKNOWN = "UNKNOWN" NOT_SUPPORTED_TYPE_MSSQL = "HY106" # Error code for MSSQL unsupported type RETURNED_SQL_CURSOR = ( "RETURNED_SQL_CURSOR" # Postgres procedure returned a SQL cursor ) INVALID_PARAM_MODE_DB2 = ( "42886" # DB2 procedure invalid parameter mode (e.g., OUT param used as IN) ) class ErrorQueryResult(BaseModel): description: str = Field(description="Error message if an error occurred.") type: ErrorType = Field(description="The type of error.") def __init__(self, description: str, type: ErrorType = ErrorType.UNKNOWN): super().__init__(description=description, type=type) def __str__(self) -> str: if self.type == ErrorType.NOT_SUPPORTED_TYPE_MSSQL: return ( f"{self.description}\n\n" "CRITICAL - How to fix:\n" "1. Identify the column at position 'column-index' (starting from 0) in your SELECT clause\n" "2. That column has an unsupported spatial type (marked as 'NULL' in schema)\n" "3. Add .ToString() ONLY: columnName.ToString() AS columnName\n" "4. DO NOT use ISNULL(), CAST(), CONVERT(), or any other SQL functions\n" "6. If using SELECT *, replace with explicit column names first\n" "7. Keep all other parts unchanged (WHERE, JOIN, ORDER BY)\n" "Example fix: c.DeliveryLocation.ToString() AS DeliveryLocation\n" ) return self.description class QueryResult(BaseModel): query: str | None = Field( default=None, description="The SQL query that was executed." ) rows_text: str | None = Field( default=None, description="The result of the query or an error message." ) columns: list[str] | None = Field( default=None, description="The column names of the result set." ) rows: Sequence[Any] = Field( default_factory=list, description="The raw rows returned by the query." ) error: ErrorQueryResult | None = Field( default=None, description="Error message if an error occurred." ) row_count: int = Field( description="The number of rows returned by the query, -1 if an error occurred.", ) warning: str | None = Field( default=None, description="Warning message if the query executed but with potential issues (e.g., timeout, partial results).", ) _csv_cache: str | None = None def as_csv(self) -> str: if self._csv_cache is not None: return self._csv_cache if self.error: return f"Error executing query: {self.error}" if not self.rows or not self.columns: return "No results found." table_str = DataFrame( columns=self.columns, data=self.rows, ).to_csv(index=False) if not table_str: return "No results found." self._csv_cache = table_str return table_str class DatabaseResultEvent(BaseModel): content: list[Any] = Field( default_factory=list, description="The content blocks from the analysis." ) is_error: bool = Field( False, description="Indicates if the analysis resulted in an error.", ) _DEFAULT_EXCLUDED_SCHEMAS = { # Postgres specific "pg_catalog", "information_schema", "pg_toast", "pg_temp_1", "pg_toast_temp_1", # MSSQL specific "db_accessadmin", "db_backupoperator", "db_datareader", "db_datawriter", "db_ddladmin", "db_denydatareader", "db_denydatawriter", "db_owner", "db_securityadmin", "PowerBI", "guest", "sys", "nullid", "sqlj", } class InspectorConfig(BaseModel): tables: bool = Field(default=True) views: bool = Field(default=True) procedures: bool = Field(default=True) functions: bool = Field(default=True) _ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") class DatabaseQueryGenerator: """Helper to handle natural language queries against a SQL database. Generally PandasAI is used for tabular data analysis, but it simply doesn't work well for relational databases. This service uses an LLM to convert natural language queries into SQL, executes them, and returns the results. """ connection_string: str ssl: bool schemas: list[str] | None max_retries: int is_readonly: bool _engine: Engine | None _connection: Connection | None _dialect: str | None inspector_config: InspectorConfig batch_size: int timeout_seconds: int | None max_mb_result: int | None = 100 def __init__( self, connection_string: str, schemas: list[str] | None = None, ssl: bool = False, max_retries: int = 3, is_readonly: bool = True, enable_tables: bool = True, enable_views: bool = True, enable_procedures: bool = True, enable_functions: bool = True, description: str = "", batch_size: int = 1000, timeout_seconds: int | None = None, max_mb_result: int | None = None, cache: Cache | None = None, ): # Need to do it lazily to avoid circular dependency self.connection_string = connection_string self.ssl = ssl self.schemas = schemas self.max_retries = max_retries self.is_readonly = is_readonly self._connection = None self._engine = None self.inspector_config = InspectorConfig() self.inspector_config.views = enable_views self.inspector_config.tables = enable_tables self.inspector_config.procedures = enable_procedures self.inspector_config.functions = enable_functions self.description = description self._dialect = self._extract_dialect() self._prepare_connection_string() self.batch_size = batch_size self.timeout_seconds = timeout_seconds self.max_mb_result = max_mb_result self.cache = cache or MemoryCache(max_entries=1000) def _extract_database_name(self) -> str: # crude way to extract the database name from the connection string # works for postgres style connection strings # postgresql://user:password@host:port/dbname try: # extract the part after the last / db_name = self.connection_string.rsplit("/", 1)[-1] # remove any query parameters db_name = db_name.split("?", 1)[0] return db_name if db_name else "unknown" except Exception: return "unknown" def _extract_dialect(self) -> str | None: try: protocol = self.connection_string.split("://", 1)[0] if "mssql" in protocol: # if we don't do this, distance will pick "mysql" # over "tsql" due to shorter length return cast(str, Dialects.TSQL.value) best_distance = -1 best_dialect: str | None = None for dialect in Dialects: dist = distance(protocol.lower(), dialect.value.lower()) if best_distance == -1 or dist < best_distance: best_distance = dist best_dialect = cast(str, dialect.value) return best_dialect except Exception: return None def _is_readonly_query(self, sql: str) -> bool: with contextlib.suppress(Exception): parsed = sqlglot.parse_one(sql, dialect=self._dialect) return parsed.find(sqlglot.expressions.Select) is not None and not any( parsed.find(expr) for expr in [ sqlglot.expressions.Insert, sqlglot.expressions.Update, sqlglot.expressions.Delete, sqlglot.expressions.Drop, sqlglot.expressions.Create, sqlglot.expressions.Alter, ] ) return False # If parsing fails, assume unsafe def _create_transaction_template(self) -> str: """Generate transaction template using SQLGlot transpilation.""" if not self.is_readonly: return "%s;" template = [] if self._dialect in [Dialects.POSTGRES]: template = ["SET TRANSACTION READ ONLY"] elif self._dialect in [Dialects.TSQL]: # Whitelisted fallback template = ["BEGIN", "ROLLBACK"] template = [ sqlglot.transpile(part, write=self._dialect)[0] for part in template ] if not template: return "%s;" prefix = f"{template[0]};\n" suffix = f";\n{template[-1]};" if template[-1] != template[0] else "" return f"{prefix}%s{suffix}" def _create_safe_query_block(self, queries: list[str]) -> str: """Create safe query block.""" template = self._create_transaction_template() return template % ";\n".join(queries) def _fix_cursor_result(self, query: str) -> str: # Currently only support Postgres cursors, but we need to check with order # type of returns in the postgres procedures if self._dialect in [Dialects.POSTGRES]: call_pattern = r"CALL\s+([\w\.]+)\s*\((.*?)\)" match = re.search(call_pattern, query, re.IGNORECASE | re.DOTALL) if not match: return query proc_name = match.group(1) params_str = match.group(2).strip() # Sometimes when the user does more than one call # the model generate correctly the call with cursor if re.search(r"llm_cursor_\d+", params_str): return query param_values = [] if ":=" in params_str: param_pattern = r"(\w+)\s*:=\s*([^,\)]+)" for param_match in re.finditer(param_pattern, params_str): value = param_match.group(2).strip() param_values.append(value) else: param_pattern = r"'[^']*'|\"[^\"]*\"|\b[^,]+\b" param_values = [ v.strip() for v in re.findall(param_pattern, params_str) if v.strip() ] params_clean = ", ".join(param_values) cursor_name = f"llm_cursor_{hash(proc_name + params_clean) & 0xFFFFFF}" if params_clean: params_with_cursor = f"{params_clean}, '{cursor_name}'" else: params_with_cursor = f"'{cursor_name}'" modified_query = f'CALL {proc_name}({params_with_cursor}); FETCH ALL FROM "{cursor_name}";' return modified_query return query def _prepare_connection_string(self) -> None: parsed = urlparse(self.connection_string) params = parse_qs(parsed.query, keep_blank_values=True) if self._dialect in [Dialects.TSQL]: # MSSQL specific adjustments if not any(k.lower() == "encrypt" for k in params): params["Encrypt"] = ["yes" if self.ssl else "no"] if not any(k.lower() == "driver" for k in params): if not parsed.scheme.startswith("mssql+pyodbc"): raise ValueError( "MSSQL connection requires pyodbc scheme when no driver specified" ) params["driver"] = ["ODBC Driver 18 for SQL Server"] elif self._dialect in [Dialects.MYSQL]: # MySQL specific adjustments if parsed.scheme == "mysql" or parsed.scheme == "mysql+mysqldb": parsed = parsed._replace(scheme="mysql+pymysql") elif not parsed.scheme.startswith("mysql+pymysql"): raise ValueError( "MySQL connection requires mysql, mysql+mysqldb, or mysql+pymysql scheme" ) if not any(k.lower() == "charset" for k in params): params["charset"] = ["utf8mb4"] flattened = {k: v[0] if len(v) == 1 else v for k, v in params.items()} new_parsed = parsed._replace(query=urlencode(flattened, doseq=True)) self.connection_string = str(urlunparse(new_parsed)) def _check_connection(self) -> str | None: """Check if the database connection can be established. Returns None if successful, or an error message if failed. """ try: conn = self._ensure_connected() match self._engine.dialect.name.lower() if self._engine else "unknown": case "db2" | "ibm_db_sa": conn.execute(text("SELECT 1 FROM SYSIBM.SYSDUMMY1")) case _: conn.execute(text("SELECT 1")) return None except (SQLAlchemyError, ProgrammingError) as e: logging.error(e) return f"Failed to connect to database '{self._extract_database_name()}'" except Exception as e: logging.error(e) return f"Failed to connect to database '{self._extract_database_name()}'" finally: self._disconnect() async def check_connection(self) -> str | None: result = await asyncio.to_thread(self._check_connection) if result is None: # Successful connection # Pre-warm the schema cache asynchronously def _extract_schema_warmup() -> None: try: self._extract_database_schema() except Exception: logger.warning( f"Failed to pre-warm schema cache for database '{self._extract_database_name()}'" ) finally: self._disconnect() asyncio.create_task( # noqa: RUF006 asyncio.to_thread(_extract_schema_warmup) ) return result def _query(self, query: str, execute_batch: bool = False) -> QueryResult: def execute_query(q: str) -> QueryResult: safe_query = self._create_safe_query_block([q]) conn = self._ensure_connected() cursor = conn.execute(text(safe_query)) rows = cursor.fetchall() column_names = cursor.keys() return QueryResult( query=q, rows=rows, columns=[str(s) for s in column_names], row_count=len(rows), ) def execute_query_batched(q: str) -> QueryResult: start_time = time.perf_counter() all_rows: list[Row[Any]] = [] safe_query = self._create_safe_query_block([q]) conn = self._ensure_connected() result = conn.execution_options(stream_results=True).execute( text(safe_query) ) # result.keys() is needed here because result is a SQLAlchemy # Result object, not a dict columns = [str(s) for s in result.keys()] # noqa: SIM118 count = 0 timeout_reached = False batches_limit = None # Máximo de 100 MB max_bytes = self.max_mb_result * 1024 * 1024 if self.max_mb_result else None size_limit_reached = False while True: if ( self.timeout_seconds and time.perf_counter() - start_time >= self.timeout_seconds ): timeout_reached = True break batch = result.fetchmany(self.batch_size) count += 1 if not batch: break all_rows.extend(batch) if count == 1 and max_bytes: estimated_bytes_per_batch = len( pickle.dumps(batch, protocol=pickle.HIGHEST_PROTOCOL) ) if estimated_bytes_per_batch > 0: batches_limit = ( int(max_bytes * 0.9) ) // estimated_bytes_per_batch if batches_limit and count > batches_limit: size_limit_reached = True break result.close() warning = None if size_limit_reached: warning = ( f"Query result size limit of {self.max_mb_result} MB reached after fetching {len(all_rows)}" f" rows. The result set may be incomplete due to size constraints." ) if timeout_reached: warning = ( f"Query time limit reached after fetching {len(all_rows)} rows. The result set may be " f"incomplete due to time constraints." ) return QueryResult( query=q, rows=all_rows, columns=columns, row_count=len(all_rows), warning=warning if warning else None, ) exec_func = execute_query_batched if execute_batch else execute_query original_sql: str = "" result: QueryResult | None = None # 1. Try to run the query as-is try: original_sql = self._extract_sql_code(query, transpile_sql=False) result = exec_func(original_sql) # TODO: review for add cursor support # result = self._check_result(result) if result and not result.error: return result except (SQLAlchemyError, ProgrammingError) as e: error_str = str(e).upper() # Determine error type based on SQLSTATE or error code if ErrorType.NOT_SUPPORTED_TYPE_MSSQL in error_str: error_type = ErrorType.NOT_SUPPORTED_TYPE_MSSQL elif f"SQLSTATE={ErrorType.INVALID_PARAM_MODE_DB2}" in error_str or ( "PARAMS BOUND NOT MATCHING" in error_str and "REQUIRED" in error_str ): error_type = ErrorType.INVALID_PARAM_MODE_DB2 else: error_type = ErrorType.UNKNOWN result = QueryResult( query=query, error=ErrorQueryResult(description=str(e), type=error_type), row_count=-1, ) # 2. Try to transpile and run again with contextlib.suppress(Exception): transpiled_sql = self._extract_sql_code(query, transpile_sql=True) if transpiled_sql != original_sql: result = exec_func(transpiled_sql) if result: return result # 3. Try to fix NOT_SUPPORTED_TYPE errors if ( result and result.error and result.error.type == ErrorType.NOT_SUPPORTED_TYPE_MSSQL ): with contextlib.suppress(Exception): fixed_sql = self._try_to_fix_unsupported_type_error(original_sql) if fixed_sql and fixed_sql != original_sql: result = exec_func(fixed_sql) if result: return result # 4. Try to fix DB2 procedure OUT parameter errors if ( result and result.error and result.error.type == ErrorType.INVALID_PARAM_MODE_DB2 ): with contextlib.suppress(Exception): fixed_result = self._execute_db2_procedure(original_sql) if fixed_result and not fixed_result.error: return fixed_result # TODO: This code is for postgres support but now is not used # 4. Try to fix returned SQL cursor errors # if ( # result # and result.error # and result.error.type == ErrorType.RETURNED_SQL_CURSOR # ): # with contextlib.suppress(Exception): # fixed_sql = self._fix_cursor_result(original_sql) # if fixed_sql and fixed_sql != original_sql: # result = exec_func(fixed_sql) # if result: # return result return ( result if result else QueryResult( query=query, error=ErrorQueryResult("Failed to execute query for unknown reasons."), row_count=-1, ) ) def _check_batched_support(self) -> bool: # Change to with list (Now support mssql and db2 and works with bacthed) # But postgres doesn't support batch with procedures that return cursors, unsupported_dialects = [Dialects.POSTGRES] return self._dialect not in unsupported_dialects async def _query_batched_stream(self, query: str) -> QueryResult: return await asyncio.to_thread( self._query, query, execute_batch=self._check_batched_support() ) async def query( self, query: str, additional_context: str | None = None, ) -> QueryResult: last_error: ErrorQueryResult | None = None last_generated_query: str | None = None try: extracted_schemas = await asyncio.to_thread(self._extract_database_schema) except SQLAlchemyError as e: return QueryResult( error=ErrorQueryResult(f"Failed to extract database schema: {e!s}"), row_count=-1, ) if not extracted_schemas: return QueryResult( error=ErrorQueryResult("No database schema found."), row_count=-1, ) for _i in range(self.max_retries): sql_query = await self.generate_sql_query( natural_language_query=query, schemas=extracted_schemas, additional_context=additional_context, last_generated_query=last_generated_query, last_error=last_error, ) if "NO_QUERY" in sql_query.strip().upper(): return QueryResult( query=None, rows_text="No relevant tables found to answer the question.", row_count=0, ) last_generated_query = sql_query result = await self._query_batched_stream(sql_query) if result and not result.error: return result last_error = result.error if result else ErrorQueryResult("Unknown error") return QueryResult( query=last_generated_query, error=last_error, row_count=-1, ) def _ensure_connected(self) -> Connection: if self._connection and not self._connection.closed: return self._connection self._engine = create_engine( self.connection_string # TODO: SSL Certificates ) conn = self._engine.connect() self._connection = conn return conn def _disconnect(self) -> None: if self._connection and not self._connection.closed: self._connection.close() self._connection = None if self._engine: self._engine.dispose() self._engine = None def _get_examples_by_dialect(self) -> str: value = self._engine.dialect.name.lower() if self._engine else self._dialect system_prompt = f"The database SQL dialect is: {value}." match value: case "db2" | "ibm_db_sa": system_prompt += ( " For DB2 procedures: input parameters (Params) use literal values, output parameters (Returns) use '?' placeholders." " Example with 1 input + 3 outputs: CALL schema.procedure_name('value1', ?, ?, ?);" ) return system_prompt async def generate_sql_query( self, natural_language_query: str, schemas: list[InspectedSchema], additional_context: str | None = None, last_generated_query: str | None = None, last_error: ErrorQueryResult | None = None, ) -> str: # Need to do it lazily to avoid circular dependency from private_gpt.server.chat.chat_service import ChatService chat_service = get_global_injector().get(ChatService) system_prompt = "Given the following database schema information:\n\n" system_prompt += "\n\n".join(str(schema) for schema in schemas).strip() system_prompt += "\n\n" if self.description: system_prompt += f"Schema description: {self.description}\n\n" system_prompt += ( "Only generate the SQL query, do not include any explanations. " ) system_prompt += self._get_examples_by_dialect() system_prompt += ( "Do not generate style characters, the output SQL will be executed as-is, " "it will not be displayed in a terminal. " ) system_prompt += "Prefer standard SQL syntax over database-specific syntax. " system_prompt += ( "Use views or procedures instead of underlying tables whenever possible. " ) system_prompt += "If there is no relevant tables that can answer the question. " system_prompt += "Return replying 'NO_QUERY'. " system_prompt += "When asked about data that mostly contains IDs or non-informative data (e.g. boolean, timestamps, UUIDs) " system_prompt += "attempt to add a relevant column that is human-readable (eg. name, title...) " system_prompt += "to the query unless explicitly told not to. " system_prompt += "The output MUST be valid SQL, no other text or explanations. " example = ( "(e.g., TableName.ColumnName)" if self._dialect in [Dialects.TSQL.value] else "" ) system_prompt += f"Before referencing any column {example}, verify that column exists in that specific table's column list. " system_prompt += "If a column does not exist in Table A but exists in related Table B, you MUST JOIN Table B first to access it. " system_prompt += "If no direct foreign key exists between two tables, use intermediate tables to connect them. " system_prompt += "Never assume column locations or relationships not explicitly documented in the schema. " if last_generated_query and last_error: system_prompt += f"\n Previous attempt was:\n{last_generated_query}\n" system_prompt += "But it resulted in an error:\n" system_prompt += f"{last_error}\n" system_prompt += "Please correct the SQL query." user_prompt = f"Generate an SQL query for the following request: {natural_language_query}\n" if additional_context: user_prompt += f"Additional context: {additional_context}\n" messages: list[ChatMessage] = [ ChatMessage( role="user", content=user_prompt, ) ] llm_component = get_global_injector().get(LLMComponent) tokenizer = llm_component.tokenizer max_model_tokens = llm_component.metadata().context_window final_history: list[ChatMessage] = [ ChatMessage( role=MessageRole.SYSTEM, content=system_prompt, ), *messages, ] chat_history = await asyncio.to_thread(messages_to_history_str, final_history) available_tokens = max_model_tokens - (max_model_tokens // 5) # 20% buffer if tokenizer is not None: current_tokens = len(tokenizer(chat_history)) available_tokens -= current_tokens sampling_params: dict[str, Any] = {} if available_tokens <= 0: # TODO: TLDR strategy don't work well here, # need to implement a especially TLDR for schema raise ValueError("The database schema is too long to fit in the model.") if available_tokens > 0: sampling_params["max_tokens"] = available_tokens response = await chat_service.chat( ResolvedChatRequest( messages=messages, system=ResolvedSystemConfig( prompt=system_prompt, use_default_prompt=False ), condensation=CondensationConfig(enabled=False), sampling_params=sampling_params, ) ) # find the first text block in the response for block in response.content: if isinstance(block, TextBlock): raw_text = block.text # despite the instructions, the LLM might # generate markdown like ```sql ... ``` # so we try to extract the SQL code from it # remove the prefix and suffix if present return self._extract_sql_code(raw_text, transpile_sql=False) raise ValueError("Failed to generate SQL query") def _transpile_sql(self, sql: str) -> str: if not self._dialect: return sql for read_dialect in Dialects: if read_dialect.value == self._dialect: continue with contextlib.suppress(ParseError): result = "\n".join( sqlglot.transpile(sql, read=read_dialect, write=self._dialect) ) if result: return result try: return "\n".join(sqlglot.transpile(sql, identity=True, write=self._dialect)) except ParseError as e: error_str = str(e) error_str = _ansi_escape.sub("", error_str) raise ValueError(f"Generated SQL query is invalid: {error_str}") from e except Exception as e: raise e def _extract_sql_code(self, raw_text: str, transpile_sql: bool = True) -> str: """Extract SQL code from the raw text, removing any Markdown formatting. LLM usually generates the SQL code wrapped in triple markdown ``` blocks, sometimes with a "sql" language hint. This function extracts the actual SQL code from such formatting. """ # Find the index of the first ``` and last ``` start_idx = raw_text.find("```") end_idx = raw_text.rfind("```") clean_code: str if start_idx != -1 and end_idx != -1 and start_idx != end_idx: # Extract the content between the first and last ``` code_block = raw_text[start_idx + 3 : end_idx].strip() # If the code block starts with "sql", remove it if code_block.lower().startswith("sql"): code_block = code_block[3:].strip() clean_code = code_block.strip() else: # No code block found, return the original text trimmed clean_code = raw_text.strip() # remove any ANSI escape sequences, since LLM # can sometimes generate them result = _ansi_escape.sub("", clean_code) # remove final semicolon if present if result.endswith(";"): result = result[:-1].rstrip() # Transpile to the target dialect if needed if transpile_sql: result = self._transpile_sql(result) # remove any ANSI escape sequences, just in case # than the transpiler added any result = _ansi_escape.sub("", result) # remove leading and trailing whitespace result = result.strip() return result def _list_target_schemas(self, include_system: bool) -> list[str]: self._ensure_connected() meta = inspect(self._engine) all_schemas = meta.get_schema_names() # type: ignore if self.schemas: selected = [s for s in all_schemas if s in set(self.schemas)] else: if include_system: selected = all_schemas else: selected = [ s for s in all_schemas if s not in _DEFAULT_EXCLUDED_SCHEMAS ] selected.sort() return selected def _get_inspectors(self) -> Generator[DatabaseObjectInspector, None, None]: if not self.inspector_config or self.inspector_config.views: yield DatabaseViewInspector( self._engine, self._connection, self.connection_string, self.is_readonly, ) if not self.inspector_config or self.inspector_config.tables: yield DatabaseTableInspector( self._engine, self._connection, self.connection_string, self.is_readonly, ) if not self.inspector_config or self.inspector_config.procedures: yield DatabaseProcedureInspector( self._engine, self._connection, self.connection_string, self.is_readonly, ) if not self.inspector_config or self.inspector_config.functions: yield DatabaseFunctionsInspector( self._engine, self._connection, self.connection_string, self.is_readonly, ) def _get_cached_objects_by_type( self, cache_key: str, schema: str, inspector: DatabaseObjectInspector, ) -> list[InspectedDatabaseObject]: cached_objects = self.cache.get("database-schema", cache_key) if cached_objects is not None: return cast(list[InspectedDatabaseObject], cached_objects) objects = list(inspector.get_objects(schema)) self.cache.set("database-schema", cache_key, objects) return objects def _extract_database_schema( self, include_system: bool = False, ) -> list[InspectedSchema]: self._ensure_connected() result: list[InspectedSchema] = [] target_schemas = self._list_target_schemas(include_system) for schema in target_schemas: out_schema: InspectedSchema = InspectedSchema() out_schema.name = schema for inspector in self._get_inspectors(): type_cache_key = f"{self.connection_string}_{schema}_{inspector.get_inspector_type()}" db_objects = self._get_cached_objects_by_type( type_cache_key, schema, inspector ) for db_object in db_objects: out_schema.add_object(db_object) result.append(out_schema) cleaned_up_schema = [s for s in result if s.all_objects] return cleaned_up_schema def _try_to_fix_unsupported_type_error(self, original_sql: str) -> str | None: try: from sqlgpt_parser.format.formatter import ( # type: ignore[import-not-found,import-untyped] # ty:ignore[unresolved-import] format_sql, ) from sqlgpt_parser.parser.mysql_parser import ( # type: ignore[import-not-found,import-untyped] # ty:ignore[unresolved-import] parser as mysql_parser, ) from sqlgpt_parser.parser.tree.expression import ( # type: ignore[import-not-found,import-untyped] # ty:ignore[unresolved-import] QualifiedNameReference, ) from sqlgpt_parser.parser.tree.qualified_name import ( # type: ignore[import-not-found,import-untyped] # ty:ignore[unresolved-import] QualifiedName, ) from sqlgpt_parser.parser.tree.select_item import ( # type: ignore[import-not-found,import-untyped] # ty:ignore[unresolved-import] SingleColumn, ) except ImportError: logger.warning( "sqlgpt_parser is required for fixing unsupported type errors." ) return None # 1. Parse SQL to AST ast = mysql_parser.parse(original_sql) # 2. Extract all table names used in the query (handles both Table and Join) def extract_table_names(from_clause: Any) -> list[str]: if hasattr(from_clause, "name") and from_clause.name: parts: list[str] = list(from_clause.name.parts) return parts elif hasattr(from_clause, "left") and hasattr(from_clause, "right"): result: list[str] = [ *extract_table_names(from_clause.left), *extract_table_names(from_clause.right), ] return result return [] table_parts = extract_table_names( ast.query_body.from_ ) # Return a list with schema and table parts table_parts_tolower: list[str] = [p.lower() for p in table_parts] # 3. Extract database schema extracted_schemas: list[InspectedSchema] = self._extract_database_schema() # 4. Filter only tables used in the query filtered_tables = [ item for schema in extracted_schemas if schema.name.lower() in table_parts_tolower for item in schema.tables + schema.views if item.name.lower() in table_parts_tolower ] # 5. Check if query is select * select_items = ast.query_body.select.select_items is_select_all = ( len(select_items) == 1 and isinstance(select_items[0].expression, QualifiedNameReference) and select_items[0].expression.name.parts == ["*"] ) if is_select_all: # Case SELECT *: put all columns, add ToString to columns of type NULL new_select_items = [ *[ SingleColumn( expression=QualifiedNameReference( name=QualifiedName.of(column.name) ) ) for table in filtered_tables for column in table.columns if column.type != "NULL" # When mssql returns NULL type, it means the column have unsupported type ], *[ SingleColumn( expression=QualifiedNameReference( name=QualifiedName.of(f"{column.name}.ToString()") ) ) for table in filtered_tables for column in table.columns if column.type == "NULL" ], ] else: # Case specific columns: only modify NULL columns to have .ToString() new_select_items = [] for item in select_items: full_col_parts = item.expression.name.parts # Extract table prefix and column name if len(full_col_parts) > 1: table_prefix = full_col_parts[-2] col_name = full_col_parts[-1] else: table_prefix = None col_name = full_col_parts[0] # Find the column type among filtered tables column_type = None for table in filtered_tables: # If there's a table prefix, match it (case-insensitive) if table_prefix and table.name.lower() != table_prefix.lower(): continue for column in table.columns: if column.name.lower() == col_name.lower(): column_type = column.type break if column_type is not None: break # Build the expression preserving the original prefix structure col_full_name = ".".join(full_col_parts) if ( column_type == "NULL" ): # When mssql returns NULL type, it means the column have unsupported type col_full_name += ".ToString()" expr_name = QualifiedName.of(col_full_name) new_select_items.append( SingleColumn(expression=QualifiedNameReference(name=expr_name)) ) # 6. Replace select_items if new ones created if len(new_select_items) > 0: ast.query_body.select.select_items = new_select_items result = format_sql(ast) if isinstance(result, str): return result return None # TODO: need a review for postgres cursor support def _check_result(self, result: QueryResult) -> QueryResult: if result.error is not None: return result if ( result.rows and result.row_count == 1 and result.columns and len(result.columns) == 1 ): row_value = result.rows[0][0] if result.rows[0] else None if isinstance(row_value, str) and result.query and "CALL" in result.query: result.error = ErrorQueryResult( type=ErrorType.RETURNED_SQL_CURSOR, description="The query returned a SQL cursor (refcursor) that requires manual fetching. " 'Keep your original CALL statement, then use FETCH ALL FROM "" to retrieve the results. ' "Do not use BEGIN, COMMIT, or any other statements—only CALL followed by FETCH.", ) result.rows = [] result.columns = None result.row_count = -1 return result def close(self) -> None: """Close any open connections.""" self._disconnect() def _execute_db2_procedure(self, call_statement: str) -> QueryResult: try: proc_name, param_values, out_indices = self._parse_call_statement( call_statement ) conn = self._create_db2_connection() try: param_metadata = self._get_procedure_param_metadata(conn, proc_name) result_params = self._execute_procedure(conn, proc_name, param_values) out_values, out_columns = self._extract_output_parameters( result_params, out_indices, param_metadata ) return QueryResult( query=call_statement, rows=[tuple(out_values)] if out_values else [], columns=out_columns, row_count=1 if out_values else 0, ) finally: _load_ibm_db().close(conn) except Exception as e: return QueryResult( query=call_statement, error=ErrorQueryResult( description=f"DB2 procedure execution failed: {e!s}", type=ErrorType.UNKNOWN, ), row_count=-1, ) def _parse_call_statement( self, call_statement: str ) -> tuple[str, list[str | int], list[int]]: match = re.search( r"CALL\s+([\w.]+)\s*\((.*?)\)", call_statement, re.IGNORECASE | re.DOTALL ) if not match: raise ValueError("Invalid CALL statement format") proc_name = match.group(1) params_str = match.group(2) param_values: list[str | int] = [] out_indices: list[int] = [] for i, param in enumerate(params_str.split(",")): param = param.strip() if param == "?": param_values.append(0) # Default value for OUT parameters out_indices.append(i) else: param_values.append(param.strip("'\"")) return proc_name, param_values, out_indices def _create_db2_connection(self) -> Any: conn_str = self.connection_string.split("://")[1] user_pass, host_db = conn_str.split("@") user, password = user_pass.split(":") host_port, database = host_db.split("/") host, port = host_port.split(":") db2_conn_str = ( f"DATABASE={database};" f"HOSTNAME={host};" f"PORT={port};" f"PROTOCOL=TCPIP;" f"UID={user};" f"PWD={password};" ) return _load_ibm_db().connect(db2_conn_str, "", "") def _execute_procedure( self, conn: Any, proc_name: str, param_values: list[str | int] ) -> tuple[Any, ...]: ibm_db = _load_ibm_db() result = ibm_db.callproc(conn, proc_name, tuple(param_values)) stmt = result[0] result_params: tuple[Any, ...] = result[1:] # Consume all result sets to ensure OUT parameters are populated if stmt: while ibm_db.fetch_row(stmt): pass while ibm_db.next_result(stmt): while ibm_db.fetch_row(stmt): pass return result_params def _get_procedure_param_metadata( self, conn: Any, proc_name: str ) -> list[tuple[str, str]]: schema, name = ( proc_name.rsplit(".", 1) if "." in proc_name else (None, proc_name) ) ibm_db = _load_ibm_db() stmt = ibm_db.procedure_columns(conn, None, schema, name, None) params: list[tuple[str, str]] = [] if stmt: row = ibm_db.fetch_assoc(stmt) while row: param_name = row["COLUMN_NAME"] or f"PARAM_{row['ORDINAL_POSITION']}" param_type_code = row["COLUMN_TYPE"] type_map = {1: "IN", 2: "INOUT", 4: "OUT"} param_type = type_map.get(param_type_code, "UNKNOWN") params.append((param_name, param_type)) row = ibm_db.fetch_assoc(stmt) return params def _extract_output_parameters( self, result_params: tuple[Any, ...], out_indices: list[int], param_metadata: list[tuple[str, str]], ) -> tuple[list[Any], list[str]]: out_values: list[Any] = [result_params[i] for i in out_indices] out_columns: list[str] = [] for idx in out_indices: if idx < len(param_metadata): param_name, param_type = param_metadata[idx] if param_type in ("OUT", "INOUT"): out_columns.append(param_name) else: out_columns.append(f"OUT_PARAM_{idx + 1}") else: out_columns.append(f"OUT_PARAM_{idx + 1}") return out_values, out_columns