feat: 三大核心功能完成 — query_history持久化 + EXPLAIN真实解析 + Dashboard修复
P1 查询历史持久化 (47 tests): - QueryHistory/QuickQuery SQLAlchemy model 建表 - query_history_service 从内存迁移到 SQLite async - 每用户500条上限自动清理 - 7个API endpoint全部对接持久化 - 10个预置快捷查询模板 P2 EXPLAIN真实解析 (40 tests): - MySQL EXPLAIN FORMAT=JSON 执行+解析 - PostgreSQL EXPLAIN (FORMAT JSON) 执行+解析 - SQLite EXPLAIN QUERY PLAN 解析 - 统一树结构输出 + 优化警告 - POST /api/v1/sql-assistant/explain endpoint Dashboard修复: - 修复重复export default导致页面崩溃 - 对接/instances /tickets /dashboard/stats真实API - 替换硬编码统计数字为API驱动 - 修复calcHealthScore安全访问 AuditLogsPage: - 修复Fragment key warning 1081 tests passed (10 system_settings failures are pre-existing)
This commit is contained in:
1 parent
1fcb984aee
commit
c7fccf99c7
8 files changed
+2274
-400
No files matched your search
@@ -180,6 +180,7 @@ async def execute_query(
|
|||||||
try:
|
try:
|
||||||
username = current_user.display_name or current_user.username
|
username = current_user.display_name or current_user.username
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
username=username,
|
username=username,
|
||||||
instance_id=instance.id,
|
instance_id=instance.id,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async def my_query_history(
|
|||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取我的查询历史。
|
"""获取我的查询历史。
|
||||||
@@ -25,6 +26,7 @@ async def my_query_history(
|
|||||||
自动记录每次查询,支持按数据库类型、实例、关键词筛选。
|
自动记录每次查询,支持按数据库类型、实例、关键词筛选。
|
||||||
"""
|
"""
|
||||||
return await QueryHistoryService.get_history(
|
return await QueryHistoryService.get_history(
|
||||||
|
db=db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
db_type=db_type,
|
db_type=db_type,
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
@@ -37,10 +39,11 @@ async def my_query_history(
|
|||||||
@router.post("/{history_id}/favorite")
|
@router.post("/{history_id}/favorite")
|
||||||
async def toggle_favorite(
|
async def toggle_favorite(
|
||||||
history_id: int,
|
history_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""切换查询历史收藏状态。"""
|
"""切换查询历史收藏状态。"""
|
||||||
is_fav = await QueryHistoryService.toggle_favorite(current_user.id, history_id)
|
is_fav = await QueryHistoryService.toggle_favorite(db, current_user.id, history_id)
|
||||||
return {"is_favorite": is_fav, "message": "已收藏" if is_fav else "已取消收藏"}
|
return {"is_favorite": is_fav, "message": "已收藏" if is_fav else "已取消收藏"}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,20 +51,22 @@ async def toggle_favorite(
|
|||||||
async def my_favorites(
|
async def my_favorites(
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取我收藏的查询。"""
|
"""获取我收藏的查询。"""
|
||||||
return await QueryHistoryService.get_favorites(
|
return await QueryHistoryService.get_favorites(
|
||||||
user_id=current_user.id, page=page, page_size=page_size
|
db=db, user_id=current_user.id, page=page, page_size=page_size
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/statistics")
|
@router.get("/statistics")
|
||||||
async def my_query_statistics(
|
async def my_query_statistics(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取我的查询统计 — 了解自己的数据使用习惯。"""
|
"""获取我的查询统计 — 了解自己的数据使用习惯。"""
|
||||||
return await QueryHistoryService.get_query_statistics(user_id=current_user.id)
|
return await QueryHistoryService.get_query_statistics(db=db, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
# ── 快捷查询 ──
|
# ── 快捷查询 ──
|
||||||
@@ -70,11 +75,12 @@ async def my_query_statistics(
|
|||||||
async def list_quick_queries(
|
async def list_quick_queries(
|
||||||
db_type: Optional[str] = None,
|
db_type: Optional[str] = None,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取快捷查询列表(个人+团队共享)。"""
|
"""获取快捷查询列表(个人+团队共享)。"""
|
||||||
items = await QueryHistoryService.get_quick_queries(
|
items = await QueryHistoryService.get_quick_queries(
|
||||||
user_id=current_user.id, db_type=db_type, keyword=keyword
|
db=db, user_id=current_user.id, db_type=db_type, keyword=keyword
|
||||||
)
|
)
|
||||||
return {"items": items, "total": len(items)}
|
return {"items": items, "total": len(items)}
|
||||||
|
|
||||||
@@ -87,10 +93,12 @@ async def create_quick_query(
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
database_pattern: str = "*",
|
database_pattern: str = "*",
|
||||||
is_shared: bool = False,
|
is_shared: bool = False,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""创建快捷查询。"""
|
"""创建快捷查询。"""
|
||||||
qq = await QueryHistoryService.create_quick_query(
|
qq = await QueryHistoryService.create_quick_query(
|
||||||
|
db=db,
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
query_content=query_content,
|
query_content=query_content,
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"""SQL助手 API — 人性化辅助功能。"""
|
"""SQL助手 API — 人性化辅助功能。"""
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.crypto import decrypt_password
|
||||||
|
from app.core.permissions import check_instance_access, check_instance_permission
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.instance import Instance
|
||||||
from app.services.sql_assistant import SQLAssistant
|
from app.services.sql_assistant import SQLAssistant
|
||||||
|
from app.services.explain_service import ExplainService
|
||||||
|
|
||||||
router = APIRouter(prefix="/sql-assistant", tags=["SQL助手"])
|
router = APIRouter(prefix="/sql-assistant", tags=["SQL助手"])
|
||||||
|
|
||||||
@@ -111,3 +120,63 @@ async def parse_explain(
|
|||||||
"warnings": result.warnings,
|
"warnings": result.warnings,
|
||||||
"human_readable": result.human_readable,
|
"human_readable": result.human_readable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== EXPLAIN 真实执行 API =====
|
||||||
|
|
||||||
|
|
||||||
|
class ExplainRequest(BaseModel):
|
||||||
|
"""EXPLAIN 请求体。"""
|
||||||
|
instance_id: int
|
||||||
|
database: str
|
||||||
|
query: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/explain")
|
||||||
|
async def execute_explain(
|
||||||
|
req: ExplainRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""连接数据源执行 EXPLAIN 并解析为统一树结构。
|
||||||
|
|
||||||
|
根据数据源类型自动构造对应的 EXPLAIN 语句:
|
||||||
|
- MySQL: EXPLAIN FORMAT=JSON <query>
|
||||||
|
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
|
||||||
|
- SQLite: EXPLAIN QUERY PLAN <query>
|
||||||
|
|
||||||
|
返回统一的树形执行计划、优化警告和汇总统计。
|
||||||
|
"""
|
||||||
|
if current_user.role == "guest":
|
||||||
|
raise HTTPException(status_code=403, detail="访客无权执行 EXPLAIN,请联系管理员申请权限")
|
||||||
|
|
||||||
|
if not req.query or not req.query.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="查询语句不能为空")
|
||||||
|
|
||||||
|
# 获取实例
|
||||||
|
result = await db.execute(select(Instance).where(Instance.id == req.instance_id))
|
||||||
|
instance = result.scalar_one_or_none()
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(status_code=404, detail="数据源不存在,请确认实例ID正确")
|
||||||
|
|
||||||
|
# 权限检查
|
||||||
|
await check_instance_access(current_user, instance, db)
|
||||||
|
await check_instance_permission(current_user, instance, "query:select", db)
|
||||||
|
|
||||||
|
# 解密密码
|
||||||
|
instance.password = decrypt_password(instance.password)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
explain_result = await ExplainService.execute_explain(
|
||||||
|
instance, req.database, req.query
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"EXPLAIN 执行失败: {e}")
|
||||||
|
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
|
explain_result["elapsed_ms"] = elapsed_ms
|
||||||
|
|
||||||
|
return explain_result
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
"""EXPLAIN 执行服务 — 连接数据源执行 EXPLAIN 并解析为统一树结构。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- MySQL: EXPLAIN FORMAT=JSON <query>
|
||||||
|
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
|
||||||
|
- SQLite: EXPLAIN QUERY PLAN <query>
|
||||||
|
|
||||||
|
返回统一树结构:
|
||||||
|
{
|
||||||
|
"execution_plan": [...], # 树形执行计划节点
|
||||||
|
"warnings": [...], # 优化警告
|
||||||
|
"summary": {...}, # 汇总统计
|
||||||
|
"db_type": "mysql",
|
||||||
|
"raw_result": {...}, # 原始结果
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# 顶层 import 以便测试 mock(实际运行时为可选依赖)
|
||||||
|
try:
|
||||||
|
import aiomysql # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
aiomysql = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import asyncpg # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
asyncpg = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from app.models.instance import Instance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 统一节点结构 =====
|
||||||
|
|
||||||
|
def make_plan_node(
|
||||||
|
id: int,
|
||||||
|
operation: str,
|
||||||
|
table: Optional[str] = None,
|
||||||
|
rows: Optional[int] = None,
|
||||||
|
cost: Optional[float] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
extra: Optional[str] = None,
|
||||||
|
children: Optional[List[Dict]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""构建统一执行计划节点。"""
|
||||||
|
node = {
|
||||||
|
"id": id,
|
||||||
|
"operation": operation,
|
||||||
|
"table": table,
|
||||||
|
"rows": rows,
|
||||||
|
"cost": cost,
|
||||||
|
"key": key,
|
||||||
|
"extra": extra,
|
||||||
|
"children": children or [],
|
||||||
|
}
|
||||||
|
# 附加额外字段
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if v is not None:
|
||||||
|
node[k] = v
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
# ===== MySQL EXPLAIN FORMAT=JSON 解析 =====
|
||||||
|
|
||||||
|
def parse_mysql_explain_json(raw_json: Any) -> Dict[str, Any]:
|
||||||
|
"""解析 MySQL EXPLAIN FORMAT=JSON 的结果。
|
||||||
|
|
||||||
|
MySQL EXPLAIN FORMAT=JSON 结构示例:
|
||||||
|
{
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "0.35"},
|
||||||
|
"table": {
|
||||||
|
"table_name": "users",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 1000,
|
||||||
|
"rows_produced_per_join": 1000,
|
||||||
|
"filtered": "100.00",
|
||||||
|
"cost_info": {...},
|
||||||
|
"used_columns": [...]
|
||||||
|
},
|
||||||
|
"nested_loop": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一结构的 Dict
|
||||||
|
"""
|
||||||
|
if isinstance(raw_json, str):
|
||||||
|
try:
|
||||||
|
raw_json = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return _empty_result("mysql", raw_json, error="EXPLAIN JSON 解析失败")
|
||||||
|
|
||||||
|
nodes: List[Dict] = []
|
||||||
|
warnings: List[str] = []
|
||||||
|
node_id_counter = [0]
|
||||||
|
|
||||||
|
def next_id() -> int:
|
||||||
|
node_id_counter[0] += 1
|
||||||
|
return node_id_counter[0]
|
||||||
|
|
||||||
|
def parse_query_block(qb: Dict) -> Dict[str, Any]:
|
||||||
|
"""递归解析 query_block。"""
|
||||||
|
node_id = next_id()
|
||||||
|
children = []
|
||||||
|
operation = "Query Block"
|
||||||
|
table_name = None
|
||||||
|
access_type = None
|
||||||
|
key_used = None
|
||||||
|
rows_est = None
|
||||||
|
cost_val = None
|
||||||
|
extra_parts = []
|
||||||
|
|
||||||
|
# 表信息
|
||||||
|
table_info = qb.get("table")
|
||||||
|
if table_info:
|
||||||
|
table_name = table_info.get("table_name")
|
||||||
|
access_type = table_info.get("access_type", "")
|
||||||
|
key_used = table_info.get("key")
|
||||||
|
possible_keys = table_info.get("possible_keys")
|
||||||
|
rows_est = table_info.get("rows_examined_per_scan")
|
||||||
|
filtered = table_info.get("filtered", "100.00")
|
||||||
|
|
||||||
|
# 成本
|
||||||
|
cost_info = table_info.get("cost_info", {})
|
||||||
|
cost_str = cost_info.get("read_cost") or cost_info.get("eval_cost") or cost_info.get("prefix_cost")
|
||||||
|
if cost_str:
|
||||||
|
try:
|
||||||
|
cost_val = float(cost_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 操作类型映射
|
||||||
|
operation = _mysql_access_type_to_operation(access_type)
|
||||||
|
|
||||||
|
# Extra 信息
|
||||||
|
if key_used:
|
||||||
|
extra_parts.append(f"使用索引: {key_used}")
|
||||||
|
if possible_keys and not key_used:
|
||||||
|
extra_parts.append(f"可选索引: {possible_keys}")
|
||||||
|
if filtered and filtered != "100.00":
|
||||||
|
extra_parts.append(f"过滤率: {filtered}%")
|
||||||
|
|
||||||
|
used_key_parts = table_info.get("used_key_parts")
|
||||||
|
if used_key_parts:
|
||||||
|
extra_parts.append(f"索引列: {', '.join(str(p) for p in used_key_parts)}")
|
||||||
|
|
||||||
|
# 警告检测
|
||||||
|
if access_type == "ALL":
|
||||||
|
warnings.append(f"表 '{table_name}' 全表扫描,建议添加索引")
|
||||||
|
if access_type == "index":
|
||||||
|
warnings.append(f"表 '{table_name}' 全索引扫描")
|
||||||
|
|
||||||
|
# 嵌套循环 / join
|
||||||
|
nested_loop = qb.get("nested_loop")
|
||||||
|
if nested_loop:
|
||||||
|
for nl_item in nested_loop:
|
||||||
|
child = parse_query_block(nl_item)
|
||||||
|
children.append(child)
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
order_by = qb.get("ordering_operation")
|
||||||
|
if order_by:
|
||||||
|
sort_node = _parse_mysql_ordering(order_by, next_id())
|
||||||
|
children.append(sort_node)
|
||||||
|
extra_parts.append("Using filesort")
|
||||||
|
warnings.append("使用了文件排序 (filesort)")
|
||||||
|
|
||||||
|
# 分组
|
||||||
|
grouping = qb.get("grouping_operation")
|
||||||
|
if grouping:
|
||||||
|
group_node = _parse_mysql_grouping(grouping, next_id())
|
||||||
|
children.append(group_node)
|
||||||
|
extra_parts.append("Using temporary")
|
||||||
|
warnings.append("使用了临时表 (temporary)")
|
||||||
|
|
||||||
|
# 子查询
|
||||||
|
subqueries = qb.get("subqueries") or qb.get("optimized_away_subqueries")
|
||||||
|
if subqueries:
|
||||||
|
for sq in subqueries:
|
||||||
|
if "query_block" in sq:
|
||||||
|
child = parse_query_block(sq["query_block"])
|
||||||
|
children.append(child)
|
||||||
|
|
||||||
|
# 成本信息
|
||||||
|
qb_cost = qb.get("cost_info", {})
|
||||||
|
if not cost_val and "query_cost" in qb_cost:
|
||||||
|
try:
|
||||||
|
cost_val = float(qb_cost["query_cost"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return make_plan_node(
|
||||||
|
id=node_id,
|
||||||
|
operation=operation,
|
||||||
|
table=table_name,
|
||||||
|
rows=int(rows_est) if rows_est else None,
|
||||||
|
cost=cost_val,
|
||||||
|
key=key_used,
|
||||||
|
extra="; ".join(extra_parts) if extra_parts else None,
|
||||||
|
children=children,
|
||||||
|
access_type=access_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析顶层 query_block
|
||||||
|
query_block = raw_json.get("query_block") if isinstance(raw_json, dict) else None
|
||||||
|
if query_block:
|
||||||
|
root = parse_query_block(query_block)
|
||||||
|
nodes = [root]
|
||||||
|
else:
|
||||||
|
warnings.append("无法识别的 EXPLAIN JSON 结构")
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
summary = _build_summary(nodes, warnings, "mysql")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"execution_plan": nodes,
|
||||||
|
"warnings": warnings,
|
||||||
|
"summary": summary,
|
||||||
|
"db_type": "mysql",
|
||||||
|
"raw_result": raw_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mysql_ordering(ordering: Dict, node_id: int) -> Dict:
|
||||||
|
"""解析 MySQL 排序操作。"""
|
||||||
|
table_info = ordering.get("table", {})
|
||||||
|
return make_plan_node(
|
||||||
|
id=node_id,
|
||||||
|
operation="Filesort",
|
||||||
|
table=table_info.get("table_name"),
|
||||||
|
extra="排序操作",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mysql_grouping(grouping: Dict, node_id: int) -> Dict:
|
||||||
|
"""解析 MySQL 分组操作。"""
|
||||||
|
table_info = grouping.get("table", {})
|
||||||
|
return make_plan_node(
|
||||||
|
id=node_id,
|
||||||
|
operation="Temporary Table",
|
||||||
|
table=table_info.get("table_name"),
|
||||||
|
extra="分组操作使用临时表",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mysql_access_type_to_operation(access_type: Optional[str]) -> str:
|
||||||
|
"""将 MySQL access_type 映射为统一操作名。"""
|
||||||
|
mapping = {
|
||||||
|
"system": "System Scan",
|
||||||
|
"const": "Const Row",
|
||||||
|
"eq_ref": "Unique Lookup",
|
||||||
|
"ref": "Index Lookup",
|
||||||
|
"fulltext": "Fulltext Search",
|
||||||
|
"ref_or_null": "Index Lookup (nullable)",
|
||||||
|
"index_merge": "Index Merge",
|
||||||
|
"unique_subquery": "Unique Subquery",
|
||||||
|
"index_subquery": "Index Subquery",
|
||||||
|
"range": "Index Range Scan",
|
||||||
|
"index": "Full Index Scan",
|
||||||
|
"ALL": "Table Scan",
|
||||||
|
}
|
||||||
|
return mapping.get(access_type or "", f"Unknown ({access_type})")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PostgreSQL EXPLAIN (FORMAT JSON) 解析 =====
|
||||||
|
|
||||||
|
def parse_postgres_explain_json(raw_json: Any) -> Dict[str, Any]:
|
||||||
|
"""解析 PostgreSQL EXPLAIN (FORMAT JSON) 的结果。
|
||||||
|
|
||||||
|
PostgreSQL EXPLAIN JSON 结构示例:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "users",
|
||||||
|
"Alias": "users",
|
||||||
|
"Startup Cost": 0.00,
|
||||||
|
"Total Cost": 15.50,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
"Plan Width": 68,
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Index Scan",
|
||||||
|
...
|
||||||
|
"Plans": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一结构的 Dict
|
||||||
|
"""
|
||||||
|
if isinstance(raw_json, str):
|
||||||
|
try:
|
||||||
|
raw_json = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return _empty_result("postgres", raw_json, error="EXPLAIN JSON 解析失败")
|
||||||
|
|
||||||
|
# PG 返回的是数组
|
||||||
|
if isinstance(raw_json, list) and len(raw_json) > 0:
|
||||||
|
plan_root = raw_json[0].get("Plan", raw_json[0])
|
||||||
|
elif isinstance(raw_json, dict):
|
||||||
|
plan_root = raw_json.get("Plan", raw_json)
|
||||||
|
else:
|
||||||
|
return _empty_result("postgres", raw_json, error="无法识别的 EXPLAIN JSON 结构")
|
||||||
|
|
||||||
|
warnings: List[str] = []
|
||||||
|
node_id_counter = [0]
|
||||||
|
|
||||||
|
def next_id() -> int:
|
||||||
|
node_id_counter[0] += 1
|
||||||
|
return node_id_counter[0]
|
||||||
|
|
||||||
|
def parse_pg_plan(plan: Dict) -> Dict[str, Any]:
|
||||||
|
"""递归解析 PostgreSQL Plan 节点。"""
|
||||||
|
node_id = next_id()
|
||||||
|
children = []
|
||||||
|
|
||||||
|
node_type = plan.get("Node Type", "Unknown")
|
||||||
|
relation_name = plan.get("Relation Name") or plan.get("Table Name")
|
||||||
|
alias = plan.get("Alias")
|
||||||
|
startup_cost = plan.get("Startup Cost")
|
||||||
|
total_cost = plan.get("Total Cost")
|
||||||
|
plan_rows = plan.get("Plan Rows")
|
||||||
|
plan_width = plan.get("Plan Width")
|
||||||
|
index_name = plan.get("Index Name")
|
||||||
|
index_cond = plan.get("Index Cond")
|
||||||
|
filter_str = plan.get("Filter")
|
||||||
|
hash_cond = plan.get("Hash Cond")
|
||||||
|
join_type = plan.get("Join Type")
|
||||||
|
sort_key = plan.get("Sort Key")
|
||||||
|
sort_method = plan.get("Sort Method")
|
||||||
|
group_key = plan.get("Group Key")
|
||||||
|
actual_rows = plan.get("Actual Rows")
|
||||||
|
actual_time = plan.get("Actual Total Time")
|
||||||
|
|
||||||
|
# 操作类型映射
|
||||||
|
operation = _pg_node_type_to_operation(node_type)
|
||||||
|
if join_type:
|
||||||
|
operation = f"{join_type} {_pg_node_type_to_operation(node_type)}"
|
||||||
|
|
||||||
|
# Extra 信息
|
||||||
|
extra_parts = []
|
||||||
|
if index_cond:
|
||||||
|
extra_parts.append(f"索引条件: {index_cond}")
|
||||||
|
if filter_str:
|
||||||
|
extra_parts.append(f"过滤条件: {filter_str}")
|
||||||
|
if hash_cond:
|
||||||
|
extra_parts.append(f"Hash 条件: {hash_cond}")
|
||||||
|
if sort_key:
|
||||||
|
extra_parts.append(f"排序键: {sort_key}")
|
||||||
|
if sort_method:
|
||||||
|
extra_parts.append(f"排序方法: {sort_method}")
|
||||||
|
if group_key:
|
||||||
|
extra_parts.append(f"分组键: {group_key}")
|
||||||
|
if actual_rows is not None:
|
||||||
|
extra_parts.append(f"实际行数: {actual_rows}")
|
||||||
|
if actual_time is not None:
|
||||||
|
extra_parts.append(f"实际耗时: {actual_time}ms")
|
||||||
|
|
||||||
|
# 警告检测
|
||||||
|
if node_type == "Seq Scan":
|
||||||
|
warnings.append(f"表 '{relation_name or '?'}' 使用了顺序扫描 (Seq Scan),建议检查索引")
|
||||||
|
if node_type == "Sort" and sort_method == "external" or plan.get("Sort Space Used"):
|
||||||
|
warnings.append(f"排序使用了外部排序 (磁盘),建议增加 work_mem")
|
||||||
|
|
||||||
|
# 子计划
|
||||||
|
sub_plans = plan.get("Plans", [])
|
||||||
|
for sp in sub_plans:
|
||||||
|
children.append(parse_pg_plan(sp))
|
||||||
|
|
||||||
|
# 子查询/SubPlan
|
||||||
|
subplan_name = plan.get("Subplan Name")
|
||||||
|
if subplan_name:
|
||||||
|
extra_parts.append(f"子计划: {subplan_name}")
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if alias and alias != relation_name:
|
||||||
|
kwargs["alias"] = alias
|
||||||
|
if plan_width:
|
||||||
|
kwargs["plan_width"] = plan_width
|
||||||
|
if startup_cost is not None:
|
||||||
|
kwargs["startup_cost"] = startup_cost
|
||||||
|
if actual_time is not None:
|
||||||
|
kwargs["actual_time_ms"] = actual_time
|
||||||
|
if actual_rows is not None:
|
||||||
|
kwargs["actual_rows"] = actual_rows
|
||||||
|
|
||||||
|
return make_plan_node(
|
||||||
|
id=node_id,
|
||||||
|
operation=operation,
|
||||||
|
table=relation_name,
|
||||||
|
rows=int(plan_rows) if plan_rows else None,
|
||||||
|
cost=float(total_cost) if total_cost is not None else None,
|
||||||
|
key=index_name,
|
||||||
|
extra="; ".join(extra_parts) if extra_parts else None,
|
||||||
|
children=children,
|
||||||
|
node_type=node_type,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = parse_pg_plan(plan_root)
|
||||||
|
nodes = [root]
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
summary = _build_summary(nodes, warnings, "postgres")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"execution_plan": nodes,
|
||||||
|
"warnings": warnings,
|
||||||
|
"summary": summary,
|
||||||
|
"db_type": "postgres",
|
||||||
|
"raw_result": raw_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_node_type_to_operation(node_type: str) -> str:
|
||||||
|
"""将 PostgreSQL Node Type 映射为统一操作名。"""
|
||||||
|
mapping = {
|
||||||
|
"Seq Scan": "Table Scan",
|
||||||
|
"Index Scan": "Index Scan",
|
||||||
|
"Index Only Scan": "Index Only Scan",
|
||||||
|
"Bitmap Heap Scan": "Bitmap Heap Scan",
|
||||||
|
"Bitmap Index Scan": "Bitmap Index Scan",
|
||||||
|
"Tid Scan": "TID Scan",
|
||||||
|
"Subquery Scan": "Subquery Scan",
|
||||||
|
"Function Scan": "Function Scan",
|
||||||
|
"Table Function Scan": "Table Function Scan",
|
||||||
|
"Values Scan": "Values Scan",
|
||||||
|
"CTE Scan": "CTE Scan",
|
||||||
|
"Named Tuplestore Scan": "Named Tuplestore Scan",
|
||||||
|
"WorkTable Scan": "WorkTable Scan",
|
||||||
|
"Nested Loop": "Nested Loop Join",
|
||||||
|
"Merge Join": "Merge Join",
|
||||||
|
"Hash Join": "Hash Join",
|
||||||
|
"Sort": "Sort",
|
||||||
|
"Hash": "Hash",
|
||||||
|
"Aggregate": "Aggregate",
|
||||||
|
"Group": "Group",
|
||||||
|
"Append": "Append",
|
||||||
|
"Merge Append": "Merge Append",
|
||||||
|
"Limit": "Limit",
|
||||||
|
"Unique": "Unique",
|
||||||
|
"Gather": "Gather",
|
||||||
|
"Gather Merge": "Gather Merge",
|
||||||
|
"Materialize": "Materialize",
|
||||||
|
"Result": "Result",
|
||||||
|
"ProjectSet": "ProjectSet",
|
||||||
|
"WindowAgg": "Window Aggregate",
|
||||||
|
"Recursive Union": "Recursive Union",
|
||||||
|
"LockRows": "Lock Rows",
|
||||||
|
"ModifyTable": "Modify Table",
|
||||||
|
"SetOp": "Set Operation",
|
||||||
|
}
|
||||||
|
return mapping.get(node_type, node_type)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== SQLite EXPLAIN QUERY PLAN 解析 =====
|
||||||
|
|
||||||
|
def parse_sqlite_explain(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""解析 SQLite EXPLAIN QUERY PLAN 的文本/行结果。
|
||||||
|
|
||||||
|
SQLite 返回格式:
|
||||||
|
id|parent|notused|detail
|
||||||
|
3|0|0|SCAN TABLE users
|
||||||
|
或
|
||||||
|
id|parent|notused|detail
|
||||||
|
2|0|0|SEARCH TABLE users USING INDEX idx_name (name=?)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一结构的 Dict
|
||||||
|
"""
|
||||||
|
warnings: List[str] = []
|
||||||
|
node_id_counter = [0]
|
||||||
|
node_map: Dict[int, Dict] = {}
|
||||||
|
|
||||||
|
def next_id() -> int:
|
||||||
|
node_id_counter[0] += 1
|
||||||
|
return node_id_counter[0]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# SQLite EXPLAIN QUERY PLAN 返回: id, parent, notused, detail
|
||||||
|
detail = row.get("detail") or row.get("DETAIL") or ""
|
||||||
|
parent_id = row.get("parent") or row.get("PARENT") or 0
|
||||||
|
sqlite_id = row.get("id") or row.get("ID") or next_id()
|
||||||
|
|
||||||
|
table_name = None
|
||||||
|
operation = "Unknown"
|
||||||
|
key = None
|
||||||
|
extra = None
|
||||||
|
|
||||||
|
detail_upper = detail.upper()
|
||||||
|
|
||||||
|
# 解析 detail 文本
|
||||||
|
if "SCAN TABLE" in detail_upper:
|
||||||
|
operation = "Table Scan"
|
||||||
|
# 提取表名: "SCAN TABLE users"
|
||||||
|
parts = detail.split("TABLE", 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
table_part = parts[1].strip().split()[0] if parts[1].strip() else None
|
||||||
|
table_name = table_part
|
||||||
|
warnings.append(f"表 '{table_name or '?'}' 全表扫描,建议添加索引")
|
||||||
|
elif "SEARCH TABLE" in detail_upper:
|
||||||
|
operation = "Index Lookup"
|
||||||
|
parts = detail.split("TABLE", 1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
rest = parts[1].strip()
|
||||||
|
tokens = rest.split()
|
||||||
|
table_name = tokens[0] if tokens else None
|
||||||
|
# 提取索引名: "USING INDEX idx_name (col=?)"
|
||||||
|
if "USING INDEX" in detail_upper:
|
||||||
|
idx_parts = detail.split("USING INDEX", 1)
|
||||||
|
if len(idx_parts) > 1:
|
||||||
|
idx_rest = idx_parts[1].strip().split("(")[0].strip()
|
||||||
|
key = idx_rest
|
||||||
|
elif "USING COVERING INDEX" in detail_upper:
|
||||||
|
idx_parts = detail.split("USING COVERING INDEX", 1)
|
||||||
|
if len(idx_parts) > 1:
|
||||||
|
idx_rest = idx_parts[1].strip().split("(")[0].strip()
|
||||||
|
key = idx_rest
|
||||||
|
extra = "Covering Index"
|
||||||
|
elif "EXECUTE" in detail_upper:
|
||||||
|
operation = "Execute"
|
||||||
|
extra = detail
|
||||||
|
elif "CORRELATED SCALAR SUBQUERY" in detail_upper:
|
||||||
|
operation = "Correlated Subquery"
|
||||||
|
extra = detail
|
||||||
|
elif "CO-ROUTINE" in detail_upper:
|
||||||
|
operation = "Co-routine"
|
||||||
|
extra = detail
|
||||||
|
elif "SCAN" in detail_upper and "TABLE" not in detail_upper:
|
||||||
|
operation = "Scan"
|
||||||
|
extra = detail
|
||||||
|
else:
|
||||||
|
operation = detail if detail else "Unknown"
|
||||||
|
|
||||||
|
node = make_plan_node(
|
||||||
|
id=next_id(),
|
||||||
|
operation=operation,
|
||||||
|
table=table_name,
|
||||||
|
key=key,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
node_map[sqlite_id] = node
|
||||||
|
|
||||||
|
# 构建树(基于 parent)
|
||||||
|
root_nodes = []
|
||||||
|
for sqlite_id, node in node_map.items():
|
||||||
|
parent = None
|
||||||
|
for row in rows:
|
||||||
|
rid = row.get("id") or row.get("ID")
|
||||||
|
if rid == sqlite_id:
|
||||||
|
parent = row.get("parent") or row.get("PARENT") or 0
|
||||||
|
break
|
||||||
|
if parent == 0 or parent not in node_map:
|
||||||
|
root_nodes.append(node)
|
||||||
|
else:
|
||||||
|
parent_node = node_map[parent]
|
||||||
|
parent_node["children"].append(node)
|
||||||
|
|
||||||
|
if not root_nodes and node_map:
|
||||||
|
root_nodes = list(node_map.values())
|
||||||
|
|
||||||
|
summary = _build_summary(root_nodes, warnings, "sqlite")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"execution_plan": root_nodes,
|
||||||
|
"warnings": warnings,
|
||||||
|
"summary": summary,
|
||||||
|
"db_type": "sqlite",
|
||||||
|
"raw_result": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 辅助函数 =====
|
||||||
|
|
||||||
|
def _empty_result(db_type: str, raw: Any, error: str = "") -> Dict[str, Any]:
|
||||||
|
"""返回空结果(解析失败时)。"""
|
||||||
|
return {
|
||||||
|
"execution_plan": [],
|
||||||
|
"warnings": [error] if error else ["EXPLAIN 结果为空"],
|
||||||
|
"summary": {
|
||||||
|
"total_cost": 0,
|
||||||
|
"estimated_rows": 0,
|
||||||
|
"has_full_scan": False,
|
||||||
|
"has_temporary": False,
|
||||||
|
"has_filesort": False,
|
||||||
|
"index_usage_ratio": 0.0,
|
||||||
|
},
|
||||||
|
"db_type": db_type,
|
||||||
|
"raw_result": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(
|
||||||
|
nodes: List[Dict],
|
||||||
|
warnings: List[str],
|
||||||
|
db_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""从执行计划树构建汇总信息。"""
|
||||||
|
total_cost = 0.0
|
||||||
|
total_rows = 0
|
||||||
|
has_full_scan = False
|
||||||
|
has_temporary = False
|
||||||
|
has_filesort = False
|
||||||
|
index_used_count = 0
|
||||||
|
total_table_count = 0
|
||||||
|
|
||||||
|
def walk(node: Dict):
|
||||||
|
nonlocal total_cost, total_rows, has_full_scan, has_temporary, has_filesort
|
||||||
|
nonlocal index_used_count, total_table_count
|
||||||
|
|
||||||
|
if node.get("cost") is not None:
|
||||||
|
total_cost = max(total_cost, float(node["cost"]))
|
||||||
|
|
||||||
|
if node.get("rows") is not None:
|
||||||
|
total_rows += int(node["rows"])
|
||||||
|
|
||||||
|
op = (node.get("operation") or "").lower()
|
||||||
|
extra = str(node.get("extra") or "").lower()
|
||||||
|
|
||||||
|
if "table scan" in op or "seq scan" in op or op == "system scan":
|
||||||
|
has_full_scan = True
|
||||||
|
total_table_count += 1
|
||||||
|
elif "index" in op or "lookup" in op:
|
||||||
|
index_used_count += 1
|
||||||
|
total_table_count += 1
|
||||||
|
elif "scan" in op:
|
||||||
|
total_table_count += 1
|
||||||
|
|
||||||
|
if "temporary" in op or "temporary" in extra:
|
||||||
|
has_temporary = True
|
||||||
|
if "filesort" in op or "sort" in op or "filesort" in extra:
|
||||||
|
has_filesort = True
|
||||||
|
|
||||||
|
for child in node.get("children", []):
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
for n in nodes:
|
||||||
|
walk(n)
|
||||||
|
|
||||||
|
index_usage_ratio = 0.0
|
||||||
|
if total_table_count > 0:
|
||||||
|
index_usage_ratio = round(index_used_count / total_table_count, 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_cost": round(total_cost, 4),
|
||||||
|
"estimated_rows": total_rows,
|
||||||
|
"has_full_scan": has_full_scan,
|
||||||
|
"has_temporary": has_temporary,
|
||||||
|
"has_filesort": has_filesort,
|
||||||
|
"index_usage_ratio": index_usage_ratio,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== EXPLAIN 执行入口 =====
|
||||||
|
|
||||||
|
class ExplainService:
|
||||||
|
"""EXPLAIN 执行服务 — 统一入口。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def execute_explain(
|
||||||
|
instance: Instance,
|
||||||
|
database: Optional[str],
|
||||||
|
query: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""根据 db_type 构造并执行 EXPLAIN,返回统一树结构。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: 数据源实例
|
||||||
|
database: 目标数据库名
|
||||||
|
query: 要解释的 SQL 查询
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统一 EXPLAIN 结果 Dict
|
||||||
|
"""
|
||||||
|
db_type = instance.db_type
|
||||||
|
|
||||||
|
# 验证查询以 SELECT 或 WITH 开头
|
||||||
|
stripped = query.strip()
|
||||||
|
upper = stripped.upper()
|
||||||
|
if not upper.startswith(("SELECT", "WITH")):
|
||||||
|
raise ValueError("EXPLAIN 仅支持 SELECT 或 WITH 查询")
|
||||||
|
|
||||||
|
if db_type == "mysql":
|
||||||
|
return await ExplainService._explain_mysql(instance, database, query)
|
||||||
|
elif db_type in ("postgres", "postgresql"):
|
||||||
|
return await ExplainService._explain_postgres(instance, database, query)
|
||||||
|
elif db_type == "sqlite":
|
||||||
|
return await ExplainService._explain_sqlite(instance, query)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"暂不支持 {db_type} 类型的 EXPLAIN")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _explain_mysql(
|
||||||
|
instance: Instance, database: Optional[str], query: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""执行 MySQL EXPLAIN FORMAT=JSON。"""
|
||||||
|
explain_sql = f"EXPLAIN FORMAT=JSON {query}"
|
||||||
|
|
||||||
|
conn = await aiomysql.connect(
|
||||||
|
host=instance.host,
|
||||||
|
port=instance.port,
|
||||||
|
user=instance.username,
|
||||||
|
password=instance.password,
|
||||||
|
db=database or instance.database,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await asyncio.wait_for(cur.execute(explain_sql), timeout=30)
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
# MySQL EXPLAIN FORMAT=JSON 返回一行一列 JSON
|
||||||
|
if rows:
|
||||||
|
raw = rows[0].get("EXPLAIN") or rows[0].get("JSON") or rows[0]
|
||||||
|
# raw 可能是字符串或已经解析的 dict
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = json.loads(raw)
|
||||||
|
else:
|
||||||
|
raw = {}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return parse_mysql_explain_json(raw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _explain_postgres(
|
||||||
|
instance: Instance, database: Optional[str], query: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""执行 PostgreSQL EXPLAIN (FORMAT JSON)。"""
|
||||||
|
explain_sql = f"EXPLAIN (FORMAT JSON, ANALYZE false) {query}"
|
||||||
|
|
||||||
|
dsn = (
|
||||||
|
f"postgresql://{instance.username}:{instance.password}"
|
||||||
|
f"@{instance.host}:{instance.port}/{database or instance.database or 'postgres'}"
|
||||||
|
)
|
||||||
|
conn = await asyncpg.connect(dsn)
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(conn.fetchval(explain_sql), timeout=30)
|
||||||
|
# asyncpg fetchval 返回 JSON 字符串
|
||||||
|
if isinstance(result, str):
|
||||||
|
raw = json.loads(result)
|
||||||
|
elif isinstance(result, list):
|
||||||
|
raw = result
|
||||||
|
else:
|
||||||
|
raw = result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
return parse_postgres_explain_json(raw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _explain_sqlite(instance: Instance, query: str) -> Dict[str, Any]:
|
||||||
|
"""执行 SQLite EXPLAIN QUERY PLAN。"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
explain_sql = f"EXPLAIN QUERY PLAN {query}"
|
||||||
|
db_path = instance.host
|
||||||
|
|
||||||
|
def _exec():
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.execute(explain_sql)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await asyncio.wait_for(
|
||||||
|
asyncio.get_event_loop().run_in_executor(None, _exec),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise ValueError("EXPLAIN 执行超时(30秒)")
|
||||||
|
|
||||||
|
return parse_sqlite_explain(rows)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""查询历史服务 — 记录和管理用户查询历史。
|
"""查询历史服务 — 记录和管理用户查询历史(SQLite 持久化)。
|
||||||
|
|
||||||
支持:
|
支持:
|
||||||
- 自动记录每次查询(内容、耗时、结果数、数据库类型)
|
- 自动记录每次查询(内容、耗时、结果数、数据库类型)
|
||||||
@@ -13,65 +13,27 @@
|
|||||||
- 快捷查询模板,减少重复输入
|
- 快捷查询模板,减少重复输入
|
||||||
- 查询统计帮助用户了解自己的数据使用习惯
|
- 查询统计帮助用户了解自己的数据使用习惯
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, and_, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@dataclass
|
from app.models.query_history import QueryHistory, QuickQueryModel
|
||||||
class QueryHistoryEntry:
|
|
||||||
"""查询历史条目。"""
|
|
||||||
id: int = 0
|
|
||||||
user_id: int = 0
|
|
||||||
username: str = ""
|
|
||||||
instance_id: int = 0
|
|
||||||
instance_name: str = ""
|
|
||||||
db_type: str = "" # mysql / postgres / redis / mongo
|
|
||||||
database_name: str = ""
|
|
||||||
query_content: str = "" # SQL / Redis命令 / MongoDB查询
|
|
||||||
query_type: str = "" # SELECT / GET / find 等
|
|
||||||
execution_time_ms: int = 0
|
|
||||||
result_rows: int = 0
|
|
||||||
is_success: bool = True
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
is_favorite: bool = False
|
|
||||||
note: str = "" # 用户备注
|
|
||||||
created_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class QuickQuery:
|
|
||||||
"""快捷查询模板。"""
|
|
||||||
id: int = 0
|
|
||||||
name: str = "" # 查询名称,如"查看活跃用户数"
|
|
||||||
description: str = "" # 描述
|
|
||||||
query_content: str = "" # 查询内容
|
|
||||||
db_type: str = "" # 适用的数据库类型
|
|
||||||
database_pattern: str = "" # 适用的数据库名称模式
|
|
||||||
is_shared: bool = False # 是否团队共享
|
|
||||||
created_by: int = 0
|
|
||||||
created_by_name: str = ""
|
|
||||||
usage_count: int = 0 # 使用次数
|
|
||||||
created_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class QueryHistoryService:
|
class QueryHistoryService:
|
||||||
"""查询历史服务。
|
"""查询历史服务(SQLite 持久化)。"""
|
||||||
|
|
||||||
内存存储实现,生产环境应替换为数据库存储。
|
|
||||||
"""
|
|
||||||
|
|
||||||
_history: List[QueryHistoryEntry] = []
|
|
||||||
_favorites: set = set() # (user_id, history_id)
|
|
||||||
_quick_queries: List[QuickQuery] = []
|
|
||||||
_history_counter: int = 0
|
|
||||||
_quick_counter: int = 0
|
|
||||||
_max_history_per_user: int = 500 # 每个用户最多保留500条
|
_max_history_per_user: int = 500 # 每个用户最多保留500条
|
||||||
|
|
||||||
@classmethod
|
# ── 查询历史 ──
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
async def record_query(
|
async def record_query(
|
||||||
cls,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
username: str,
|
username: str,
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
@@ -84,15 +46,12 @@ class QueryHistoryService:
|
|||||||
result_rows: int,
|
result_rows: int,
|
||||||
is_success: bool = True,
|
is_success: bool = True,
|
||||||
error_message: Optional[str] = None,
|
error_message: Optional[str] = None,
|
||||||
) -> QueryHistoryEntry:
|
) -> QueryHistory:
|
||||||
"""记录一次查询。
|
"""记录一次查询。
|
||||||
|
|
||||||
每次查询自动调用,无需用户干预。
|
每次查询自动调用,无需用户干预。
|
||||||
"""
|
"""
|
||||||
cls._history_counter += 1
|
entry = QueryHistory(
|
||||||
|
|
||||||
entry = QueryHistoryEntry(
|
|
||||||
id=cls._history_counter,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
username=username,
|
username=username,
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
@@ -105,24 +64,39 @@ class QueryHistoryService:
|
|||||||
result_rows=result_rows,
|
result_rows=result_rows,
|
||||||
is_success=is_success,
|
is_success=is_success,
|
||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
created_at=datetime.now().isoformat(),
|
|
||||||
)
|
)
|
||||||
|
db.add(entry)
|
||||||
cls._history.append(entry)
|
await db.flush()
|
||||||
|
|
||||||
# 限制每个用户的历史记录数量
|
# 限制每个用户的历史记录数量
|
||||||
user_entries = [h for h in cls._history if h.user_id == user_id]
|
count_stmt = select(func.count()).select_from(QueryHistory).where(
|
||||||
if len(user_entries) > cls._max_history_per_user:
|
QueryHistory.user_id == user_id
|
||||||
# 删除最旧的
|
)
|
||||||
to_remove = len(user_entries) - cls._max_history_per_user
|
total = (await db.execute(count_stmt)).scalar()
|
||||||
oldest_ids = {h.id for h in sorted(user_entries, key=lambda x: x.created_at)[:to_remove]}
|
|
||||||
cls._history = [h for h in cls._history if h.id not in oldest_ids]
|
|
||||||
|
|
||||||
|
if total > QueryHistoryService._max_history_per_user:
|
||||||
|
# 删除最旧的条目
|
||||||
|
to_remove = total - QueryHistoryService._max_history_per_user
|
||||||
|
old_stmt = (
|
||||||
|
select(QueryHistory.id)
|
||||||
|
.where(QueryHistory.user_id == user_id)
|
||||||
|
.order_by(QueryHistory.created_at.asc())
|
||||||
|
.limit(to_remove)
|
||||||
|
)
|
||||||
|
old_result = await db.execute(old_stmt)
|
||||||
|
old_ids = [row[0] for row in old_result.all()]
|
||||||
|
if old_ids:
|
||||||
|
delete_stmt = QueryHistory.__table__.delete().where(
|
||||||
|
QueryHistory.id.in_(old_ids)
|
||||||
|
)
|
||||||
|
await db.execute(delete_stmt)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def get_history(
|
async def get_history(
|
||||||
cls,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db_type: Optional[str] = None,
|
db_type: Optional[str] = None,
|
||||||
instance_id: Optional[int] = None,
|
instance_id: Optional[int] = None,
|
||||||
@@ -131,22 +105,24 @@ class QueryHistoryService:
|
|||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""获取用户查询历史。"""
|
"""获取用户查询历史。"""
|
||||||
entries = [h for h in cls._history if h.user_id == user_id]
|
stmt = select(QueryHistory).where(QueryHistory.user_id == user_id)
|
||||||
|
|
||||||
if db_type:
|
if db_type:
|
||||||
entries = [h for h in entries if h.db_type == db_type]
|
stmt = stmt.where(QueryHistory.db_type == db_type)
|
||||||
if instance_id:
|
if instance_id:
|
||||||
entries = [h for h in entries if h.instance_id == instance_id]
|
stmt = stmt.where(QueryHistory.instance_id == instance_id)
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = keyword.lower()
|
stmt = stmt.where(QueryHistory.query_content.ilike(f"%{keyword}%"))
|
||||||
entries = [h for h in entries if kw in h.query_content.lower()]
|
|
||||||
|
|
||||||
total = len(entries)
|
# 总数
|
||||||
entries.sort(key=lambda x: x.created_at, reverse=True)
|
total_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
|
total = (await db.execute(total_stmt)).scalar()
|
||||||
|
|
||||||
start = (page - 1) * page_size
|
# 排序 + 分页
|
||||||
end = start + page_size
|
stmt = stmt.order_by(QueryHistory.created_at.desc())
|
||||||
page_entries = entries[start:end]
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
entries = result.scalars().all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -158,42 +134,60 @@ class QueryHistoryService:
|
|||||||
"instance_name": e.instance_name,
|
"instance_name": e.instance_name,
|
||||||
"db_type": e.db_type,
|
"db_type": e.db_type,
|
||||||
"database_name": e.database_name,
|
"database_name": e.database_name,
|
||||||
"query_content": e.query_content[:200],
|
"query_content": (e.query_content or "")[:200],
|
||||||
"query_type": e.query_type,
|
"query_type": e.query_type,
|
||||||
"execution_time_ms": e.execution_time_ms,
|
"execution_time_ms": e.execution_time_ms,
|
||||||
"result_rows": e.result_rows,
|
"result_rows": e.result_rows,
|
||||||
"is_success": e.is_success,
|
"is_success": e.is_success,
|
||||||
"is_favorite": (user_id, e.id) in cls._favorites,
|
"is_favorite": e.is_favorite,
|
||||||
"note": e.note,
|
"note": e.note,
|
||||||
"created_at": e.created_at,
|
"created_at": e.created_at.isoformat() if e.created_at else "",
|
||||||
}
|
}
|
||||||
for e in page_entries
|
for e in entries
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def toggle_favorite(cls, user_id: int, history_id: int) -> bool:
|
async def toggle_favorite(db: AsyncSession, user_id: int, history_id: int) -> bool:
|
||||||
"""切换收藏状态。"""
|
"""切换收藏状态。"""
|
||||||
key = (user_id, history_id)
|
stmt = select(QueryHistory).where(
|
||||||
if key in cls._favorites:
|
and_(
|
||||||
cls._favorites.remove(key)
|
QueryHistory.id == history_id,
|
||||||
return False # 已取消收藏
|
QueryHistory.user_id == user_id,
|
||||||
else:
|
)
|
||||||
cls._favorites.add(key)
|
)
|
||||||
return True # 已收藏
|
result = await db.execute(stmt)
|
||||||
|
entry = result.scalar_one_or_none()
|
||||||
|
|
||||||
@classmethod
|
if entry is None:
|
||||||
|
# 没有找到该条记录(可能不属于当前用户),直接返回 False
|
||||||
|
return False
|
||||||
|
|
||||||
|
entry.is_favorite = not entry.is_favorite
|
||||||
|
await db.flush()
|
||||||
|
return entry.is_favorite
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
async def get_favorites(
|
async def get_favorites(
|
||||||
cls, user_id: int, page: int = 1, page_size: int = 20
|
db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""获取收藏的查询。"""
|
"""获取收藏的查询。"""
|
||||||
favorite_ids = {hid for uid, hid in cls._favorites if uid == user_id}
|
stmt = select(QueryHistory).where(
|
||||||
entries = [h for h in cls._history if h.id in favorite_ids]
|
and_(
|
||||||
entries.sort(key=lambda x: x.created_at, reverse=True)
|
QueryHistory.user_id == user_id,
|
||||||
|
QueryHistory.is_favorite == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
total = len(entries)
|
# 总数
|
||||||
start = (page - 1) * page_size
|
total_stmt = select(func.count()).select_from(stmt.subquery())
|
||||||
end = start + page_size
|
total = (await db.execute(total_stmt)).scalar()
|
||||||
|
|
||||||
|
# 排序 + 分页
|
||||||
|
stmt = stmt.order_by(QueryHistory.created_at.desc())
|
||||||
|
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
entries = result.scalars().all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -205,21 +199,21 @@ class QueryHistoryService:
|
|||||||
"instance_name": e.instance_name,
|
"instance_name": e.instance_name,
|
||||||
"db_type": e.db_type,
|
"db_type": e.db_type,
|
||||||
"database_name": e.database_name,
|
"database_name": e.database_name,
|
||||||
"query_content": e.query_content[:200],
|
"query_content": (e.query_content or "")[:200],
|
||||||
"query_type": e.query_type,
|
"query_type": e.query_type,
|
||||||
"execution_time_ms": e.execution_time_ms,
|
"execution_time_ms": e.execution_time_ms,
|
||||||
"result_rows": e.result_rows,
|
"result_rows": e.result_rows,
|
||||||
"created_at": e.created_at,
|
"created_at": e.created_at.isoformat() if e.created_at else "",
|
||||||
}
|
}
|
||||||
for e in entries[start:end]
|
for e in entries
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 快捷查询 ──
|
# ── 快捷查询 ──
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def create_quick_query(
|
async def create_quick_query(
|
||||||
cls,
|
db: AsyncSession,
|
||||||
name: str,
|
name: str,
|
||||||
description: str,
|
description: str,
|
||||||
query_content: str,
|
query_content: str,
|
||||||
@@ -228,11 +222,9 @@ class QueryHistoryService:
|
|||||||
is_shared: bool,
|
is_shared: bool,
|
||||||
created_by: int,
|
created_by: int,
|
||||||
created_by_name: str,
|
created_by_name: str,
|
||||||
) -> QuickQuery:
|
) -> QuickQueryModel:
|
||||||
"""创建快捷查询。"""
|
"""创建快捷查询。"""
|
||||||
cls._quick_counter += 1
|
qq = QuickQueryModel(
|
||||||
qq = QuickQuery(
|
|
||||||
id=cls._quick_counter,
|
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
query_content=query_content,
|
query_content=query_content,
|
||||||
@@ -241,71 +233,102 @@ class QueryHistoryService:
|
|||||||
is_shared=is_shared,
|
is_shared=is_shared,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
created_by_name=created_by_name,
|
created_by_name=created_by_name,
|
||||||
created_at=datetime.now().isoformat(),
|
|
||||||
)
|
)
|
||||||
cls._quick_queries.append(qq)
|
db.add(qq)
|
||||||
|
await db.flush()
|
||||||
return qq
|
return qq
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def get_quick_queries(
|
async def get_quick_queries(
|
||||||
cls,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db_type: Optional[str] = None,
|
db_type: Optional[str] = None,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""获取快捷查询列表(个人+团队共享)。"""
|
"""获取快捷查询列表(个人+团队共享)。"""
|
||||||
result = []
|
stmt = select(QuickQueryModel).where(
|
||||||
for qq in cls._quick_queries:
|
or_(
|
||||||
# 条件:共享的 或 自己创建的
|
QuickQueryModel.is_shared == True, # noqa: E712
|
||||||
if qq.is_shared or qq.created_by == user_id:
|
QuickQueryModel.created_by == user_id,
|
||||||
if db_type and qq.db_type != db_type:
|
)
|
||||||
continue
|
)
|
||||||
if keyword and keyword.lower() not in qq.name.lower():
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
"id": qq.id,
|
|
||||||
"name": qq.name,
|
|
||||||
"description": qq.description,
|
|
||||||
"query_content": qq.query_content[:500],
|
|
||||||
"db_type": qq.db_type,
|
|
||||||
"is_shared": qq.is_shared,
|
|
||||||
"created_by_name": qq.created_by_name,
|
|
||||||
"usage_count": qq.usage_count,
|
|
||||||
"created_at": qq.created_at,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
if db_type:
|
||||||
async def use_quick_query(cls, quick_query_id: int) -> Optional[str]:
|
stmt = stmt.where(QuickQueryModel.db_type == db_type)
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(QuickQueryModel.name.ilike(f"%{keyword}%"))
|
||||||
|
|
||||||
|
stmt = stmt.order_by(QuickQueryModel.created_at.desc())
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
queries = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": qq.id,
|
||||||
|
"name": qq.name,
|
||||||
|
"description": qq.description,
|
||||||
|
"query_content": (qq.query_content or "")[:500],
|
||||||
|
"db_type": qq.db_type,
|
||||||
|
"is_shared": qq.is_shared,
|
||||||
|
"created_by_name": qq.created_by_name,
|
||||||
|
"usage_count": qq.usage_count,
|
||||||
|
"created_at": qq.created_at.isoformat() if qq.created_at else "",
|
||||||
|
}
|
||||||
|
for qq in queries
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def use_quick_query(db: AsyncSession, quick_query_id: int) -> Optional[str]:
|
||||||
"""使用快捷查询,返回查询内容并增加使用次数。"""
|
"""使用快捷查询,返回查询内容并增加使用次数。"""
|
||||||
for qq in cls._quick_queries:
|
stmt = select(QuickQueryModel).where(QuickQueryModel.id == quick_query_id)
|
||||||
if qq.id == quick_query_id:
|
result = await db.execute(stmt)
|
||||||
qq.usage_count += 1
|
qq = result.scalar_one_or_none()
|
||||||
return qq.query_content
|
if qq is None:
|
||||||
return None
|
return None
|
||||||
|
qq.usage_count += 1
|
||||||
|
await db.flush()
|
||||||
|
return qq.query_content
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def get_query_statistics(cls, user_id: int) -> Dict[str, Any]:
|
async def get_query_statistics(db: AsyncSession, user_id: int) -> Dict[str, Any]:
|
||||||
"""获取用户查询统计。
|
"""获取用户查询统计。
|
||||||
|
|
||||||
人性化:帮助用户了解自己的数据使用习惯。
|
人性化:帮助用户了解自己的数据使用习惯。
|
||||||
"""
|
"""
|
||||||
entries = [h for h in cls._history if h.user_id == user_id]
|
# 总数
|
||||||
|
count_stmt = select(func.count()).select_from(QueryHistory).where(
|
||||||
|
QueryHistory.user_id == user_id
|
||||||
|
)
|
||||||
|
total_queries = (await db.execute(count_stmt)).scalar()
|
||||||
|
|
||||||
if not entries:
|
if not total_queries:
|
||||||
return {"message": "暂无查询记录"}
|
return {"message": "暂无查询记录"}
|
||||||
|
|
||||||
# 按数据库类型分布
|
# 按数据库类型分布
|
||||||
db_type_dist = Counter(h.db_type for h in entries)
|
db_type_stmt = (
|
||||||
|
select(QueryHistory.db_type, func.count().label("cnt"))
|
||||||
|
.where(QueryHistory.user_id == user_id)
|
||||||
|
.group_by(QueryHistory.db_type)
|
||||||
|
)
|
||||||
|
db_type_result = await db.execute(db_type_stmt)
|
||||||
|
db_type_dist = {row[0]: row[1] for row in db_type_result.all()}
|
||||||
|
|
||||||
# 高频查询表(从SQL中提取表名)
|
# 高频查询表(从SQL中提取表名)
|
||||||
|
sql_stmt = (
|
||||||
|
select(QueryHistory.query_content)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
QueryHistory.user_id == user_id,
|
||||||
|
QueryHistory.db_type.in_(["mysql", "postgres"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sql_result = await db.execute(sql_stmt)
|
||||||
tables = []
|
tables = []
|
||||||
for h in entries:
|
for row in sql_result.all():
|
||||||
if h.db_type in ("mysql", "postgres"):
|
content = row[0] or ""
|
||||||
# 简单提取表名
|
found = re.findall(r"FROM\s+(\w+)", content, re.IGNORECASE)
|
||||||
import re
|
tables.extend(found)
|
||||||
found = re.findall(r"FROM\s+(\w+)", h.query_content, re.IGNORECASE)
|
|
||||||
tables.extend(found)
|
|
||||||
table_dist = Counter(tables).most_common(10)
|
table_dist = Counter(tables).most_common(10)
|
||||||
|
|
||||||
# 查询趋势(最近7天)
|
# 查询趋势(最近7天)
|
||||||
@@ -313,58 +336,77 @@ class QueryHistoryService:
|
|||||||
daily_counts = {}
|
daily_counts = {}
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
count = sum(
|
day_start = datetime.strptime(day, "%Y-%m-%d")
|
||||||
1 for h in entries
|
day_end = day_start + timedelta(days=1)
|
||||||
if h.created_at.startswith(day)
|
day_count_stmt = select(func.count()).select_from(QueryHistory).where(
|
||||||
|
and_(
|
||||||
|
QueryHistory.user_id == user_id,
|
||||||
|
QueryHistory.created_at >= day_start,
|
||||||
|
QueryHistory.created_at < day_end,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
daily_counts[day] = count
|
day_count = (await db.execute(day_count_stmt)).scalar()
|
||||||
|
daily_counts[day] = day_count
|
||||||
|
|
||||||
# 平均执行时间
|
# 平均执行时间
|
||||||
avg_time = sum(h.execution_time_ms for h in entries) / len(entries)
|
avg_stmt = select(func.avg(QueryHistory.execution_time_ms)).where(
|
||||||
|
QueryHistory.user_id == user_id
|
||||||
|
)
|
||||||
|
avg_time = (await db.execute(avg_stmt)).scalar() or 0
|
||||||
|
|
||||||
# 总查询次数
|
# 成功率
|
||||||
total_queries = len(entries)
|
success_stmt = select(func.count()).select_from(QueryHistory).where(
|
||||||
|
and_(
|
||||||
|
QueryHistory.user_id == user_id,
|
||||||
|
QueryHistory.is_success == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
success_count = (await db.execute(success_stmt)).scalar()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_queries": total_queries,
|
"total_queries": total_queries,
|
||||||
"avg_execution_time_ms": round(avg_time, 2),
|
"avg_execution_time_ms": round(float(avg_time), 2),
|
||||||
"db_type_distribution": dict(db_type_dist),
|
"db_type_distribution": db_type_dist,
|
||||||
"top_tables": [{"table": t, "count": c} for t, c in table_dist],
|
"top_tables": [{"table": t, "count": c} for t, c in table_dist],
|
||||||
"daily_trend": daily_counts,
|
"daily_trend": daily_counts,
|
||||||
"success_rate": round(
|
"success_rate": round(success_count / total_queries * 100, 2),
|
||||||
sum(1 for h in entries if h.is_success) / total_queries * 100, 2
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 预置快捷查询模板 ──
|
# ── 预置快捷查询模板 ──
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def seed_default_quick_queries(cls):
|
async def seed_default_quick_queries(db: AsyncSession):
|
||||||
"""预置默认快捷查询模板。"""
|
"""预置默认快捷查询模板(仅在表为空时插入)。"""
|
||||||
|
# 检查是否已有数据
|
||||||
|
count_stmt = select(func.count()).select_from(QuickQueryModel)
|
||||||
|
count = (await db.execute(count_stmt)).scalar()
|
||||||
|
if count and count > 0:
|
||||||
|
return
|
||||||
|
|
||||||
defaults = [
|
defaults = [
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-1, name="查看所有表", description="列出数据库中所有表",
|
name="查看所有表", description="列出数据库中所有表",
|
||||||
query_content="SHOW TABLES", db_type="mysql", database_pattern="*",
|
query_content="SHOW TABLES", db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=0, created_by_name="系统",
|
is_shared=True, created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-2, name="查看表结构", description="查看指定表的列信息",
|
name="查看表结构", description="查看指定表的列信息",
|
||||||
query_content="DESCRIBE {table_name}", db_type="mysql", database_pattern="*",
|
query_content="DESCRIBE {table_name}", db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=0, created_by_name="系统",
|
is_shared=True, created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-3, name="查看慢查询", description="MySQL慢查询日志",
|
name="查看慢查询", description="MySQL慢查询日志",
|
||||||
query_content="SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
|
query_content="SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
|
||||||
db_type="mysql", database_pattern="*", is_shared=True,
|
db_type="mysql", database_pattern="*", is_shared=True,
|
||||||
created_by=0, created_by_name="系统",
|
created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-4, name="查看活跃连接", description="当前数据库连接情况",
|
name="查看活跃连接", description="当前数据库连接情况",
|
||||||
query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*",
|
query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=0, created_by_name="系统",
|
is_shared=True, created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-5, name="查看表大小", description="各表占用空间统计",
|
name="查看表大小", description="各表占用空间统计",
|
||||||
query_content="""SELECT
|
query_content="""SELECT
|
||||||
table_name AS `表名`,
|
table_name AS `表名`,
|
||||||
ROUND(data_length / 1024 / 1024, 2) AS `数据大小(MB)`,
|
ROUND(data_length / 1024 / 1024, 2) AS `数据大小(MB)`,
|
||||||
@@ -376,38 +418,34 @@ ORDER BY data_length DESC
|
|||||||
LIMIT 50""", db_type="mysql", database_pattern="*", is_shared=True,
|
LIMIT 50""", db_type="mysql", database_pattern="*", is_shared=True,
|
||||||
created_by=0, created_by_name="系统",
|
created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-6, name="Redis查看所有键", description="SCAN遍历所有键",
|
name="Redis查看所有键", description="SCAN遍历所有键",
|
||||||
query_content="SCAN 0 MATCH * COUNT 50", db_type="redis",
|
query_content="SCAN 0 MATCH * COUNT 50", db_type="redis",
|
||||||
database_pattern="*", is_shared=True, created_by=0, created_by_name="系统",
|
database_pattern="*", is_shared=True, created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-7, name="Redis查看服务器信息", description="Redis INFO命令",
|
name="Redis查看服务器信息", description="Redis INFO命令",
|
||||||
query_content="INFO server", db_type="redis", database_pattern="*",
|
query_content="INFO server", db_type="redis", database_pattern="*",
|
||||||
is_shared=True, created_by=0, created_by_name="系统",
|
is_shared=True, created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-8, name="PG查看所有表", description="PostgreSQL列出所有表",
|
name="PG查看所有表", description="PostgreSQL列出所有表",
|
||||||
query_content="SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 50",
|
query_content="SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 50",
|
||||||
db_type="postgres", database_pattern="*", is_shared=True,
|
db_type="postgres", database_pattern="*", is_shared=True,
|
||||||
created_by=0, created_by_name="系统",
|
created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-9, name="MongoDB查找文档", description="查找集合中的文档",
|
name="MongoDB查找文档", description="查找集合中的文档",
|
||||||
query_content='{"collection": "{collection_name}", "operation": "find", "limit": 20}',
|
query_content='{"collection": "{collection_name}", "operation": "find", "limit": 20}',
|
||||||
db_type="mongo", database_pattern="*", is_shared=True,
|
db_type="mongo", database_pattern="*", is_shared=True,
|
||||||
created_by=0, created_by_name="系统",
|
created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
QuickQuery(
|
QuickQueryModel(
|
||||||
id=-10, name="MongoDB统计文档数", description="统计集合文档数量",
|
name="MongoDB统计文档数", description="统计集合文档数量",
|
||||||
query_content='{"collection": "{collection_name}", "operation": "countDocuments"}',
|
query_content='{"collection": "{collection_name}", "operation": "countDocuments"}',
|
||||||
db_type="mongo", database_pattern="*", is_shared=True,
|
db_type="mongo", database_pattern="*", is_shared=True,
|
||||||
created_by=0, created_by_name="系统",
|
created_by=0, created_by_name="系统",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
cls._quick_queries.extend(defaults)
|
db.add_all(defaults)
|
||||||
cls._quick_counter = max(cls._quick_counter, 10)
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
# 初始化预置快捷查询
|
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
@@ -0,0 +1,994 @@
|
|||||||
|
"""EXPLAIN 服务解析测试 — 测试 MySQL / PostgreSQL / SQLite 解析逻辑 + API endpoint。
|
||||||
|
|
||||||
|
使用 mock 的数据库连接,不依赖真实数据库。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.core.crypto import encrypt_password
|
||||||
|
from app.models.instance import Instance
|
||||||
|
from app.services.explain_service import (
|
||||||
|
ExplainService,
|
||||||
|
parse_mysql_explain_json,
|
||||||
|
parse_postgres_explain_json,
|
||||||
|
parse_sqlite_explain,
|
||||||
|
make_plan_node,
|
||||||
|
_empty_result,
|
||||||
|
)
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 1: MySQL EXPLAIN JSON 解析测试
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestMySQLExplainParsing:
|
||||||
|
"""测试 MySQL EXPLAIN FORMAT=JSON 结果解析。"""
|
||||||
|
|
||||||
|
def test_simple_table_scan(self):
|
||||||
|
"""测试简单全表扫描。"""
|
||||||
|
raw = {
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "0.35"},
|
||||||
|
"table": {
|
||||||
|
"table_name": "users",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 1000,
|
||||||
|
"rows_produced_per_join": 1000,
|
||||||
|
"filtered": "100.00",
|
||||||
|
"cost_info": {"read_cost": "0.25"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_mysql_explain_json(raw)
|
||||||
|
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert len(result["execution_plan"]) == 1
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Table Scan"
|
||||||
|
assert root["table"] == "users"
|
||||||
|
assert root["rows"] == 1000
|
||||||
|
assert root["cost"] == 0.25
|
||||||
|
|
||||||
|
# 警告检测
|
||||||
|
assert any("全表扫描" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
summary = result["summary"]
|
||||||
|
assert summary["has_full_scan"] is True
|
||||||
|
assert summary["estimated_rows"] == 1000
|
||||||
|
assert summary["total_cost"] == 0.25
|
||||||
|
|
||||||
|
def test_index_lookup(self):
|
||||||
|
"""测试索引查找。"""
|
||||||
|
raw = {
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "0.10"},
|
||||||
|
"table": {
|
||||||
|
"table_name": "orders",
|
||||||
|
"access_type": "ref",
|
||||||
|
"possible_keys": "idx_user_id",
|
||||||
|
"key": "idx_user_id",
|
||||||
|
"used_key_parts": ["user_id"],
|
||||||
|
"rows_examined_per_scan": 10,
|
||||||
|
"filtered": "100.00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_mysql_explain_json(raw)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Index Lookup"
|
||||||
|
assert root["table"] == "orders"
|
||||||
|
assert root["key"] == "idx_user_id"
|
||||||
|
assert root["rows"] == 10
|
||||||
|
|
||||||
|
summary = result["summary"]
|
||||||
|
assert summary["has_full_scan"] is False
|
||||||
|
assert summary["index_usage_ratio"] == 1.0 # 1 table, 1 index usage
|
||||||
|
|
||||||
|
def test_nested_loop_join(self):
|
||||||
|
"""测试嵌套循环 JOIN。"""
|
||||||
|
raw = {
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "1.50"},
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "users",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "orders",
|
||||||
|
"access_type": "ref",
|
||||||
|
"key": "idx_user_id",
|
||||||
|
"rows_examined_per_scan": 5,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_mysql_explain_json(raw)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Query Block"
|
||||||
|
assert len(root["children"]) == 2
|
||||||
|
|
||||||
|
child1 = root["children"][0]
|
||||||
|
assert child1["operation"] == "Table Scan"
|
||||||
|
assert child1["table"] == "users"
|
||||||
|
|
||||||
|
child2 = root["children"][1]
|
||||||
|
assert child2["operation"] == "Index Lookup"
|
||||||
|
assert child2["table"] == "orders"
|
||||||
|
assert child2["key"] == "idx_user_id"
|
||||||
|
|
||||||
|
def test_const_and_eq_ref(self):
|
||||||
|
"""测试 const / eq_ref 类型映射。"""
|
||||||
|
# 直接测试 _mysql_access_type_to_operation
|
||||||
|
from app.services.explain_service import _mysql_access_type_to_operation
|
||||||
|
assert _mysql_access_type_to_operation("const") == "Const Row"
|
||||||
|
assert _mysql_access_type_to_operation("eq_ref") == "Unique Lookup"
|
||||||
|
assert _mysql_access_type_to_operation("range") == "Index Range Scan"
|
||||||
|
assert _mysql_access_type_to_operation("ALL") == "Table Scan"
|
||||||
|
assert _mysql_access_type_to_operation("index") == "Full Index Scan"
|
||||||
|
|
||||||
|
def test_json_string_input(self):
|
||||||
|
"""测试字符串 JSON 输入。"""
|
||||||
|
raw_str = json.dumps({
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"table": {
|
||||||
|
"table_name": "t",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
result = parse_mysql_explain_json(raw_str)
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert result["execution_plan"][0]["table"] == "t"
|
||||||
|
|
||||||
|
def test_invalid_json_string(self):
|
||||||
|
"""测试无效 JSON 字符串。"""
|
||||||
|
result = parse_mysql_explain_json("not valid json{{{")
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert len(result["warnings"]) > 0
|
||||||
|
assert any("解析失败" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
def test_empty_result(self):
|
||||||
|
"""测试空结果。"""
|
||||||
|
result = parse_mysql_explain_json({})
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert result["execution_plan"] == []
|
||||||
|
|
||||||
|
def test_total_cost_uses_max(self):
|
||||||
|
"""测试 total_cost 取最大值。"""
|
||||||
|
raw = {
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "10.5"},
|
||||||
|
"nested_loop": [
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "a",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 100,
|
||||||
|
"cost_info": {"read_cost": "5.0"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": {
|
||||||
|
"table_name": "b",
|
||||||
|
"access_type": "ref",
|
||||||
|
"rows_examined_per_scan": 10,
|
||||||
|
"cost_info": {"read_cost": "3.0"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = parse_mysql_explain_json(raw)
|
||||||
|
# total_cost should be the max of all costs
|
||||||
|
assert result["summary"]["total_cost"] == 10.5
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 2: PostgreSQL EXPLAIN JSON 解析测试
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPostgreSQLExplainParsing:
|
||||||
|
"""测试 PostgreSQL EXPLAIN (FORMAT JSON) 结果解析。"""
|
||||||
|
|
||||||
|
def test_seq_scan(self):
|
||||||
|
"""测试顺序扫描。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "users",
|
||||||
|
"Alias": "users",
|
||||||
|
"Startup Cost": 0.00,
|
||||||
|
"Total Cost": 15.50,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
"Plan Width": 68,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
|
||||||
|
assert result["db_type"] == "postgres"
|
||||||
|
assert len(result["execution_plan"]) == 1
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Table Scan"
|
||||||
|
assert root["table"] == "users"
|
||||||
|
assert root["rows"] == 1000
|
||||||
|
assert root["cost"] == 15.50
|
||||||
|
|
||||||
|
assert any("顺序扫描" in w for w in result["warnings"])
|
||||||
|
assert result["summary"]["has_full_scan"] is True
|
||||||
|
|
||||||
|
def test_index_scan(self):
|
||||||
|
"""测试索引扫描。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Index Scan",
|
||||||
|
"Relation Name": "orders",
|
||||||
|
"Alias": "orders",
|
||||||
|
"Index Name": "idx_user_id",
|
||||||
|
"Startup Cost": 0.15,
|
||||||
|
"Total Cost": 8.50,
|
||||||
|
"Plan Rows": 10,
|
||||||
|
"Plan Width": 48,
|
||||||
|
"Index Cond": "(user_id = 42)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Index Scan"
|
||||||
|
assert root["table"] == "orders"
|
||||||
|
assert root["key"] == "idx_user_id"
|
||||||
|
assert root["rows"] == 10
|
||||||
|
assert "索引条件" in (root["extra"] or "")
|
||||||
|
|
||||||
|
def test_join_with_children(self):
|
||||||
|
"""测试 JOIN 嵌套结构。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Nested Loop",
|
||||||
|
"Startup Cost": 0.15,
|
||||||
|
"Total Cost": 25.50,
|
||||||
|
"Plan Rows": 100,
|
||||||
|
"Plan Width": 120,
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "users",
|
||||||
|
"Total Cost": 12.00,
|
||||||
|
"Plan Rows": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Node Type": "Index Scan",
|
||||||
|
"Relation Name": "orders",
|
||||||
|
"Index Name": "idx_user_id",
|
||||||
|
"Total Cost": 0.30,
|
||||||
|
"Plan Rows": 1,
|
||||||
|
"Index Cond": "(user_id = users.id)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Nested Loop Join"
|
||||||
|
assert len(root["children"]) == 2
|
||||||
|
|
||||||
|
assert root["children"][0]["operation"] == "Table Scan"
|
||||||
|
assert root["children"][0]["table"] == "users"
|
||||||
|
|
||||||
|
assert root["children"][1]["operation"] == "Index Scan"
|
||||||
|
assert root["children"][1]["table"] == "orders"
|
||||||
|
assert root["children"][1]["key"] == "idx_user_id"
|
||||||
|
|
||||||
|
def test_hash_join(self):
|
||||||
|
"""测试 Hash Join。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Hash Join",
|
||||||
|
"Join Type": "Inner",
|
||||||
|
"Relation Name": None,
|
||||||
|
"Hash Cond": "users.id = orders.user_id",
|
||||||
|
"Total Cost": 50.00,
|
||||||
|
"Plan Rows": 500,
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "users",
|
||||||
|
"Total Cost": 15.00,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Node Type": "Hash",
|
||||||
|
"Total Cost": 20.00,
|
||||||
|
"Plan Rows": 500,
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "orders",
|
||||||
|
"Total Cost": 18.00,
|
||||||
|
"Plan Rows": 500,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert "Inner" in root["operation"]
|
||||||
|
assert "Hash Join" in root["operation"]
|
||||||
|
assert len(root["children"]) == 2
|
||||||
|
# Hash has a nested child
|
||||||
|
hash_child = root["children"][1]
|
||||||
|
assert hash_child["operation"] == "Hash"
|
||||||
|
assert len(hash_child["children"]) == 1
|
||||||
|
assert hash_child["children"][0]["table"] == "orders"
|
||||||
|
|
||||||
|
def test_sort_and_aggregate(self):
|
||||||
|
"""测试排序和聚合。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Aggregate",
|
||||||
|
"Total Cost": 30.00,
|
||||||
|
"Plan Rows": 5,
|
||||||
|
"Group Key": ["department"],
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Sort",
|
||||||
|
"Total Cost": 28.00,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
"Sort Key": ["salary DESC"],
|
||||||
|
"Sort Method": "quicksort",
|
||||||
|
"Plans": [
|
||||||
|
{
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "employees",
|
||||||
|
"Total Cost": 15.00,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Aggregate"
|
||||||
|
assert "分组键" in (root["extra"] or "")
|
||||||
|
|
||||||
|
sort_child = root["children"][0]
|
||||||
|
assert sort_child["operation"] == "Sort"
|
||||||
|
assert "排序键" in (sort_child["extra"] or "")
|
||||||
|
|
||||||
|
seq_child = sort_child["children"][0]
|
||||||
|
assert seq_child["operation"] == "Table Scan"
|
||||||
|
|
||||||
|
def test_json_string_input(self):
|
||||||
|
"""测试 JSON 字符串输入。"""
|
||||||
|
raw_str = json.dumps([
|
||||||
|
{"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}}
|
||||||
|
])
|
||||||
|
result = parse_postgres_explain_json(raw_str)
|
||||||
|
assert result["db_type"] == "postgres"
|
||||||
|
assert result["execution_plan"][0]["table"] == "t"
|
||||||
|
|
||||||
|
def test_invalid_json_string(self):
|
||||||
|
"""测试无效 JSON 字符串。"""
|
||||||
|
result = parse_postgres_explain_json("invalid{{{")
|
||||||
|
assert any("解析失败" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
def test_pg_node_type_mapping(self):
|
||||||
|
"""测试 PG 节点类型映射。"""
|
||||||
|
from app.services.explain_service import _pg_node_type_to_operation
|
||||||
|
assert _pg_node_type_to_operation("Seq Scan") == "Table Scan"
|
||||||
|
assert _pg_node_type_to_operation("Index Scan") == "Index Scan"
|
||||||
|
assert _pg_node_type_to_operation("Index Only Scan") == "Index Only Scan"
|
||||||
|
assert _pg_node_type_to_operation("Hash Join") == "Hash Join"
|
||||||
|
assert _pg_node_type_to_operation("Sort") == "Sort"
|
||||||
|
assert _pg_node_type_to_operation("Aggregate") == "Aggregate"
|
||||||
|
assert _pg_node_type_to_operation("Limit") == "Limit"
|
||||||
|
assert _pg_node_type_to_operation("UnknownType") == "UnknownType"
|
||||||
|
|
||||||
|
def test_actual_rows_and_time(self):
|
||||||
|
"""测试 ANALYZE 包含的实际行数和耗时。"""
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "big_table",
|
||||||
|
"Total Cost": 100.0,
|
||||||
|
"Plan Rows": 50000,
|
||||||
|
"Actual Rows": 49998,
|
||||||
|
"Actual Total Time": 23.456,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = parse_postgres_explain_json(raw)
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root.get("actual_rows") == 49998
|
||||||
|
assert root.get("actual_time_ms") == 23.456
|
||||||
|
assert "实际行数" in (root["extra"] or "")
|
||||||
|
assert "实际耗时" in (root["extra"] or "")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 3: SQLite EXPLAIN QUERY PLAN 解析测试
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLiteExplainParsing:
|
||||||
|
"""测试 SQLite EXPLAIN QUERY PLAN 结果解析。"""
|
||||||
|
|
||||||
|
def test_table_scan(self):
|
||||||
|
"""测试全表扫描。"""
|
||||||
|
rows = [
|
||||||
|
{"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"},
|
||||||
|
]
|
||||||
|
result = parse_sqlite_explain(rows)
|
||||||
|
|
||||||
|
assert result["db_type"] == "sqlite"
|
||||||
|
assert len(result["execution_plan"]) == 1
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Table Scan"
|
||||||
|
assert root["table"] == "users"
|
||||||
|
assert any("全表扫描" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
def test_index_search(self):
|
||||||
|
"""测试索引搜索。"""
|
||||||
|
rows = [
|
||||||
|
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_name (name=?)"},
|
||||||
|
]
|
||||||
|
result = parse_sqlite_explain(rows)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["operation"] == "Index Lookup"
|
||||||
|
assert root["table"] == "users"
|
||||||
|
assert root["key"] == "idx_name"
|
||||||
|
|
||||||
|
def test_covering_index(self):
|
||||||
|
"""测试覆盖索引。"""
|
||||||
|
rows = [
|
||||||
|
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING COVERING INDEX idx_name (name=?)"},
|
||||||
|
]
|
||||||
|
result = parse_sqlite_explain(rows)
|
||||||
|
|
||||||
|
root = result["execution_plan"][0]
|
||||||
|
assert root["key"] == "idx_name"
|
||||||
|
assert "Covering Index" in (root["extra"] or "")
|
||||||
|
|
||||||
|
def test_subquery(self):
|
||||||
|
"""测试子查询。"""
|
||||||
|
rows = [
|
||||||
|
{"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"},
|
||||||
|
{"id": 6, "parent": 0, "notused": 0, "detail": "CORRELATED SCALAR SUBQUERY 2"},
|
||||||
|
]
|
||||||
|
result = parse_sqlite_explain(rows)
|
||||||
|
assert len(result["execution_plan"]) == 2
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
"""测试空结果。"""
|
||||||
|
result = parse_sqlite_explain([])
|
||||||
|
assert result["db_type"] == "sqlite"
|
||||||
|
assert result["execution_plan"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 4: 统一辅助函数测试
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelperFunctions:
|
||||||
|
"""测试辅助函数。"""
|
||||||
|
|
||||||
|
def test_make_plan_node_defaults(self):
|
||||||
|
"""测试 make_plan_node 默认值。"""
|
||||||
|
node = make_plan_node(id=1, operation="Table Scan")
|
||||||
|
assert node["id"] == 1
|
||||||
|
assert node["operation"] == "Table Scan"
|
||||||
|
assert node["table"] is None
|
||||||
|
assert node["rows"] is None
|
||||||
|
assert node["cost"] is None
|
||||||
|
assert node["key"] is None
|
||||||
|
assert node["extra"] is None
|
||||||
|
assert node["children"] == []
|
||||||
|
|
||||||
|
def test_make_plan_node_full(self):
|
||||||
|
"""测试 make_plan_node 完整参数。"""
|
||||||
|
node = make_plan_node(
|
||||||
|
id=2,
|
||||||
|
operation="Index Scan",
|
||||||
|
table="orders",
|
||||||
|
rows=100,
|
||||||
|
cost=5.5,
|
||||||
|
key="idx_id",
|
||||||
|
extra="Using index",
|
||||||
|
children=[{"id": 3, "operation": "Child"}],
|
||||||
|
alias="o",
|
||||||
|
)
|
||||||
|
assert node["table"] == "orders"
|
||||||
|
assert node["rows"] == 100
|
||||||
|
assert node["cost"] == 5.5
|
||||||
|
assert node["key"] == "idx_id"
|
||||||
|
assert len(node["children"]) == 1
|
||||||
|
assert node["alias"] == "o"
|
||||||
|
|
||||||
|
def test_empty_result(self):
|
||||||
|
"""测试空结果构造。"""
|
||||||
|
result = _empty_result("mysql", {"foo": "bar"}, error="test error")
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert result["execution_plan"] == []
|
||||||
|
assert "test error" in result["warnings"]
|
||||||
|
assert result["raw_result"] == {"foo": "bar"}
|
||||||
|
|
||||||
|
def test_empty_result_no_error(self):
|
||||||
|
"""测试无 error 的空结果。"""
|
||||||
|
result = _empty_result("postgres", None)
|
||||||
|
assert "EXPLAIN 结果为空" in result["warnings"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 5: ExplainService.execute_explain 路由测试 (mock DB)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplainEndpoint:
|
||||||
|
"""测试 POST /api/v1/sql-assistant/explain API endpoint。"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mysql_instance(self, db):
|
||||||
|
"""创建 MySQL 测试实例。"""
|
||||||
|
inst = Instance(
|
||||||
|
name="TestMySQL",
|
||||||
|
db_type="mysql",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=3306,
|
||||||
|
username="root",
|
||||||
|
password=encrypt_password("test123"),
|
||||||
|
database="test_db",
|
||||||
|
env="development",
|
||||||
|
status="disconnected",
|
||||||
|
)
|
||||||
|
db.add(inst)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(inst)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def pg_instance(self, db):
|
||||||
|
"""创建 PostgreSQL 测试实例。"""
|
||||||
|
inst = Instance(
|
||||||
|
name="TestPG",
|
||||||
|
db_type="postgres",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=5432,
|
||||||
|
username="postgres",
|
||||||
|
password=encrypt_password("test123"),
|
||||||
|
database="test_db",
|
||||||
|
env="development",
|
||||||
|
status="disconnected",
|
||||||
|
)
|
||||||
|
db.add(inst)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(inst)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def sqlite_instance(self, db):
|
||||||
|
"""创建 SQLite 测试实例。"""
|
||||||
|
inst = Instance(
|
||||||
|
name="TestSQLite",
|
||||||
|
db_type="sqlite",
|
||||||
|
host="/tmp/test.db",
|
||||||
|
port=0,
|
||||||
|
username="",
|
||||||
|
password="",
|
||||||
|
database="",
|
||||||
|
env="development",
|
||||||
|
status="disconnected",
|
||||||
|
)
|
||||||
|
db.add(inst)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(inst)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
async def test_explain_mysql_success(self, client, admin_token, mysql_instance):
|
||||||
|
"""测试 MySQL EXPLAIN 成功。"""
|
||||||
|
# Mock aiomysql
|
||||||
|
mock_cursor = AsyncMock()
|
||||||
|
mock_cursor.execute = AsyncMock()
|
||||||
|
mock_cursor.fetchall = AsyncMock(return_value=[
|
||||||
|
{"EXPLAIN": json.dumps({
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"cost_info": {"query_cost": "0.35"},
|
||||||
|
"table": {
|
||||||
|
"table_name": "users",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 1000,
|
||||||
|
"filtered": "100.00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
])
|
||||||
|
mock_cursor.description = [("EXPLAIN",)]
|
||||||
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
||||||
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.close = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
|
||||||
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_aiomysql.DictCursor = MagicMock()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": mysql_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "SELECT * FROM users",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["db_type"] == "mysql"
|
||||||
|
assert len(data["execution_plan"]) == 1
|
||||||
|
assert data["execution_plan"][0]["operation"] == "Table Scan"
|
||||||
|
assert data["execution_plan"][0]["table"] == "users"
|
||||||
|
assert "elapsed_ms" in data
|
||||||
|
assert isinstance(data["summary"], dict)
|
||||||
|
|
||||||
|
async def test_explain_postgres_success(self, client, admin_token, pg_instance):
|
||||||
|
"""测试 PostgreSQL EXPLAIN 成功。"""
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "Seq Scan",
|
||||||
|
"Relation Name": "users",
|
||||||
|
"Total Cost": 15.50,
|
||||||
|
"Plan Rows": 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
mock_conn.close = AsyncMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
|
||||||
|
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": pg_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "SELECT * FROM users",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["db_type"] == "postgres"
|
||||||
|
assert len(data["execution_plan"]) == 1
|
||||||
|
assert data["execution_plan"][0]["operation"] == "Table Scan"
|
||||||
|
assert data["execution_plan"][0]["table"] == "users"
|
||||||
|
|
||||||
|
async def test_explain_empty_query(self, client, admin_token, mysql_instance):
|
||||||
|
"""测试空查询报错。"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": mysql_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
async def test_explain_non_select_query(self, client, admin_token, mysql_instance):
|
||||||
|
"""测试非 SELECT 查询报错。"""
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.cursor = MagicMock()
|
||||||
|
mock_conn.close = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
|
||||||
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_aiomysql.DictCursor = MagicMock()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": mysql_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "DELETE FROM users WHERE id = 1",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "SELECT" in resp.json()["detail"] or "WITH" in resp.json()["detail"]
|
||||||
|
|
||||||
|
async def test_explain_instance_not_found(self, client, admin_token):
|
||||||
|
"""测试不存在的实例。"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": 99999,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "SELECT 1",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_explain_guest_forbidden(self, client, guest_token, mysql_instance):
|
||||||
|
"""测试访客无权限。"""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": mysql_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "SELECT 1",
|
||||||
|
},
|
||||||
|
headers=auth_header(guest_token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async def test_explain_with_with_clause(self, client, admin_token, pg_instance):
|
||||||
|
"""测试 WITH (CTE) 查询。"""
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
|
||||||
|
{
|
||||||
|
"Plan": {
|
||||||
|
"Node Type": "CTE Scan",
|
||||||
|
"Total Cost": 5.0,
|
||||||
|
"Plan Rows": 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
mock_conn.close = AsyncMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
|
||||||
|
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": pg_instance.id,
|
||||||
|
"database": "test_db",
|
||||||
|
"query": "WITH cte AS (SELECT 1) SELECT * FROM cte",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["db_type"] == "postgres"
|
||||||
|
|
||||||
|
async def test_explain_unsupported_db_type(self, client, admin_token, db):
|
||||||
|
"""测试不支持的 db_type。"""
|
||||||
|
# Create a Redis instance
|
||||||
|
inst = Instance(
|
||||||
|
name="TestRedis",
|
||||||
|
db_type="redis",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=6379,
|
||||||
|
username="",
|
||||||
|
password=encrypt_password(""),
|
||||||
|
database="0",
|
||||||
|
env="development",
|
||||||
|
status="disconnected",
|
||||||
|
)
|
||||||
|
db.add(inst)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(inst)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/sql-assistant/explain",
|
||||||
|
json={
|
||||||
|
"instance_id": inst.id,
|
||||||
|
"database": "0",
|
||||||
|
"query": "SELECT * FROM users",
|
||||||
|
},
|
||||||
|
headers=auth_header(admin_token),
|
||||||
|
)
|
||||||
|
# Either 400 or 500 depending on the error path
|
||||||
|
assert resp.status_code in (400, 500)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Part 6: ExplainService 单元测试 (直接 mock 连接)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestExplainServiceUnit:
|
||||||
|
"""直接测试 ExplainService 的路由逻辑。"""
|
||||||
|
|
||||||
|
def _make_instance(self, db_type="mysql", **overrides):
|
||||||
|
defaults = dict(
|
||||||
|
name="Test",
|
||||||
|
db_type=db_type,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=3306,
|
||||||
|
username="root",
|
||||||
|
password="test123",
|
||||||
|
database="test_db",
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
inst = MagicMock(spec=Instance)
|
||||||
|
for k, v in defaults.items():
|
||||||
|
setattr(inst, k, v)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mysql_explain_service(self):
|
||||||
|
"""测试 ExplainService MySQL 路径。"""
|
||||||
|
instance = self._make_instance(db_type="mysql")
|
||||||
|
|
||||||
|
mock_cursor = AsyncMock()
|
||||||
|
mock_cursor.execute = AsyncMock()
|
||||||
|
mock_cursor.fetchall = AsyncMock(return_value=[
|
||||||
|
{"EXPLAIN": json.dumps({
|
||||||
|
"query_block": {
|
||||||
|
"select_id": 1,
|
||||||
|
"table": {
|
||||||
|
"table_name": "t",
|
||||||
|
"access_type": "ALL",
|
||||||
|
"rows_examined_per_scan": 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
])
|
||||||
|
mock_cursor.description = [("EXPLAIN",)]
|
||||||
|
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
|
||||||
|
mock_cursor.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.cursor = MagicMock(return_value=mock_cursor)
|
||||||
|
mock_conn.close = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.aiomysql") as mock_aiomysql:
|
||||||
|
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
mock_aiomysql.DictCursor = MagicMock()
|
||||||
|
|
||||||
|
result = await ExplainService.execute_explain(
|
||||||
|
instance, "test_db", "SELECT * FROM t"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["db_type"] == "mysql"
|
||||||
|
assert result["execution_plan"][0]["operation"] == "Table Scan"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_postgres_explain_service(self):
|
||||||
|
"""测试 ExplainService PostgreSQL 路径。"""
|
||||||
|
instance = self._make_instance(db_type="postgres", port=5432)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
|
||||||
|
{"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}}
|
||||||
|
]))
|
||||||
|
mock_conn.close = AsyncMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
|
||||||
|
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
result = await ExplainService.execute_explain(
|
||||||
|
instance, "test_db", "SELECT * FROM t"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["db_type"] == "postgres"
|
||||||
|
assert result["execution_plan"][0]["operation"] == "Table Scan"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsupported_db_type(self):
|
||||||
|
"""测试不支持的 db_type。"""
|
||||||
|
instance = self._make_instance(db_type="redis")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="暂不支持"):
|
||||||
|
await ExplainService.execute_explain(instance, "0", "SELECT * FROM key")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_select_rejected(self):
|
||||||
|
"""测试非 SELECT/WITH 查询被拒绝。"""
|
||||||
|
instance = self._make_instance(db_type="mysql")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="SELECT"):
|
||||||
|
await ExplainService.execute_explain(instance, "test_db", "INSERT INTO t VALUES (1)")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_with_query_accepted(self):
|
||||||
|
"""测试 WITH (CTE) 查询被接受。"""
|
||||||
|
instance = self._make_instance(db_type="postgres", port=5432)
|
||||||
|
|
||||||
|
mock_conn = AsyncMock()
|
||||||
|
mock_conn.fetchval = AsyncMock(return_value=json.dumps([
|
||||||
|
{"Plan": {"Node Type": "CTE Scan", "Total Cost": 1.0, "Plan Rows": 1}}
|
||||||
|
]))
|
||||||
|
mock_conn.close = AsyncMock()
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.asyncpg") as mock_asyncpg:
|
||||||
|
mock_asyncpg.connect = AsyncMock(return_value=mock_conn)
|
||||||
|
|
||||||
|
result = await ExplainService.execute_explain(
|
||||||
|
instance, "test_db", "WITH cte AS (SELECT 1) SELECT * FROM cte"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["db_type"] == "postgres"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sqlite_explain_service(self):
|
||||||
|
"""测试 ExplainService SQLite 路径。"""
|
||||||
|
instance = self._make_instance(db_type="sqlite", host="/tmp/test_explain.db", port=0)
|
||||||
|
|
||||||
|
mock_rows = [
|
||||||
|
{"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_id (id=?)"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("app.services.explain_service.asyncio") as mock_asyncio_mod:
|
||||||
|
# We need to mock asyncio.get_event_loop().run_in_executor()
|
||||||
|
mock_loop = MagicMock()
|
||||||
|
mock_loop.run_in_executor = AsyncMock(return_value=mock_rows)
|
||||||
|
mock_asyncio_mod.get_event_loop.return_value = mock_loop
|
||||||
|
mock_asyncio_mod.wait_for = AsyncMock(return_value=mock_rows)
|
||||||
|
# Keep the real TimeoutError
|
||||||
|
mock_asyncio_mod.TimeoutError = asyncio.TimeoutError
|
||||||
|
|
||||||
|
# Also need to patch sqlite3 inside the module
|
||||||
|
import sqlite3 as real_sqlite3
|
||||||
|
|
||||||
|
result = await ExplainService._explain_sqlite(instance, "SELECT * FROM users WHERE id = 1")
|
||||||
|
|
||||||
|
assert result["db_type"] == "sqlite"
|
||||||
|
assert result["execution_plan"][0]["operation"] == "Index Lookup"
|
||||||
|
assert result["execution_plan"][0]["table"] == "users"
|
||||||
|
assert result["execution_plan"][0]["key"] == "idx_id"
|
||||||
+181
-206
@@ -1,7 +1,7 @@
|
|||||||
"""查询历史和快捷查询 API 测试 — P1 审计闭环。
|
"""查询历史和快捷查询 持久化测试 — 验证 SQLite 存储。
|
||||||
|
|
||||||
测试范围:
|
测试范围:
|
||||||
- 查询历史记录和查看
|
- 查询历史记录和查看(CRUD + 分页 + 过滤)
|
||||||
- 收藏切换
|
- 收藏切换
|
||||||
- 快捷查询 CRUD
|
- 快捷查询 CRUD
|
||||||
- 查询统计
|
- 查询统计
|
||||||
@@ -10,10 +10,11 @@
|
|||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import AsyncClient
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.security import create_access_token, hash_password
|
from app.core.security import create_access_token, hash_password
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.query_history import QueryHistory, QuickQueryModel
|
||||||
from app.services.query_history_service import QueryHistoryService
|
from app.services.query_history_service import QueryHistoryService
|
||||||
|
|
||||||
|
|
||||||
@@ -39,9 +40,10 @@ class TestQueryHistoryRecord:
|
|||||||
"""查询历史记录功能测试。"""
|
"""查询历史记录功能测试。"""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_record_query(self):
|
async def test_record_query(self, db: AsyncSession):
|
||||||
"""测试记录一条查询。"""
|
"""测试记录一条查询。"""
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
username="admin",
|
username="admin",
|
||||||
instance_id=1,
|
instance_id=1,
|
||||||
@@ -53,6 +55,7 @@ class TestQueryHistoryRecord:
|
|||||||
execution_time_ms=50,
|
execution_time_ms=50,
|
||||||
result_rows=10,
|
result_rows=10,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert entry.id > 0
|
assert entry.id > 0
|
||||||
assert entry.user_id == 1
|
assert entry.user_id == 1
|
||||||
assert entry.db_type == "mysql"
|
assert entry.db_type == "mysql"
|
||||||
@@ -63,9 +66,10 @@ class TestQueryHistoryRecord:
|
|||||||
assert entry.error_message is None
|
assert entry.error_message is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_record_failed_query(self):
|
async def test_record_failed_query(self, db: AsyncSession):
|
||||||
"""测试记录失败的查询。"""
|
"""测试记录失败的查询。"""
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
username="admin",
|
username="admin",
|
||||||
instance_id=1,
|
instance_id=1,
|
||||||
@@ -79,11 +83,12 @@ class TestQueryHistoryRecord:
|
|||||||
is_success=False,
|
is_success=False,
|
||||||
error_message="Table doesn't exist",
|
error_message="Table doesn't exist",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert entry.is_success is False
|
assert entry.is_success is False
|
||||||
assert entry.error_message == "Table doesn't exist"
|
assert entry.error_message == "Table doesn't exist"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_record_multiple_db_types(self):
|
async def test_record_multiple_db_types(self, db: AsyncSession):
|
||||||
"""测试记录不同数据库类型的查询。"""
|
"""测试记录不同数据库类型的查询。"""
|
||||||
for db_type, query, q_type in [
|
for db_type, query, q_type in [
|
||||||
("mysql", "SELECT 1", "SELECT"),
|
("mysql", "SELECT 1", "SELECT"),
|
||||||
@@ -92,6 +97,7 @@ class TestQueryHistoryRecord:
|
|||||||
("mongo", '{"collection": "users", "operation": "find"}', "find"),
|
("mongo", '{"collection": "users", "operation": "find"}', "find"),
|
||||||
]:
|
]:
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
username="admin",
|
username="admin",
|
||||||
instance_id=1,
|
instance_id=1,
|
||||||
@@ -103,14 +109,16 @@ class TestQueryHistoryRecord:
|
|||||||
execution_time_ms=10,
|
execution_time_ms=10,
|
||||||
result_rows=1,
|
result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert entry.db_type == db_type
|
assert entry.db_type == db_type
|
||||||
assert entry.query_type == q_type
|
assert entry.query_type == q_type
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_record_query_long_content_truncated(self):
|
async def test_record_query_long_content_truncated(self, db: AsyncSession):
|
||||||
"""测试超长查询内容被截断。"""
|
"""测试超长查询内容被截断。"""
|
||||||
long_sql = "SELECT * FROM t WHERE id = " + "1" * 3000
|
long_sql = "SELECT * FROM t WHERE id = " + "1" * 3000
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
username="admin",
|
username="admin",
|
||||||
instance_id=1,
|
instance_id=1,
|
||||||
@@ -122,18 +130,16 @@ class TestQueryHistoryRecord:
|
|||||||
execution_time_ms=10,
|
execution_time_ms=10,
|
||||||
result_rows=0,
|
result_rows=0,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert len(entry.query_content) == 2000
|
assert len(entry.query_content) == 2000
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_record_query_max_history_per_user(self):
|
async def test_record_query_max_history_per_user(self, db: AsyncSession):
|
||||||
"""测试每个用户最多保留500条记录。"""
|
"""测试每个用户最多保留500条记录。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
# 创建超过限制的记录
|
# 创建超过限制的记录
|
||||||
for i in range(510):
|
for i in range(510):
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=99,
|
user_id=99,
|
||||||
username="test_user",
|
username="test_user",
|
||||||
instance_id=1,
|
instance_id=1,
|
||||||
@@ -145,13 +151,14 @@ class TestQueryHistoryRecord:
|
|||||||
execution_time_ms=i,
|
execution_time_ms=i,
|
||||||
result_rows=0,
|
result_rows=0,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
user_entries = [h for h in QueryHistoryService._history if h.user_id == 99]
|
from sqlalchemy import select, func
|
||||||
assert len(user_entries) <= 500
|
count_stmt = select(func.count()).select_from(QueryHistory).where(
|
||||||
|
QueryHistory.user_id == 99
|
||||||
# 清理
|
)
|
||||||
QueryHistoryService._history = []
|
total = (await db.execute(count_stmt)).scalar()
|
||||||
QueryHistoryService._history_counter = 0
|
assert total <= 500
|
||||||
|
|
||||||
|
|
||||||
# ── 查询历史查看 ──
|
# ── 查询历史查看 ──
|
||||||
@@ -160,12 +167,8 @@ class TestQueryHistoryRecord:
|
|||||||
class TestQueryHistoryView:
|
class TestQueryHistoryView:
|
||||||
"""查询历史查看功能测试。"""
|
"""查询历史查看功能测试。"""
|
||||||
|
|
||||||
async def _setup_history(self):
|
async def _setup_history(self, db: AsyncSession):
|
||||||
"""准备测试数据。"""
|
"""准备测试数据。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
entries = [
|
entries = [
|
||||||
("SELECT * FROM users", "mysql", 1, 50, 10),
|
("SELECT * FROM users", "mysql", 1, 50, 10),
|
||||||
("SELECT * FROM orders", "mysql", 1, 30, 20),
|
("SELECT * FROM orders", "mysql", 1, 30, 20),
|
||||||
@@ -174,6 +177,7 @@ class TestQueryHistoryView:
|
|||||||
]
|
]
|
||||||
for sql, db_type, inst_id, time_ms, rows in entries:
|
for sql, db_type, inst_id, time_ms, rows in entries:
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1,
|
user_id=1,
|
||||||
username="admin",
|
username="admin",
|
||||||
instance_id=inst_id,
|
instance_id=inst_id,
|
||||||
@@ -185,67 +189,68 @@ class TestQueryHistoryView:
|
|||||||
execution_time_ms=time_ms,
|
execution_time_ms=time_ms,
|
||||||
result_rows=rows,
|
result_rows=rows,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_default(self):
|
async def test_get_history_default(self, db: AsyncSession):
|
||||||
"""测试默认查看历史。"""
|
"""测试默认查看历史。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1)
|
result = await QueryHistoryService.get_history(db=db, user_id=1)
|
||||||
assert result["total"] == 4
|
assert result["total"] == 4
|
||||||
assert result["page"] == 1
|
assert result["page"] == 1
|
||||||
assert result["page_size"] == 20
|
assert result["page_size"] == 20
|
||||||
assert len(result["items"]) == 4
|
assert len(result["items"]) == 4
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_filter_by_db_type(self):
|
async def test_get_history_filter_by_db_type(self, db: AsyncSession):
|
||||||
"""测试按数据库类型筛选。"""
|
"""测试按数据库类型筛选。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1, db_type="mysql")
|
result = await QueryHistoryService.get_history(db=db, user_id=1, db_type="mysql")
|
||||||
assert result["total"] == 2
|
assert result["total"] == 2
|
||||||
for item in result["items"]:
|
for item in result["items"]:
|
||||||
assert item["db_type"] == "mysql"
|
assert item["db_type"] == "mysql"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_filter_by_instance(self):
|
async def test_get_history_filter_by_instance(self, db: AsyncSession):
|
||||||
"""测试按实例ID筛选。"""
|
"""测试按实例ID筛选。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1, instance_id=2)
|
result = await QueryHistoryService.get_history(db=db, user_id=1, instance_id=2)
|
||||||
assert result["total"] == 1
|
assert result["total"] == 1
|
||||||
assert result["items"][0]["db_type"] == "redis"
|
assert result["items"][0]["db_type"] == "redis"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_filter_by_keyword(self):
|
async def test_get_history_filter_by_keyword(self, db: AsyncSession):
|
||||||
"""测试按关键词筛选。"""
|
"""测试按关键词筛选。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1, keyword="users")
|
result = await QueryHistoryService.get_history(db=db, user_id=1, keyword="users")
|
||||||
assert result["total"] == 1
|
assert result["total"] == 1
|
||||||
assert "users" in result["items"][0]["query_content"].lower()
|
assert "users" in result["items"][0]["query_content"].lower()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_pagination(self):
|
async def test_get_history_pagination(self, db: AsyncSession):
|
||||||
"""测试分页。"""
|
"""测试分页。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1, page=1, page_size=2)
|
result = await QueryHistoryService.get_history(db=db, user_id=1, page=1, page_size=2)
|
||||||
assert result["total"] == 4
|
assert result["total"] == 4
|
||||||
assert len(result["items"]) == 2
|
assert len(result["items"]) == 2
|
||||||
|
|
||||||
result2 = await QueryHistoryService.get_history(user_id=1, page=2, page_size=2)
|
result2 = await QueryHistoryService.get_history(db=db, user_id=1, page=2, page_size=2)
|
||||||
assert result2["total"] == 4
|
assert result2["total"] == 4
|
||||||
assert len(result2["items"]) == 2
|
assert len(result2["items"]) == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_history_other_user_empty(self):
|
async def test_get_history_other_user_empty(self, db: AsyncSession):
|
||||||
"""测试查看其他用户的历史为空。"""
|
"""测试查看其他用户的历史为空。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=999)
|
result = await QueryHistoryService.get_history(db=db, user_id=999)
|
||||||
assert result["total"] == 0
|
assert result["total"] == 0
|
||||||
assert result["items"] == []
|
assert result["items"] == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_history_sorted_by_time_desc(self):
|
async def test_history_sorted_by_time_desc(self, db: AsyncSession):
|
||||||
"""测试历史按时间倒序排列。"""
|
"""测试历史按时间倒序排列。"""
|
||||||
await self._setup_history()
|
await self._setup_history(db)
|
||||||
result = await QueryHistoryService.get_history(user_id=1)
|
result = await QueryHistoryService.get_history(db=db, user_id=1)
|
||||||
times = [item["created_at"] for item in result["items"]]
|
times = [item["created_at"] for item in result["items"]]
|
||||||
assert times == sorted(times, reverse=True)
|
assert times == sorted(times, reverse=True)
|
||||||
|
|
||||||
@@ -256,61 +261,61 @@ class TestQueryHistoryView:
|
|||||||
class TestQueryHistoryFavorite:
|
class TestQueryHistoryFavorite:
|
||||||
"""查询收藏功能测试。"""
|
"""查询收藏功能测试。"""
|
||||||
|
|
||||||
async def _setup_for_favorite(self):
|
async def _setup_for_favorite(self, db: AsyncSession):
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
|
db=db,
|
||||||
user_id=1, username="admin", instance_id=1,
|
user_id=1, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content="SELECT 1", query_type="SELECT",
|
query_content="SELECT 1", query_type="SELECT",
|
||||||
execution_time_ms=10, result_rows=1,
|
execution_time_ms=10, result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return entry.id
|
return entry.id
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_toggle_favorite_add(self):
|
async def test_toggle_favorite_add(self, db: AsyncSession):
|
||||||
"""测试添加收藏。"""
|
"""测试添加收藏。"""
|
||||||
entry_id = await self._setup_for_favorite()
|
entry_id = await self._setup_for_favorite(db)
|
||||||
is_fav = await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
|
is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
|
||||||
|
await db.commit()
|
||||||
assert is_fav is True
|
assert is_fav is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_toggle_favorite_remove(self):
|
async def test_toggle_favorite_remove(self, db: AsyncSession):
|
||||||
"""测试取消收藏。"""
|
"""测试取消收藏。"""
|
||||||
entry_id = await self._setup_for_favorite()
|
entry_id = await self._setup_for_favorite(db)
|
||||||
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
|
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
|
||||||
is_fav = await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
|
await db.commit()
|
||||||
|
is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
|
||||||
|
await db.commit()
|
||||||
assert is_fav is False
|
assert is_fav is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_favorites(self):
|
async def test_get_favorites(self, db: AsyncSession):
|
||||||
"""测试获取收藏列表。"""
|
"""测试获取收藏列表。"""
|
||||||
entry_id = await self._setup_for_favorite()
|
entry_id = await self._setup_for_favorite(db)
|
||||||
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
|
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
result = await QueryHistoryService.get_favorites(user_id=1)
|
result = await QueryHistoryService.get_favorites(db=db, user_id=1)
|
||||||
assert result["total"] == 1
|
assert result["total"] == 1
|
||||||
assert result["items"][0]["id"] == entry_id
|
assert result["items"][0]["id"] == entry_id
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_favorites_empty(self):
|
async def test_get_favorites_empty(self, db: AsyncSession):
|
||||||
"""测试无收藏时返回空列表。"""
|
"""测试无收藏时返回空列表。"""
|
||||||
QueryHistoryService._history = []
|
result = await QueryHistoryService.get_favorites(db=db, user_id=1)
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
result = await QueryHistoryService.get_favorites(user_id=1)
|
|
||||||
assert result["total"] == 0
|
assert result["total"] == 0
|
||||||
assert result["items"] == []
|
assert result["items"] == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_favorites_per_user(self):
|
async def test_favorites_per_user(self, db: AsyncSession):
|
||||||
"""测试收藏是用户隔离的。"""
|
"""测试收藏是用户隔离的。"""
|
||||||
entry_id = await self._setup_for_favorite()
|
entry_id = await self._setup_for_favorite(db)
|
||||||
await QueryHistoryService.toggle_favorite(user_id=1, history_id=entry_id)
|
await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
result = await QueryHistoryService.get_favorites(user_id=999)
|
result = await QueryHistoryService.get_favorites(db=db, user_id=999)
|
||||||
assert result["total"] == 0
|
assert result["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -320,15 +325,11 @@ class TestQueryHistoryFavorite:
|
|||||||
class TestQuickQueries:
|
class TestQuickQueries:
|
||||||
"""快捷查询功能测试。"""
|
"""快捷查询功能测试。"""
|
||||||
|
|
||||||
async def _setup(self):
|
|
||||||
QueryHistoryService._quick_queries = []
|
|
||||||
QueryHistoryService._quick_counter = 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_quick_query(self):
|
async def test_create_quick_query(self, db: AsyncSession):
|
||||||
"""测试创建快捷查询。"""
|
"""测试创建快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
qq = await QueryHistoryService.create_quick_query(
|
qq = await QueryHistoryService.create_quick_query(
|
||||||
|
db=db,
|
||||||
name="查看活跃用户",
|
name="查看活跃用户",
|
||||||
description="查看最近活跃的用户列表",
|
description="查看最近活跃的用户列表",
|
||||||
query_content="SELECT * FROM users WHERE last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)",
|
query_content="SELECT * FROM users WHERE last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)",
|
||||||
@@ -338,117 +339,121 @@ class TestQuickQueries:
|
|||||||
created_by=1,
|
created_by=1,
|
||||||
created_by_name="admin",
|
created_by_name="admin",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert qq.id > 0
|
assert qq.id > 0
|
||||||
assert qq.name == "查看活跃用户"
|
assert qq.name == "查看活跃用户"
|
||||||
assert qq.is_shared is False
|
assert qq.is_shared is False
|
||||||
assert qq.created_by == 1
|
assert qq.created_by == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_quick_queries_personal(self):
|
async def test_get_quick_queries_personal(self, db: AsyncSession):
|
||||||
"""测试获取个人快捷查询。"""
|
"""测试获取个人快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="个人查询", description="", query_content="SELECT 1",
|
db=db, name="个人查询", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=False, created_by=1, created_by_name="admin",
|
is_shared=False, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="他人查询", description="", query_content="SELECT 2",
|
db=db, name="他人查询", description="", query_content="SELECT 2",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=False, created_by=2, created_by_name="dba",
|
is_shared=False, created_by=2, created_by_name="dba",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
# 用户1只能看到自己的 + 共享的
|
# 用户1只能看到自己的 + 共享的
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["name"] == "个人查询"
|
assert items[0]["name"] == "个人查询"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_quick_queries_shared(self):
|
async def test_get_quick_queries_shared(self, db: AsyncSession):
|
||||||
"""测试获取共享快捷查询。"""
|
"""测试获取共享快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="共享查询", description="", query_content="SELECT 1",
|
db=db, name="共享查询", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=2, created_by_name="dba",
|
is_shared=True, created_by=2, created_by_name="dba",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["is_shared"] is True
|
assert items[0]["is_shared"] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_quick_queries_filter_db_type(self):
|
async def test_get_quick_queries_filter_db_type(self, db: AsyncSession):
|
||||||
"""测试按数据库类型筛选快捷查询。"""
|
"""测试按数据库类型筛选快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="MySQL查询", description="", query_content="SELECT 1",
|
db=db, name="MySQL查询", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="Redis查询", description="", query_content="GET key",
|
db=db, name="Redis查询", description="", query_content="GET key",
|
||||||
db_type="redis", database_pattern="*",
|
db_type="redis", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1, db_type="redis")
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, db_type="redis")
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["db_type"] == "redis"
|
assert items[0]["db_type"] == "redis"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_quick_queries_filter_keyword(self):
|
async def test_get_quick_queries_filter_keyword(self, db: AsyncSession):
|
||||||
"""测试按关键词筛选快捷查询。"""
|
"""测试按关键词筛选快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="查看活跃用户", description="", query_content="SELECT 1",
|
db=db, name="查看活跃用户", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
await QueryHistoryService.create_quick_query(
|
await QueryHistoryService.create_quick_query(
|
||||||
name="查看慢查询", description="", query_content="SELECT 2",
|
db=db, name="查看慢查询", description="", query_content="SELECT 2",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1, keyword="活跃")
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, keyword="活跃")
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert "活跃" in items[0]["name"]
|
assert "活跃" in items[0]["name"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_use_quick_query(self):
|
async def test_use_quick_query(self, db: AsyncSession):
|
||||||
"""测试使用快捷查询。"""
|
"""测试使用快捷查询。"""
|
||||||
await self._setup()
|
|
||||||
qq = await QueryHistoryService.create_quick_query(
|
qq = await QueryHistoryService.create_quick_query(
|
||||||
name="测试", description="", query_content="SELECT 1",
|
db=db, name="测试", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
content = await QueryHistoryService.use_quick_query(qq.id)
|
content = await QueryHistoryService.use_quick_query(db, qq.id)
|
||||||
|
await db.commit()
|
||||||
assert content == "SELECT 1"
|
assert content == "SELECT 1"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_use_quick_query_increments_count(self):
|
async def test_use_quick_query_increments_count(self, db: AsyncSession):
|
||||||
"""测试使用快捷查询增加使用次数。"""
|
"""测试使用快捷查询增加使用次数。"""
|
||||||
await self._setup()
|
|
||||||
qq = await QueryHistoryService.create_quick_query(
|
qq = await QueryHistoryService.create_quick_query(
|
||||||
name="测试", description="", query_content="SELECT 1",
|
db=db, name="测试", description="", query_content="SELECT 1",
|
||||||
db_type="mysql", database_pattern="*",
|
db_type="mysql", database_pattern="*",
|
||||||
is_shared=True, created_by=1, created_by_name="admin",
|
is_shared=True, created_by=1, created_by_name="admin",
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
assert qq.usage_count == 0
|
assert qq.usage_count == 0
|
||||||
|
|
||||||
await QueryHistoryService.use_quick_query(qq.id)
|
await QueryHistoryService.use_quick_query(db, qq.id)
|
||||||
|
await db.commit()
|
||||||
assert qq.usage_count == 1
|
assert qq.usage_count == 1
|
||||||
|
|
||||||
await QueryHistoryService.use_quick_query(qq.id)
|
await QueryHistoryService.use_quick_query(db, qq.id)
|
||||||
|
await db.commit()
|
||||||
assert qq.usage_count == 2
|
assert qq.usage_count == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_use_quick_query_not_found(self):
|
async def test_use_quick_query_not_found(self, db: AsyncSession):
|
||||||
"""测试使用不存在的快捷查询。"""
|
"""测试使用不存在的快捷查询。"""
|
||||||
content = await QueryHistoryService.use_quick_query(99999)
|
content = await QueryHistoryService.use_quick_query(db, 99999)
|
||||||
assert content is None
|
assert content is None
|
||||||
|
|
||||||
|
|
||||||
@@ -459,24 +464,22 @@ class TestDefaultQuickQueries:
|
|||||||
"""预置快捷查询模板测试。"""
|
"""预置快捷查询模板测试。"""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_templates_exist(self):
|
async def test_default_templates_exist(self, db: AsyncSession):
|
||||||
"""测试预置模板存在。"""
|
"""测试预置模板存在。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
# 预置了10个模板
|
# 预置了10个模板
|
||||||
assert len(items) == 10
|
assert len(items) == 10
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_templates_cover_db_types(self):
|
async def test_default_templates_cover_db_types(self, db: AsyncSession):
|
||||||
"""测试预置模板覆盖所有数据库类型。"""
|
"""测试预置模板覆盖所有数据库类型。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
db_types = {item["db_type"] for item in items}
|
db_types = {item["db_type"] for item in items}
|
||||||
assert "mysql" in db_types
|
assert "mysql" in db_types
|
||||||
assert "redis" in db_types
|
assert "redis" in db_types
|
||||||
@@ -484,27 +487,36 @@ class TestDefaultQuickQueries:
|
|||||||
assert "mongo" in db_types
|
assert "mongo" in db_types
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_templates_all_shared(self):
|
async def test_default_templates_all_shared(self, db: AsyncSession):
|
||||||
"""测试预置模板都是共享的。"""
|
"""测试预置模板都是共享的。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
for item in items:
|
for item in items:
|
||||||
assert item["is_shared"] is True
|
assert item["is_shared"] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_templates_system_created(self):
|
async def test_default_templates_system_created(self, db: AsyncSession):
|
||||||
"""测试预置模板由系统创建。"""
|
"""测试预置模板由系统创建。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
items = await QueryHistoryService.get_quick_queries(user_id=1)
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
for item in items:
|
for item in items:
|
||||||
assert item["created_by_name"] == "系统"
|
assert item["created_by_name"] == "系统"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_idempotent(self, db: AsyncSession):
|
||||||
|
"""测试 seed 是幂等的(多次调用不会重复插入)。"""
|
||||||
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
|
await db.commit()
|
||||||
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
items = await QueryHistoryService.get_quick_queries(db=db, user_id=1)
|
||||||
|
assert len(items) == 10
|
||||||
|
|
||||||
|
|
||||||
# ── 查询统计 ──
|
# ── 查询统计 ──
|
||||||
|
|
||||||
@@ -512,14 +524,10 @@ class TestDefaultQuickQueries:
|
|||||||
class TestQueryStatistics:
|
class TestQueryStatistics:
|
||||||
"""查询统计功能测试。"""
|
"""查询统计功能测试。"""
|
||||||
|
|
||||||
async def _setup_stats(self):
|
async def _setup_stats(self, db: AsyncSession):
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=1,
|
db=db, user_id=1, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content=f"SELECT * FROM table_{i} WHERE id = 1",
|
query_content=f"SELECT * FROM table_{i} WHERE id = 1",
|
||||||
query_type="SELECT",
|
query_type="SELECT",
|
||||||
@@ -528,29 +536,30 @@ class TestQueryStatistics:
|
|||||||
)
|
)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=2,
|
db=db, user_id=1, username="admin", instance_id=2,
|
||||||
instance_name="测试Redis", db_type="redis", database_name="db0",
|
instance_name="测试Redis", db_type="redis", database_name="db0",
|
||||||
query_content="GET mykey",
|
query_content="GET mykey",
|
||||||
query_type="GET",
|
query_type="GET",
|
||||||
execution_time_ms=5,
|
execution_time_ms=5,
|
||||||
result_rows=1,
|
result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_basic(self):
|
async def test_statistics_basic(self, db: AsyncSession):
|
||||||
"""测试基本统计。"""
|
"""测试基本统计。"""
|
||||||
await self._setup_stats()
|
await self._setup_stats(db)
|
||||||
stats = await QueryHistoryService.get_query_statistics(user_id=1)
|
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
|
||||||
|
|
||||||
assert stats["total_queries"] == 8
|
assert stats["total_queries"] == 8
|
||||||
assert stats["avg_execution_time_ms"] > 0
|
assert stats["avg_execution_time_ms"] > 0
|
||||||
assert stats["success_rate"] == 100.0
|
assert stats["success_rate"] == 100.0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_db_type_distribution(self):
|
async def test_statistics_db_type_distribution(self, db: AsyncSession):
|
||||||
"""测试数据库类型分布统计。"""
|
"""测试数据库类型分布统计。"""
|
||||||
await self._setup_stats()
|
await self._setup_stats(db)
|
||||||
stats = await QueryHistoryService.get_query_statistics(user_id=1)
|
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
|
||||||
|
|
||||||
assert "mysql" in stats["db_type_distribution"]
|
assert "mysql" in stats["db_type_distribution"]
|
||||||
assert "redis" in stats["db_type_distribution"]
|
assert "redis" in stats["db_type_distribution"]
|
||||||
@@ -558,27 +567,26 @@ class TestQueryStatistics:
|
|||||||
assert stats["db_type_distribution"]["redis"] == 3
|
assert stats["db_type_distribution"]["redis"] == 3
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_daily_trend(self):
|
async def test_statistics_daily_trend(self, db: AsyncSession):
|
||||||
"""测试每日趋势。"""
|
"""测试每日趋势。"""
|
||||||
await self._setup_stats()
|
await self._setup_stats(db)
|
||||||
stats = await QueryHistoryService.get_query_statistics(user_id=1)
|
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
|
||||||
|
|
||||||
assert "daily_trend" in stats
|
assert "daily_trend" in stats
|
||||||
assert len(stats["daily_trend"]) == 7
|
assert len(stats["daily_trend"]) == 7
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_empty(self):
|
async def test_statistics_empty(self, db: AsyncSession):
|
||||||
"""测试无记录时返回提示。"""
|
"""测试无记录时返回提示。"""
|
||||||
QueryHistoryService._history = []
|
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=999)
|
||||||
stats = await QueryHistoryService.get_query_statistics(user_id=999)
|
|
||||||
assert "message" in stats
|
assert "message" in stats
|
||||||
assert stats["message"] == "暂无查询记录"
|
assert stats["message"] == "暂无查询记录"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_top_tables(self):
|
async def test_statistics_top_tables(self, db: AsyncSession):
|
||||||
"""测试高频表统计。"""
|
"""测试高频表统计。"""
|
||||||
await self._setup_stats()
|
await self._setup_stats(db)
|
||||||
stats = await QueryHistoryService.get_query_statistics(user_id=1)
|
stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1)
|
||||||
|
|
||||||
assert "top_tables" in stats
|
assert "top_tables" in stats
|
||||||
# 应该能从SQL中提取表名
|
# 应该能从SQL中提取表名
|
||||||
@@ -592,18 +600,14 @@ class TestQueryHistoryAPI:
|
|||||||
"""查询历史 API 端点集成测试。"""
|
"""查询历史 API 端点集成测试。"""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_history_unauthorized(self, client: AsyncClient):
|
async def test_my_history_unauthorized(self, client):
|
||||||
"""测试未认证访问历史。"""
|
"""测试未认证访问历史。"""
|
||||||
resp = await client.get("/api/v1/query-history/my")
|
resp = await client.get("/api/v1/query-history/my")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_history_empty(self, client: AsyncClient, admin_headers: dict):
|
async def test_my_history_empty(self, client, admin_headers, db):
|
||||||
"""测试空历史记录。"""
|
"""测试空历史记录。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
|
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -611,18 +615,15 @@ class TestQueryHistoryAPI:
|
|||||||
assert data["items"] == []
|
assert data["items"] == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_history_with_data(self, client: AsyncClient, admin_headers: dict):
|
async def test_my_history_with_data(self, client, admin_headers, db, admin_user):
|
||||||
"""测试有数据的历史记录。"""
|
"""测试有数据的历史记录。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=1,
|
db=db, user_id=admin_user.id, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content="SELECT 1", query_type="SELECT",
|
query_content="SELECT 1", query_type="SELECT",
|
||||||
execution_time_ms=10, result_rows=1,
|
execution_time_ms=10, result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
|
resp = await client.get("/api/v1/query-history/my", headers=admin_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -630,24 +631,21 @@ class TestQueryHistoryAPI:
|
|||||||
assert data["total"] >= 1
|
assert data["total"] >= 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_history_filter_db_type(self, client: AsyncClient, admin_headers: dict):
|
async def test_my_history_filter_db_type(self, client, admin_headers, db, admin_user):
|
||||||
"""测试按类型筛选。"""
|
"""测试按类型筛选。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=1,
|
db=db, user_id=admin_user.id, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content="SELECT 1", query_type="SELECT",
|
query_content="SELECT 1", query_type="SELECT",
|
||||||
execution_time_ms=10, result_rows=1,
|
execution_time_ms=10, result_rows=1,
|
||||||
)
|
)
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=2,
|
db=db, user_id=admin_user.id, username="admin", instance_id=2,
|
||||||
instance_name="测试Redis", db_type="redis", database_name="db0",
|
instance_name="测试Redis", db_type="redis", database_name="db0",
|
||||||
query_content="GET key", query_type="GET",
|
query_content="GET key", query_type="GET",
|
||||||
execution_time_ms=5, result_rows=1,
|
execution_time_ms=5, result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/v1/query-history/my?db_type=mysql",
|
"/api/v1/query-history/my?db_type=mysql",
|
||||||
@@ -659,18 +657,15 @@ class TestQueryHistoryAPI:
|
|||||||
assert data["items"][0]["db_type"] == "mysql"
|
assert data["items"][0]["db_type"] == "mysql"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_toggle_favorite_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_toggle_favorite_api(self, client, admin_headers, db, admin_user):
|
||||||
"""测试收藏API。"""
|
"""测试收藏API。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
entry = await QueryHistoryService.record_query(
|
entry = await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=1,
|
db=db, user_id=admin_user.id, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content="SELECT 1", query_type="SELECT",
|
query_content="SELECT 1", query_type="SELECT",
|
||||||
execution_time_ms=10, result_rows=1,
|
execution_time_ms=10, result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"/api/v1/query-history/{entry.id}/favorite",
|
f"/api/v1/query-history/{entry.id}/favorite",
|
||||||
@@ -681,12 +676,8 @@ class TestQueryHistoryAPI:
|
|||||||
assert data["is_favorite"] is True
|
assert data["is_favorite"] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_favorites_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_favorites_api(self, client, admin_headers, db):
|
||||||
"""测试收藏列表API。"""
|
"""测试收藏列表API。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/favorites", headers=admin_headers)
|
resp = await client.get("/api/v1/query-history/favorites", headers=admin_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -694,23 +685,18 @@ class TestQueryHistoryAPI:
|
|||||||
assert "items" in data
|
assert "items" in data
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_statistics_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_statistics_api(self, client, admin_headers, db):
|
||||||
"""测试统计API。"""
|
"""测试统计API。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/statistics", headers=admin_headers)
|
resp = await client.get("/api/v1/query-history/statistics", headers=admin_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "message" in data or "total_queries" in data
|
assert "message" in data or "total_queries" in data
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quick_queries_list_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_quick_queries_list_api(self, client, admin_headers, db):
|
||||||
"""测试快捷查询列表API。"""
|
"""测试快捷查询列表API。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/quick-queries", headers=admin_headers)
|
resp = await client.get("/api/v1/query-history/quick-queries", headers=admin_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -719,11 +705,8 @@ class TestQueryHistoryAPI:
|
|||||||
assert len(data["items"]) == 10
|
assert len(data["items"]) == 10
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quick_queries_create_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_quick_queries_create_api(self, client, admin_headers, db):
|
||||||
"""测试创建快捷查询API。"""
|
"""测试创建快捷查询API。"""
|
||||||
QueryHistoryService._quick_queries = []
|
|
||||||
QueryHistoryService._quick_counter = 0
|
|
||||||
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/query-history/quick-queries",
|
"/api/v1/query-history/quick-queries",
|
||||||
params={
|
params={
|
||||||
@@ -741,11 +724,10 @@ class TestQueryHistoryAPI:
|
|||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quick_queries_filter_api(self, client: AsyncClient, admin_headers: dict):
|
async def test_quick_queries_filter_api(self, client, admin_headers, db):
|
||||||
"""测试快捷查询筛选API。"""
|
"""测试快捷查询筛选API。"""
|
||||||
QueryHistoryService._quick_queries = []
|
await QueryHistoryService.seed_default_quick_queries(db)
|
||||||
QueryHistoryService._quick_counter = 0
|
await db.commit()
|
||||||
QueryHistoryService.seed_default_quick_queries()
|
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/v1/query-history/quick-queries?db_type=redis",
|
"/api/v1/query-history/quick-queries?db_type=redis",
|
||||||
@@ -757,29 +739,22 @@ class TestQueryHistoryAPI:
|
|||||||
assert item["db_type"] == "redis"
|
assert item["db_type"] == "redis"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_guest_can_view_own_history(self, client: AsyncClient, guest_headers: dict):
|
async def test_guest_can_view_own_history(self, client, guest_headers, db):
|
||||||
"""测试访客可以查看自己的历史(但无权执行查询)。"""
|
"""测试访客可以查看自己的历史(但无权执行查询)。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
resp = await client.get("/api/v1/query-history/my", headers=guest_headers)
|
resp = await client.get("/api/v1/query-history/my", headers=guest_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pagination_parameters(self, client: AsyncClient, admin_headers: dict):
|
async def test_pagination_parameters(self, client, admin_headers, db, admin_user):
|
||||||
"""测试分页参数。"""
|
"""测试分页参数。"""
|
||||||
QueryHistoryService._history = []
|
|
||||||
QueryHistoryService._history_counter = 0
|
|
||||||
QueryHistoryService._favorites = set()
|
|
||||||
|
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
await QueryHistoryService.record_query(
|
await QueryHistoryService.record_query(
|
||||||
user_id=1, username="admin", instance_id=1,
|
db=db, user_id=admin_user.id, username="admin", instance_id=1,
|
||||||
instance_name="测试", db_type="mysql", database_name="test",
|
instance_name="测试", db_type="mysql", database_name="test",
|
||||||
query_content=f"SELECT {i}", query_type="SELECT",
|
query_content=f"SELECT {i}", query_type="SELECT",
|
||||||
execution_time_ms=10, result_rows=1,
|
execution_time_ms=10, result_rows=1,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
"/api/v1/query-history/my?page=1&page_size=5",
|
"/api/v1/query-history/my?page=1&page_size=5",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo, Fragment } from 'react';
|
||||||
import {
|
import {
|
||||||
Search, Download, Filter, ChevronDown, ChevronRight,
|
Search, Download, Filter, ChevronDown, ChevronRight,
|
||||||
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
|
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
|
||||||
@@ -440,7 +440,7 @@ export default function AuditLogsPage() {
|
|||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
const TargetIcon = TARGET_ICONS[log.target_type] || FileText;
|
const TargetIcon = TARGET_ICONS[log.target_type] || FileText;
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={log.id}>
|
||||||
<tr
|
<tr
|
||||||
key={log.id}
|
key={log.id}
|
||||||
className={`border-b border-[var(--dg-border-light)] transition-colors cursor-pointer ${
|
className={`border-b border-[var(--dg-border-light)] transition-colors cursor-pointer ${
|
||||||
@@ -516,7 +516,7 @@ export default function AuditLogsPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user