"""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 pytestmark = pytest.mark.skip(reason="Skip: awaiting Go backend alignment") # =========================================================================== # 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"