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

235 lines
8.1 KiB
Python
Raw Normal View History

2026-09-21 18:08:31 +08:00
from typing import Any
2026-09-22 17:02:46 +08:00
from datetime import datetime, date, time
from decimal import Decimal
from uuid import UUID
import base64
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):
pass
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
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]"
return text