Files
mini-db-mcp/src/mini_db_mcp/db.py
T

319 lines
11 KiB
Python
Raw Normal View History

2026-09-28 17:41:59 +08:00
import asyncio
import base64
import json
from time import monotonic
from typing import Any, Iterable, Sequence
2026-09-22 17:02:46 +08:00
from datetime import datetime, date, time
from decimal import Decimal
from uuid import UUID
from sqlalchemy import URL, inspect, Table, MetaData, select
2026-09-21 18:08:31 +08:00
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
2026-09-22 17:02:46 +08:00
from sqlalchemy.exc import SQLAlchemyError
2026-09-21 18:08:31 +08:00
from mini_db_mcp.config import Config
2026-09-22 17:02:46 +08:00
from mini_db_mcp.utils.pagination_util import paginate_list
2026-09-21 18:08:31 +08:00
2026-09-22 17:02:46 +08:00
class QueryExecutionError(RuntimeError):
2026-09-28 17:41:59 +08:00
def __init__(
self,
code: str,
message: str | None = None,
*,
retryable: bool = False,
) -> None:
self.code = code
self.message = message or code
self.retryable = retryable
super().__init__(self.message)
2026-09-22 17:02:46 +08:00
2026-09-21 18:08:31 +08:00
class Database:
def __init__(self, settings: Config):
self.settings = settings
self.database = settings.database
url = URL.create(
"mysql+aiomysql",
username=self.database.user,
password=self.database.password,
host=self.database.host,
port=self.database.port,
database=self.database.schema or None,
)
self.engine: AsyncEngine = create_async_engine(
url,
pool_size=self.database.pool_size,
max_overflow=self.database.max_overflow,
pool_timeout=self.database.pool_timeout,
pool_recycle=self.database.pool_recycle,
pool_pre_ping=self.database.pool_pre_ping,
connect_args={"connect_timeout": self.database.connect_timeout},
)
async def close(self) -> None:
await self.engine.dispose()
async def list_tables(
self,
*,
keyword: str | None,
limit: int,
cursor: str | None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Return one name-sorted page of tables and views.
``keyword`` filters object names case-insensitively. ``cursor`` must be
the last object name returned by the previous page. The returned cursor
is ``None`` when no additional objects remain.
Raises:
ValueError: If the cursor is not present in the filtered result.
"""
schema = self.settings.database.schema or None
def inspect_tables(sync_connection):
inspector = inspect(sync_connection)
table_names = set(inspector.get_table_names(schema=schema))
try:
view_names = set(inspector.get_view_names(schema=schema))
except NotImplementedError:
view_names = set()
return table_names, view_names
async with self.engine.connect() as connection:
table_names, view_names = await connection.run_sync(inspect_tables)
keyword_cf = keyword.casefold() if keyword else None
2026-09-22 17:02:46 +08:00
sorted_table_names = sorted(
2026-09-21 18:08:31 +08:00
name
for name in table_names | view_names
if keyword_cf is None or keyword_cf in name.casefold()
)
2026-09-22 17:02:46 +08:00
paged_table_names, next_cursor = paginate_list(
sorted_table_names,
cursor=cursor,
page_size=limit,
identifier=lambda name: name,
)
2026-09-21 18:08:31 +08:00
objects: list[dict[str, Any]] = []
2026-09-22 17:02:46 +08:00
for name in paged_table_names:
2026-09-21 18:08:31 +08:00
object_type = "view" if name in view_names else "table"
objects.append(
{
"schema_name": schema,
"object_name": name,
"object_type": object_type,
"description": None,
}
)
return objects, next_cursor
2026-09-22 17:02:46 +08:00
async def describe_table(
self,
*,
table_name: str,
schema: str | None = None,
limit: int,
cursor: str | None,
) -> dict[str, Any]:
"""Return one cursor-paginated page of a table's columns."""
effective_schema = schema or self.settings.database.schema or None
def inspect_table(sync_connection):
inspector = inspect(sync_connection)
columns = inspector.get_columns(table_name, schema=effective_schema)
primary_keys = set(
(
inspector.get_pk_constraint(
table_name,
schema=effective_schema,
)
or {}
).get("constrained_columns") or []
)
try:
table_description = (
inspector.get_table_comment(
table_name,
schema=effective_schema,
)
or {}
).get("text")
except NotImplementedError:
table_description = None
return columns, primary_keys, table_description
try:
async with self.engine.connect() as connection:
columns, primary_keys, table_description = await connection.run_sync(
inspect_table
)
except SQLAlchemyError as exc:
raise QueryExecutionError(f"Failed to read table metadata: {exc}") from exc
paged_columns, next_cursor = paginate_list(
columns,
cursor=cursor,
page_size=limit,
identifier=lambda column: column["name"],
)
visible_columns: list[dict[str, Any]] = []
for column in paged_columns:
visible_columns.append(
{
"name": column["name"],
"data_type": str(column.get("type")),
"nullable": column.get("nullable"),
"primary_key": column["name"] in primary_keys,
"description": column.get("comment"),
}
)
return {
"schema_name": effective_schema,
"table_name": table_name,
"table_description": table_description,
"columns": visible_columns,
"next_cursor": next_cursor,
"truncated": next_cursor is not None,
}
async def sample_values(
self,
*,
table_name: str,
schema: str | None = None,
column: str,
limit: int
) -> tuple[list[Any], bool]:
effective_schema = schema or self.settings.database.schema or None
metadata = MetaData()
try:
async with self.engine.connect() as connection:
table_obj = await connection.run_sync(
lambda sync_connection: Table(
table_name,
metadata,
schema=effective_schema,
autoload_with=sync_connection,
)
)
except SQLAlchemyError as exc:
raise QueryExecutionError("metadata_error", f"反射表结构失败:{exc}") from exc
try:
column_obj = table_obj.c[column]
except KeyError as exc:
raise QueryExecutionError(
"metadata_error", f"列不存在:{table_name}.{column}"
) from exc
statement = (
select(column_obj)
.where(column_obj.is_not(None))
.distinct()
.limit(limit + 1)
)
try:
async with self.engine.connect() as connection:
result = await connection.execute(statement)
values = [row[0] for row in result]
except SQLAlchemyError as exc:
raise QueryExecutionError("query_failed", f"采样查询失败:{exc}") from exc
truncated = len(values) > limit
return [
_serialize_scalar(
value,
max_chars=self.settings.query.max_cell_chars,
)
for value in values[:limit]
], truncated
2026-09-28 17:41:59 +08:00
async def execute_guarded(self, sql: str) -> dict[str, Any]:
start = monotonic()
try:
async with asyncio.timeout(self.settings.query.timeout_seconds):
async with self.engine.connect() as connection:
result = await connection.exec_driver_sql(sql)
columns = list(result.keys())
raw_rows: Iterable[Any] = result
rows, truncated = bound_rows(
columns,
raw_rows,
max_rows=self.settings.query.max_rows,
max_bytes=self.settings.query.max_bytes,
max_cell_chars=self.settings.query.max_cell_chars,
)
except SQLAlchemyError as exc:
raise QueryExecutionError(
"query_failed",
"查询执行失败,请检查 SQL、权限及数据库状态。",
) from exc
except TimeoutError as exc:
raise QueryExecutionError(
"query_timeout",
"查询超过执行时间上限,已中断。",
retryable=True,
) from exc
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"elapsed_ms": int((monotonic() - start) * 1000),
}
def bound_rows(
columns: Sequence[str],
source_rows: Iterable[Sequence[Any]],
*,
max_rows: int,
max_bytes: int,
max_cell_chars: int,
) -> tuple[list[list[Any]], bool]:
"""Bound model-facing output by rows and serialized byte size.
This only limits what is returned to the model. It does not limit the amount of
data the database may scan; query timeouts and database workload governance are
separate controls.
"""
rows: list[list[Any]] = []
truncated = False
base_size = len(json.dumps({"columns": list(columns), "rows": []}, ensure_ascii=False).encode("utf-8"))
current_size = base_size
for raw_row in source_rows:
if len(rows) >= max_rows:
truncated = True
break
row = [_serialize_scalar(value, max_chars=max_cell_chars) for value in raw_row]
row_size = len(json.dumps(row, ensure_ascii=False, default=str).encode("utf-8"))
if current_size + row_size > max_bytes:
truncated = True
break
rows.append(row)
current_size += row_size
return rows, truncated
2026-09-22 17:02:46 +08:00
def _serialize_scalar(value: Any, *, max_chars: int) -> Any:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (datetime, date, time, UUID)):
return value.isoformat() if hasattr(value, "isoformat") else str(value)
if isinstance(value, (bytes, bytearray, memoryview)):
text = base64.b64encode(bytes(value)).decode("ascii")
else:
text = str(value)
if len(text) > max_chars:
return text[:max_chars] + "…[truncated]"
2026-09-28 17:41:59 +08:00
return text