mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 01:48:03 +00:00
1320 lines
49 KiB
Python
1320 lines
49 KiB
Python
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 "<cursor_name>" 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
|