合入集成分支(阶段 2A 数据剖析、4B 剖析与 SQL 检查界面及修正)到 4A:两边功能都保留

冲突的解法:
- 后端 api/catalog.py:同时导入 catalog_impact 和 catalog_profile、SnapshotTampered;data/catalog.py:__all__ 取两边的并集重排。
- 2A 放宽了码值校验(含义可以为空,剖析写进来的是「含义待填写」):目录修改提案的码值补充改为含义为空的码不覆盖已有含义、
  只在原来没有这个码时加进去,补了测试;卡片和过程里的码值把空含义写成「含义待填写」,和目录页同一个说法。
- 前端 types.ts、terms.ts:两边各在同一处追加了新段落,按顺序都留下(提案、影响面、目录版本在前,剖析、码值、SQL 检查在后),
  没有重复的键;client.ts、decode.ts、CatalogPage.tsx、TableDetail.tsx 的导入取并集。
- check-catalog.mjs:影响面和剖析、保存数据源三条假路由都留下,各节和最后核对真库数量的逻辑不变。
- 4A 的测试数据源改用 tests/fixtures/sources.py 的 drop_source 删除,顺带作废连接池。
This commit is contained in:
ThearchyHelios
2026-10-03 12:35:47 +08:00
51 changed files with 4803 additions and 147 deletions
+65 -2
View File
@@ -8,9 +8,10 @@
- POST /catalog/{table}/patch 保存目录修改提案(乐观锁),记为人工确认
- GET /catalog/{table}/impact 影响面:引用这张表的已发布、受管模板(data/catalog_impact.py)
- POST /catalog/draft 同步起草(注释、外键、命名推断,可选模型)
- POST /catalog/profile 同步剖析:对业务库发少量只读查询,核对关系、取码值候选(data/catalog_profile.py)
规则都在 data/catalog.py,这里只做取源、转换形状和把异常翻成状态码:版本不符 409,结构不合规、审阅路径
不对 422,源或表不存在 404。所有写操作记录请求头 X-Actor 的署名(自报、未认证)。
不对 422,源或表不存在 404。所有写操作记录请求头 X-Actor 的署名(自报、未认证)。剖析没开启、正在剖析 409。
上传表格的源按当前快照的表结构回答(table_versions.resolve_source),和查询实际用的库是同一个版本。
"""
@@ -23,7 +24,8 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runs import actor_of
from app.data import catalog, catalog_impact, introspect, table_versions
from app.data import catalog, catalog_impact, catalog_profile, introspect, table_versions
from app.data.engine import SnapshotTampered
from app.db.base import get_session
from app.db.models import DataSource
@@ -65,6 +67,11 @@ class CatalogDraftIn(BaseModel):
use_model: bool = False
class CatalogProfileIn(BaseModel):
#: 要剖析的表;不给或为空时按使用次数取前 10 张目录里有待核实关系的表
tables: list[str] | None = Field(default=None, max_length=200)
async def _resolved(session: AsyncSession, source_id: str) -> tuple[DataSource, Any]:
"""(数据源记录, 用来读表结构的源):上传源解析成绑定当前快照的视图。"""
row = await session.get(DataSource, source_id)
@@ -227,6 +234,62 @@ async def draft_catalog(source_id: str, payload: CatalogDraftIn,
}
def _profile_table_out(t: catalog_profile.TableProfile) -> dict[str, Any]:
return {
"table_name": t.table,
"queries": t.queries,
"row_estimate": t.row_estimate.to_dict() if t.row_estimate else None,
"findings": t.findings,
"skipped": [s.to_dict() for s in t.skipped],
"date_ranges": t.date_ranges,
"added": t.added,
"updated": t.updated,
"removed": t.removed,
"version": t.version,
"error": t.error,
}
@router.post("/{source_id}/catalog/profile")
async def profile_catalog_tables(source_id: str, payload: CatalogProfileIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""同步剖析。返回每张表用了几条查询、写进目录的发现、跳过了什么及原因。
默认关闭:数据源设置里没开启数据剖析、数据源停用,409;同一个数据源已经在剖析,409。
"""
row, source = await _resolved(session, source_id)
try:
catalog_profile.ensure_enabled(row)
except catalog_profile.ProfileDisabled as e:
raise HTTPException(409, str(e)) from e
cache = source.schema_cache or {}
if not cache.get("tables"):
raise HTTPException(409, f"{introspect.why_empty(cache)},无法进行数据剖析。请先在数据源卡片上点「探查结构」")
actor = actor_of(x_actor)
try:
report = await catalog_profile.profile_catalog(session, row, source, tables=payload.tables or None,
actor=actor)
except (catalog_profile.ProfileDisabled, catalog_profile.ProfileBusy) as e:
raise HTTPException(409, str(e)) from e
except SnapshotTampered as e:
raise HTTPException(409, str(e)) from e
rows = [_profile_table_out(t) for t in report.tables]
note = None
if not rows:
note = "目录里还没有待核实的关联关系,请先起草数据目录,或指定要剖析的表"
return {
"profiled_at": report.profiled_at,
"actor": actor,
"settings": report.settings.to_dict(),
"queries_used": report.queries_used,
"stopped": report.stopped,
"tables": rows,
"total": {k: sum(r[k] for r in rows) for k in ("added", "updated", "removed")},
"note": note,
}
@router.post("/{source_id}/catalog/{table}/review")
async def review_catalog_item(source_id: str, table: str, payload: CatalogReviewIn,
x_actor: str | None = Header(default=None),
+3 -1
View File
@@ -23,6 +23,7 @@ from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw
from app.core.config import settings
from app.core.crypto import encrypt, mask
from app.data import catalog as data_catalog
from app.data.catalog_profile import profile_settings_problem
from app.data import introspect as introspect_mod
from app.data import table_versions
from app.data.engine import (
@@ -336,7 +337,8 @@ _MASK_FIELD = {
def _refuse_bad_options(options: dict[str, Any] | None) -> None:
problem = query_timeout_problem(options) or mask_columns_problem(options)
problem = (query_timeout_problem(options) or mask_columns_problem(options)
or profile_settings_problem(options))
if problem:
raise HTTPException(422, problem)
+80 -18
View File
@@ -15,13 +15,14 @@ notes 结构(每个「项」都是 {value, source, status},可选 note、upd
"valid_filter": 项(str,SQL 条件片段), "dedup": 项(str),
"columns": {列名: {"label": 项, "meaning": 项, "unit": 项,
"measure": 项("flow"|"stock"|"ratio"|"identifier"|"status"|"attribute"),
"codes": 项(dict[str, str])}},
"codes": 项(dict[码值, 含义];含义可为空串,表示待填写)}},
"relations": [{"id", "columns", "to_table", "to_columns", "cardinality", "coverage",
"source", "status", "note"?}]}
来源(source):comment 数据库注释、fk 外键约束、name 命名推断、profile 数据剖析(阶段 2)、llm 模型起草、
human 人工填写。状态(status):proposed 推断(命名推断、模型起草、数据库注释)、verified 有确证(外键
约束;阶段 2 的数据剖析也写这个状态)、confirmed 人工确认、rejected 人工驳回。只有 confirmed 和 verified
来源(source):comment 数据库注释、fk 外键约束、name 命名推断、profile 数据剖析(data/catalog_profile.py)、
llm 模型起草、human 人工填写。状态(status):proposed 推断(命名推断、模型起草、数据库注释)、verified 有确证
(外键约束;数据剖析核实了覆盖率和父键唯一的关系也是,见 profile_relation_holds)、confirmed 人工确认、
rejected 人工驳回。只有 confirmed 和 verified
将来会触发错误级检查;给模型看时 proposed 的项标「推断,未确认」;rejected 的项留着(防止下次起草又提出
来),但模型和检查都看不到。
@@ -98,6 +99,33 @@ def initial_status(source: str) -> str:
return _INITIAL_STATUS.get(source, "proposed")
#: 数据剖析把关系升为有确证的抽样覆盖率下限(data/catalog_profile.py 判定,撤销审阅时按它判回剖析的结论)
PROFILE_VERIFY_COVERAGE = 0.95
def profile_relation_holds(rel: Mapping[str, Any]) -> bool:
"""一条数据剖析得出的关系站不站得住:抽样覆盖率不低于 PROFILE_VERIFY_COVERAGE,而且被指向列核实唯一。
剖析只在被指向列核实唯一时才写基数(多对一或一对一),所以「基数有值」就是「父键唯一」。结论全在关系
自己的字段里,撤销审阅时照原样判得回来,不必另存一份「审阅前的状态」。
"""
coverage = rel.get("coverage")
return (rel.get("source") == "profile" and isinstance(coverage, (int, float)) and not isinstance(coverage, bool)
and coverage >= PROFILE_VERIFY_COVERAGE and rel.get("cardinality") in ("many_to_one", "one_to_one"))
def unreviewed_status(item: Mapping[str, Any]) -> str:
"""撤销审阅后回到的状态:来源的初始状态。
数据剖析是例外:它的状态随数据而定(覆盖率够、父键唯一的关系是有确证,其余是推断),不能一律回到
initial_status("profile") 的推断——那样人工确认过再撤销,有确证就无故丢了,要等下一次剖析才回来。
剖析提的码值候选、业务日期永远是推断。
"""
if item.get("source") == "profile" and "to_table" in item:
return "verified" if profile_relation_holds(item) else "proposed"
return initial_status(item.get("source"))
def make_item(value: Any, source: str, status: str | None = None, *, note: str | None = None,
at: str | None = None) -> dict[str, Any]:
"""构造一个项。status 不给时按来源取初始状态(initial_status)。"""
@@ -153,8 +181,10 @@ def _business_date_ok(value: Any) -> bool:
def _codes_ok(value: Any) -> bool:
"""码值对照:{码值: 含义},都是文字。含义可以是空串——数据剖析只知道列里出现过哪些取值,含义要等人填;
以前要求含义非空,剖析得到的码值候选就写不进来。"""
return (isinstance(value, dict) and bool(value)
and all(isinstance(k, str) and isinstance(v, str) and v.strip() for k, v in value.items()))
and all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()))
#: 表类型、度量类型在界面上的叫法(和 frontend/src/lib/terms.ts 的 CATALOG_KIND_LABEL / CATALOG_MEASURE_LABEL 一致)。
@@ -587,7 +617,7 @@ def review_item(notes: Mapping[str, Any] | None, path: str, action: str, *, at:
# 人工填写的项没有「推断时的样子」可回:撤销就是删掉,下次起草可以重新提出
del slots[key]
return _assemble(slots)
item["status"] = {"confirm": "confirmed", "reject": "rejected"}.get(action) or initial_status(item.get("source"))
item["status"] = {"confirm": "confirmed", "reject": "rejected"}.get(action) or unreviewed_status(item)
item["updated_at"] = at or now_iso()
return _assemble(slots)
@@ -652,7 +682,7 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str,
continue
kept = dict(prev)
status = item.get("status")
if status != prev.get("status") and status in ("confirmed", "rejected", initial_status(prev.get("source"))):
if status != prev.get("status") and status in ("confirmed", "rejected", unreviewed_status(prev)):
kept["status"] = status
kept["updated_at"] = at
if item.get("note") != prev.get("note"):
@@ -779,6 +809,17 @@ def _patch_relation(table: str, raw: Any, path_id: str | None, tables: Mapping[s
return value, None
def _merge_codes(before: Mapping[str, str], value: Mapping[str, str]) -> dict[str, str]:
"""码值的补充:给出的码加进去或改掉原来的含义。含义为空表示「只知道有这个码、含义待填写」(数据剖析的码值
候选就是这样),所以提案里含义为空的码不覆盖已有的含义,只在原来没有这个码时加进去。"""
out = dict(before)
for code, meaning in value.items():
if not str(meaning).strip() and str(out.get(code) or "").strip():
continue
out[code] = meaning
return out
def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
tables: Mapping[str, Any] | None = None) -> PatchPlan:
"""逐项核对一条目录修改提案,算出改前、改后。不改入参、不抛异常:不合法的项写进 problems。
@@ -790,7 +831,8 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
规则:
- 取值格式和 validate_notes 同一套(_TABLE_VALUE_RULES / _COLUMN_VALUE_RULES / _relation_problems)。
- 码值是补充:在现有(没被驳回的)码值上加或改给出的码,不整份替换——「status=9 表示作废」不该把已确认的
其余码值删掉。
其余码值删掉。含义为空的码(数据剖析写进来的「含义待填写」)可以被补上含义;提案里含义为空的码只在原来
没有这个码时加进去,不会把已有的含义抹掉(_merge_codes)。
- 看起来像计算公式的文字不收(_FORMULA):公式进口径卡。
- 同一个路径出现两次只认第一次。
"""
@@ -864,7 +906,7 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
before = _value_of(key, live) if live is not None else None
after = value
if key[0] == "c" and key[2] == "codes" and isinstance(before, dict):
after = {**before, **value}
after = _merge_codes(before, value)
if before is not None and before == after:
state = "same" if live.get("status") == "confirmed" else "confirm"
else:
@@ -1094,6 +1136,15 @@ def infer_name_relations(schema_cache: Mapping[str, Any] | None, table: str, *,
candidates: set[str] = set()
for form in _singulars(words[-1]):
candidates |= index.get((prefix, form), set())
if not candidates:
# 直接对不上:库里的表常带业务前缀(store_tags),列名却只写词根(tagId)。
# 只认和引用它的表共用前缀的那张,从最长的共用前缀试起;仍然不止一张就不推
own = _words(table)
for k in range(len(own) - 1, 0, -1):
for form in _singulars(words[-1]):
candidates |= index.get(("".join(own[:k]) + prefix, form), set())
if candidates:
break
if len(candidates) != 1:
continue
target = next(iter(candidates))
@@ -1625,6 +1676,17 @@ def _date_text(value: Mapping[str, Any]) -> str:
return ";".join(parts)
def _codes_text(codes: Mapping[str, Any]) -> str:
"""码值对照的文字:有含义的写「1=有效」,含义待填写的单独说明——不能写成「1=」,模型会当成含义就是空。"""
filled = [f"{k}={v}" for k, v in codes.items() if str(v).strip()]
pending = [str(k) for k, v in codes.items() if not str(v).strip()]
if not pending:
return "、".join(filled)
if not filled:
return f"{'、'.join(pending)}(含义待填写)"
return f"{'、'.join(filled)},{'、'.join(pending)} 的含义待填写"
def _relation_text(rel: Mapping[str, Any]) -> str:
left = ", ".join(rel.get("columns") or [])
right = ", ".join(f"{rel.get('to_table')}.{c}" for c in rel.get("to_columns") or [])
@@ -1675,7 +1737,7 @@ def _notes_lines(notes: Mapping[str, Any] | None, *, meta: Mapping[str, Any] | N
if name == "measure":
text = MEASURE_LABEL.get(value, str(value))
elif name == "codes":
text = "、".join(f"{k}={v}" for k, v in value.items())
text = _codes_text(value)
else:
text = str(value)
# 第一段是中文名(没有中文名时是含义),不加前缀;其余写明是什么
@@ -1932,13 +1994,13 @@ __all__ = [
"CATALOG_DRAFT_SYSTEM", "COLUMN_FIELDS", "COLUMN_FIELD_LABEL", "COLUMN_MEASURES", "CatalogConflict",
"CatalogEntry", "CatalogInvalid", "CatalogModelUnavailable", "CatalogPathError", "DRAFT_DEFAULT_TABLES",
"DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL", "MEASURE_LABEL",
"MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND",
"REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL", "TABLE_KINDS", "TableDraft", "TableDraftResult",
"UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit", "apply_patch", "describe_slot", "draft_catalog",
"draft_structure", "draft_with_model", "fk_relations", "frozen_catalog", "infer_name_relations",
"MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PROFILE_VERIFY_COVERAGE", "PatchChange", "PatchPlan",
"QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL", "TABLE_KINDS", "TableDraft",
"TableDraftResult", "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit", "apply_patch", "describe_slot",
"draft_catalog", "draft_structure", "draft_with_model", "fk_relations", "frozen_catalog", "infer_name_relations",
"initial_status", "join_paths", "make_item", "merge_notes", "now_iso", "parse_path", "plan_patch",
"query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id", "render_table_index",
"render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry", "review_item", "same_notes",
"save_human_edit", "save_patch", "status_counts", "system_notes_source", "table_usage", "validate_notes",
"visible_notes", "write_entry",
"profile_relation_holds", "query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id",
"render_table_index", "render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry",
"review_item", "same_notes", "save_human_edit", "save_patch", "status_counts", "system_notes_source",
"table_usage", "unreviewed_status", "validate_notes", "visible_notes", "write_entry",
]
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -58,11 +58,16 @@ MAX_QUERY_TIMEOUT_S = 600
#: 完整快照仍能按工件 id 取到,SQL 里给列起个别名也能绕开
MASK_COLUMNS_OPTION = "mask_columns"
#: 数据源 options 里的数据剖析设置(一个对象:开关、查询次数上限、单条时限……)。取值和校验见
#: data/catalog_profile.py;放在这里只为让拼连接串时摘掉它
CATALOG_PROFILE_OPTION = "catalog_profile"
# options 里这些 key 是 AgentLab 自己的配置,不是驱动参数,拼 URL 时要摘掉。
# schema:探查哪个 schema(企业库里只读账号名下常常什么都没有,数据在别处)
# query_timeout_s:查询时限,由数据层按语句下发给数据库,见 _server_deadline
# mask_columns:证据面板遮罩的列,驱动不认识它,拼进连接串会被当成未知参数拒掉
_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION, MASK_COLUMNS_OPTION})
# catalog_profile:数据剖析设置,同上;它还是个对象,拼进去连 URL 都不成形
_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION, MASK_COLUMNS_OPTION, CATALOG_PROFILE_OPTION})
_MASK_SPLIT = re.compile(r"[,,、;;\n]+")
+42 -2
View File
@@ -169,13 +169,17 @@ class SqlChecker:
和助手挑表看到的是同一张图(catalog.relation_graph):目录里的关系,加上没起草过的表按外键、命名现推的;
被驳回的关系不进图,也不会被现推回来。
"""
return [e for e in self.edges_from(a) if e.to_table.lower() == b.lower()]
def edges_from(self, a: str) -> list[catalog.JoinEdge]:
"""从 a 出发的全部边。关系图第一次用到时才建,之后复用。"""
if self._edges is None:
graph = catalog.relation_graph(self._raw, schema_cache=self.schema_cache)
index: dict[str, list[catalog.JoinEdge]] = {}
for table, edges in graph.items():
index.setdefault(table.lower(), []).extend(edges)
self._edges = index
return [e for e in self._edges.get(a.lower(), []) if e.to_table.lower() == b.lower()]
return list(self._edges.get(a.lower(), []))
# ---- 检查
@@ -557,10 +561,46 @@ class _ScopeCheck:
continue # 两张表都没写目录:没有可对照的,不判
condition = " AND ".join(filter(None, (self.excerpt(c) for c in group["conds"])))
if best is None:
self._join_checks.append(self._join_unknown(a, b, candidates, condition))
parent = self._shared_parent(a, b, pairs)
self._join_checks.append(self._join_sibling(a, b, parent, condition) if parent
else self._join_unknown(a, b, candidates, condition))
elif best.status not in _STRONG:
self._join_checks.append(self._join_proposed(a, b, best, condition))
def _shared_parent(self, a: _Table, b: _Table, pairs: set[tuple[str, str]]) -> str | None:
"""兄弟关联:每一对连接列都是两边各自指向同一张父表的同一组列(a.member_id → members.id ← b.member_id)。
返回父表名;不是这种连法返回 None。
目录里不会记 a 和 b 之间的关系,但这不是「连错了列」:比如评价表和标签关联表各自按门店编号指向门店、
彼此按门店编号相连。照「对不上任何关系」报警告是误报。
"""
def parents(table: _Table, column: str) -> set[tuple[str, tuple[str, ...]]]:
return {(e.to_table.lower(), tuple(c.lower() for c in e.to_columns))
for e in self.checker.edges_from(table.name)
if tuple(c.lower() for c in e.from_columns) == (column,)
and e.cardinality in (None, "many_to_one", "one_to_one")}
shared: set[tuple[str, tuple[str, ...]]] | None = None
for left, right in pairs:
common = parents(a, left) & parents(b, right)
shared = common if shared is None else shared & common
if not shared:
return None
names = {table for table, _ in shared or ()}
return next(iter(names)) if len(names) == 1 else None
def _join_sibling(self, a: _Table, b: _Table, parent: str, condition: str) -> SqlCheck:
item = (self.checker.notes_of(parent) or {}).get("label")
label = str(item["value"]) if isinstance(item, dict) and isinstance(item.get("value"), str) \
and item["value"].strip() else parent
return SqlCheck(
code="join_unconfirmed", level="info", table=b.name, sql_excerpt=condition or None,
message=(f"「{a.label()}」与「{b.label()}」之间没有直接的关系,这里经共同的「{label}」相连。"
"关联后行数可能成倍增加:确认其中一侧每个键只有一行,或先各自汇总再关联"),
for_model=(f"{a.name} 和 {b.name} 按 {condition or '这组条件'} 关联:两边都指向 {parent},彼此是一对多对多。"
"如果其中一侧每个键不止一行,关联后另一侧的行会被重复,求和、计数会放大;"
"先把一侧筛到每个键一行(例如只取主记录),或分别汇总到这个键再关联"))
def _join_unknown(self, a: _Table, b: _Table, candidates: list[catalog.JoinEdge], condition: str) -> SqlCheck:
known = ";".join(dict.fromkeys(e.condition() for e in candidates))
hint = f"目录里这两张表的关系是:{known}。照它改关联条件" if known else \
+5 -3
View File
@@ -9,8 +9,9 @@
从 visits 到 channels 要经过它,正好是两跳的连接路径。group_visits 同理(旅行团与入园记录)。
- **命名推断必须放过的列**(NOT_INFERRED):自引用(employees.employee_id 是工号,不是指向自己)、
多个候选(guide 和 guides 两张表都在,tour_assignments.guide_id 不知道指哪张)、没有对应的表
(parking_records.lot_id、operation_logs.operator_id、categories.parentId)、类型对不上
(feedback_forms.store_id 是文字,stores.id 是整数)。
(operation_logs.operator_id、categories.parentId)、类型对不上(feedback_forms.store_id 是文字,stores.id 是整数)。
parking_records.lot_id 没有直接对上的 lot / lots,但和引用它的表共用 parking 前缀的 parking_lots 只有一张,
推得出来(在 NAME_RELATIONS 里)。
- **唯一约束 / 唯一索引。** orders.order_no、channels.channel_code 是列上的 UNIQUE;members.member_no、
visits.ticket_no 是单独建的唯一索引。
@@ -188,6 +189,8 @@ NAME_RELATIONS = frozenset({
("tour_groups", ("agency_id",), "agencies", ("id",)),
("tour_assignments", ("tour_group_id",), "tour_groups", ("id",)),
("lost_and_found", ("park_id",), "parks", ("id",)),
# 没有 lot / lots,按引用表共用的 parking 前缀对上 parking_lots
("parking_records", ("lot_id",), "parking_lots", ("id",)),
# 视图没有约束,按列名同样推得出来:按日汇总的入园人数照样能按 park_id 连到景区
("v_daily_visits", ("park_id",), "parks", ("id",)),
})
@@ -196,7 +199,6 @@ NAME_RELATIONS = frozenset({
NOT_INFERRED = {
("employees", "employee_id"): "自引用:工号,不是指向本表",
("tour_assignments", "guide_id"): "多个候选:guide 与 guides",
("parking_records", "lot_id"): "没有名为 lot / lots 的表",
("operation_logs", "operator_id"): "没有名为 operator / operators 的表",
("categories", "parentId"): "没有名为 parent / parents 的表",
("feedback_forms", "store_id"): "类型对不上:文字列指向整数主键",
+26
View File
@@ -0,0 +1,26 @@
"""测试里建的数据源,用完就删。
数据源表在每个测试进程的测试库里是共用的:留下来的源会出现在助手的数据源清单里(50 张表的合成库还会
触发挑表、多一次模型调用),别的用例的结果就跟着执行顺序变。阶段 1B 就是这样被绊过一次。
删数据源时它的数据目录随外键级联删除(测试库开着 PRAGMA foreign_keys=ON)。
"""
from __future__ import annotations
from sqlalchemy import delete, select
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
async def drop_source(source_id: str | None = None, *, name: str | None = None) -> None:
"""按 id 或名字删一个数据源,并作废它的连接池。不存在就什么都不做。"""
async with SessionLocal() as session:
if source_id is None:
source_id = (await session.execute(
select(DataSource.id).where(DataSource.name == name))).scalar_one_or_none()
if source_id is None:
return
await session.execute(delete(DataSource).where(DataSource.id == source_id))
await session.commit()
await engines.invalidate(source_id)
+8 -5
View File
@@ -11,12 +11,12 @@ import pytest
from httpx import ASGITransport, AsyncClient
from app.data import catalog
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.main import app as _fastapi_app
from tests.fixtures.catalog import scenic
from tests.fixtures.catalog.fake_model import FakeModel
from tests.fixtures.sources import drop_source
ACTOR = {"X-Actor": quote("王敏")}
@@ -38,7 +38,7 @@ async def source_id(client, scenic_db):
r = await client.post(f"/api/datasources/{sid}/introspect")
assert r.status_code == 200, r.text
yield sid
await engines.invalidate(sid)
await drop_source(sid)
def _base(sid: str) -> str:
@@ -184,9 +184,12 @@ async def test_draft_without_schema_is_refused(client):
session.add(row)
await session.commit()
sid = row.id
r = await client.post(f"{_base(sid)}/draft", json={})
assert r.status_code == 409 and "探查结构" in r.json()["detail"]
assert (await client.get(_base(sid))).json()["tables"] == []
try:
r = await client.post(f"{_base(sid)}/draft", json={})
assert r.status_code == 409 and "探查结构" in r.json()["detail"]
assert (await client.get(_base(sid))).json()["tables"] == []
finally:
await drop_source(sid)
async def test_schema_endpoint_detail_carries_catalog(client, source_id):
+37
View File
@@ -15,6 +15,7 @@ from app.data.catalog import make_item
from app.db.base import SessionLocal
from app.db.models import DataSource
from tests.fixtures.catalog.fake_model import FakeModel
from tests.fixtures.sources import drop_source
def _col(name: str, type_: str = "INTEGER", comment: str | None = None, nullable: bool = True) -> dict:
@@ -156,6 +157,31 @@ def test_name_inference_negative(tables, why):
assert _infer(tables, next(iter(tables))) == set(), why
def test_name_inference_falls_back_to_a_table_sharing_the_prefix():
"""库里没有 tags,只有带前缀的 store_tags 和 menu_tag:和引用它的表共用 store 前缀的那张才是它要指的。
以前这种命名推不出关系,SQL 检查跟着误报「对不上任何关系」。"""
tables = {
"store_tag_links": _table(_col("id"), _col("tagId"), _col("storeID")),
"store_tags": _table(_col("id")),
"menu_tag": _table(_col("id")),
"store": _table(_col("id")),
}
assert _infer(tables, "store_tag_links") == {
(("tagId",), "store_tags", ("id",)), (("storeID",), "store", ("id",))}
@pytest.mark.parametrize("tables, why", [
# 直接对得上就用直接的,不去找带前缀的
({"shop_category_links": _table(_col("id"), _col("category_id")), "categories": _table(_col("id")),
"shop_categories": _table(_col("id"))}, {(("category_id",), "categories", ("id",))}),
# 引用它的表没有可共用的前缀:带前缀的表不止一张时照旧不推
({"links": _table(_col("id"), _col("tag_id")), "store_tags": _table(_col("id")),
"menu_tags": _table(_col("id"))}, set()),
])
def test_prefix_fallback_only_when_direct_match_is_missing(tables, why):
assert _infer(tables, next(iter(tables))) == why
def test_name_inference_one_to_one_when_column_is_own_key():
tables = {"member_profiles": _table(_col("member_id"), pk=("member_id",)), "members": _table(_col("id"))}
rel = catalog.infer_name_relations(_source(tables).schema_cache, "member_profiles")[0]
@@ -255,6 +281,16 @@ async def test_model_draft_respects_system_notes_of_imported_tables():
# ---------------------------------------------------------------- 起草并写入
_CREATED: list[str] = []
@pytest.fixture(autouse=True)
async def _drop_created_sources():
yield
while _CREATED:
await drop_source(_CREATED.pop())
async def _db_source(tables: dict, **kw) -> DataSource:
for name, meta in tables.items():
meta["qualified"] = name
@@ -263,6 +299,7 @@ async def _db_source(tables: dict, **kw) -> DataSource:
schema_cache={"tables": tables}, **kw)
session.add(row)
await session.commit()
_CREATED.append(row.id)
return row
+3 -3
View File
@@ -19,6 +19,7 @@ from app.data.engine import engines
from app.data.introspect import introspect
# 模型要在建表之前注册进 metadata:conftest 的建表只导入了 app.db.base,这个文件单独跑时没有别的模块先导入它们
from app.db import models # noqa: F401
from tests.fixtures.sources import drop_source
_CACHE: dict[str, dict] = {}
@@ -106,10 +107,9 @@ async def world(scenic_db):
for wid in created:
if (wf := await session.get(Workflow, wid)) is not None:
await session.delete(wf)
for sid in (ids.source, ids.other):
if (row := await session.get(DataSource, sid)) is not None:
await session.delete(row)
await session.commit()
for sid in (ids.source, ids.other):
await drop_source(sid)
async def _impact(source_id: str, table: str) -> dict:
+28 -5
View File
@@ -25,6 +25,7 @@ from app.data import catalog
from app.data.engine import engines
from app.data.introspect import introspect
from tests.fixtures.catalog import scenic_notes
from tests.fixtures.sources import drop_source
_CACHE: dict[str, dict] = {}
@@ -76,6 +77,32 @@ async def test_codes_are_supplemented_not_replaced(scenic_db):
assert change.value == {"9": "作废"}
async def test_codes_with_pending_meanings(scenic_db):
"""数据剖析写进来的码值候选含义为空(界面上写「含义待填写」):提案可以补上含义;提案里含义为空的码只在原来
没有时加进去,不抹掉已有的含义;含义为空的码合法,保存不报错。"""
tables = (await _schema(scenic_db))["tables"]
profiled = _visits()
profiled["columns"]["status"]["codes"] = catalog.make_item({"1": "有效", "0": "", "9": ""}, "profile", "proposed")
plan = catalog.plan_patch(profiled, [{"path": "columns.status.codes", "value": {"9": "作废", "1": ""}}],
table="visits", tables=tables)
assert plan.problems == []
[change] = plan.changes
assert change.after == {"1": "有效", "0": "", "9": "作废"} and change.state == "change"
# 只给了含义为空的新码:照样加进去,含义待人填
plan = catalog.plan_patch(profiled, [{"path": "columns.status.codes", "value": {"7": ""}}], table="visits",
tables=tables)
assert plan.changes[0].after == {"1": "有效", "0": "", "9": "", "7": ""}
# 只给了含义为空的已有码:什么都没变,这一项是「保存即确认」(原来是推断)
plan = catalog.plan_patch(profiled, [{"path": "columns.status.codes", "value": {"1": ""}}], table="visits",
tables=tables)
assert plan.changes[0].state == "confirm"
out = catalog.apply_patch(profiled, [{"path": "columns.status.codes", "value": {"9": "作废"}}], table="visits",
tables=tables)
codes = out["columns"]["status"]["codes"]
assert codes["value"] == {"1": "有效", "0": "", "9": "作废"} and (codes["source"], codes["status"]) == (
"human", "confirmed")
async def test_new_relation_gets_its_id_from_both_ends(scenic_db):
tables = (await _schema(scenic_db))["tables"]
value = {"columns": ["visit_id"], "to_table": "visits", "to_columns": ["id"], "cardinality": "many_to_one"}
@@ -190,11 +217,7 @@ async def scenic_source(scenic_db):
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
source_id = row.id
yield SimpleNamespace(id=source_id, name=name)
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
if row is not None:
await session.delete(row)
await session.commit()
await drop_source(source_id)
async def _stream(monkeypatch, model, source, base_graph=None):
+430
View File
@@ -0,0 +1,430 @@
"""数据剖析接口:POST /api/datasources/{id}/catalog/profile(阶段 2A)。
剖析是数据目录里唯一对业务库发查询的地方,这里逐条钉住方案定的约束:默认关闭、只读、每条查询都过守卫、
查询次数和单条时限、总时长、遮罩的列不取样、大表不做整表统计、同一个数据源同时只有一次剖析;
以及剖析的结论怎么进目录:命名推断对上数据的升为有确证(来源记为数据剖析,和外键分开)、对不上的写明
覆盖率、人工确认过的只补覆盖率和基数、状态列得到码值候选、只有一个日期列的表提议业务日期。
所有测试都在合成的景区库上做(tests/fixtures/catalog/scenic.py),数据和名称全部虚构。
"""
from __future__ import annotations
import asyncio
import sqlite3
import uuid
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import event
from sqlalchemy.engine import Engine
from app.data import catalog, catalog_profile
from app.data import engine as data_engine
from app.data.catalog_profile import PROFILE_OPTION, ProfileSettings
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.main import app as _fastapi_app
from tests.fixtures.catalog import scenic
from tests.fixtures.sources import drop_source
ACTOR = {"X-Actor": quote("周宁")}
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=_fastapi_app), base_url="http://test") as c:
yield c
def _on(**settings) -> dict:
return {PROFILE_OPTION: {"enabled": True, **settings}}
@pytest.fixture
async def make_source(client):
"""登记一个接上 path 的数据源并走真实的探查接口;draft 给了就先起草这些表的目录。"""
made: list[str] = []
async def _make(path: str, *, options: dict | None = None, draft: list[str] | None = None) -> str:
async with SessionLocal() as session:
row = DataSource(name=f"prof_{uuid.uuid4().hex[:10]}", kind="sqlite", database=str(path),
options=options or {})
session.add(row)
await session.commit()
sid = row.id
made.append(sid)
r = await client.post(f"/api/datasources/{sid}/introspect")
assert r.status_code == 200, r.text
if draft is not None:
r = await client.post(f"/api/datasources/{sid}/catalog/draft", json={"tables": draft})
assert r.status_code == 200, r.text
return sid
yield _make
for sid in made:
await drop_source(sid)
class _Spy:
"""记下两样东西:守卫放行(guard.check 返回)的语句,和真正发到这个业务库的语句。
后者挂在 SQLAlchemy 的 before_cursor_execute 上,任何一条到达驱动的语句都逃不掉——包括绕开查询层
另开连接发的。断言「发到库的 ⊆ 守卫放行的」就是「所有查询都经过守卫」。
"""
def __init__(self, db_name: str) -> None:
self.db_name = db_name
self.checked: list[str] = []
self.executed: list[str] = []
def clear(self) -> None:
self.checked.clear()
self.executed.clear()
def all_guarded(self) -> bool:
return bool(self.executed) and all(s in self.checked for s in self.executed)
def mentions(self, word: str) -> list[str]:
return [s for s in self.executed if word.lower() in s.lower()]
@pytest.fixture
def spy(monkeypatch, scenic_db):
holder = SimpleNamespace(spy=None)
def watch(path: str) -> _Spy:
holder.spy = _Spy(Path(path).name)
return holder.spy
real_check = data_engine.check
def checking(sql, **kw):
out = real_check(sql, **kw)
if holder.spy is not None:
holder.spy.checked.append(out)
return out
def on_execute(conn, cursor, statement, parameters, context, executemany):
if holder.spy is not None and holder.spy.db_name in str(conn.engine.url):
holder.spy.executed.append(statement)
monkeypatch.setattr(data_engine, "check", checking)
event.listen(Engine, "before_cursor_execute", on_execute)
yield watch
event.remove(Engine, "before_cursor_execute", on_execute)
def _url(sid: str) -> str:
return f"/api/datasources/{sid}/catalog/profile"
async def _notes(client, sid: str, table: str) -> dict:
r = await client.get(f"/api/datasources/{sid}/catalog/{table}")
assert r.status_code == 200, r.text
return r.json()
def _relation(notes: dict, column: str) -> dict:
return next(r for r in notes.get("relations") or [] if r["columns"] == [column])
def _table(body: dict, name: str) -> dict:
return next(t for t in body["tables"] if t["table_name"] == name)
def _reasons(table: dict) -> set[str]:
return {s["reason"] for s in table["skipped"]}
# ---------------------------------------------------------------- 开关与设置
async def test_profile_is_off_by_default_and_says_where_to_turn_it_on(client, make_source, scenic_db, spy):
sid = await make_source(scenic_db, draft=["visits"])
watch = spy(scenic_db)
watch.clear()
r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR)
assert r.status_code == 409
detail = r.json()["detail"]
assert "数据源设置" in detail and "开启数据剖析" in detail
assert watch.executed == [] # 一条查询都没发
async def test_settings_are_saved_and_validated_through_datasource_update(client, make_source, scenic_db):
sid = await make_source(scenic_db)
r = await client.patch(f"/api/datasources/{sid}", json={"options": _on(max_queries=0)})
assert r.status_code == 422 and "查询次数上限" in r.json()["detail"]
r = await client.patch(f"/api/datasources/{sid}", json={"options": _on(max_queries=40, sample_size=500)})
assert r.status_code == 200, r.text
assert r.json()["options"][PROFILE_OPTION] == {"enabled": True, "max_queries": 40, "sample_size": 500}
# 设好以后就能剖析;返回里带着这次实际用的设置
r = await client.post(_url(sid), json={"tables": ["channels"]}, headers=ACTOR)
assert r.status_code == 200, r.text
assert r.json()["settings"]["max_queries"] == 40 and r.json()["settings"]["sample_size"] == 500
# ---------------------------------------------------------------- 剖析的结论
async def test_profile_verifies_name_relations_and_proposes_codes_and_business_date(client, make_source, scenic_db,
spy):
sid = await make_source(scenic_db, options=_on(), draft=["visits", "channels"])
watch = spy(scenic_db)
watch.clear()
r = await client.post(_url(sid), json={"tables": ["visits", "channels"]}, headers=ACTOR)
assert r.status_code == 200, r.text
body = r.json()
# 所有发到业务库的语句都经过了守卫
assert watch.all_guarded(), watch.executed
assert body["queries_used"] == len(watch.executed) <= body["settings"]["max_queries"]
assert body["queries_used"] == sum(t["queries"] for t in body["tables"])
assert body["stopped"] is None and body["actor"] == "周宁" and body["profiled_at"]
notes = (await _notes(client, sid, "visits"))
assert notes["updated_by"] == "周宁"
notes = notes["notes"]
# 命名推断出来的两条关系:数据对得上、父表主键唯一 → 有确证,来源记为数据剖析
for column, parent in (("gate_id", "gates"), ("member_id", "members")):
rel = _relation(notes, column)
assert rel["to_table"] == parent
assert (rel["source"], rel["status"]) == ("profile", "verified")
assert rel["coverage"] == 1.0 and rel["cardinality"] == "many_to_one"
assert "数据剖析" in rel["note"] and "抽样" in rel["note"]
# 外键约束来的关系不动:外键是外键,剖析是剖析
park = _relation(notes, "park_id")
assert (park["source"], park["status"], park["coverage"]) == ("fk", "verified", None)
# 状态列:码值候选,值有了、含义留空,推断
codes = notes["columns"]["status"]["codes"]
assert codes["value"] == {"1": "", "0": ""}
assert (codes["source"], codes["status"]) == ("profile", "proposed") and "1500" in codes["note"]
# 只有一个日期类列:提议为业务日期,备注里写明取值范围
bdate = notes["business_date"]
assert bdate["value"] == {"column": "visit_time"} and (bdate["source"], bdate["status"]) == ("profile", "proposed")
assert "2026-07" in bdate["note"] and "2026-09" in bdate["note"]
channels = (await _notes(client, sid, "channels"))["notes"]
assert set(channels["columns"]["channel_type"]["codes"]["value"]) == {"线上直销", "线下", "分销"}
assert "channel_code" not in channels.get("columns", {}) # 唯一约束列不是码值
visits = _table(body, "visits")
kinds = sorted(f["kind"] for f in visits["findings"])
assert kinds == ["business_date", "codes", "relation", "relation"]
gate = next(f for f in visits["findings"] if f["kind"] == "relation" and f["columns"] == ["gate_id"])
assert gate["status"] == "verified" and gate["path"] == f"relations.{_relation(notes, 'gate_id')['id']}"
assert gate["sample"] == 8 and gate["matched"] == 8 and gate["summary"]
assert visits["row_estimate"] == {"rows": 1500, "method": "count", "at_least": None}
assert visits["added"] == 2 and visits["updated"] == 2 and visits["error"] is None
async def test_wrong_name_relation_stays_proposed_with_coverage_in_note(client, make_source, tmp_path):
"""推错的命名关系:parking_records.lot_id 按名字对上了新建的 lots 表,但停车记录里的区号大多不在里面。"""
path = scenic.build(tmp_path / "lots.db")
db = sqlite3.connect(path)
try:
db.execute("CREATE TABLE lots (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
db.executemany("INSERT INTO lots VALUES (?, ?)", [(i, f"{i} 号停车区") for i in (1, 2, 3)])
db.executemany("INSERT INTO parking_records VALUES (?, ?, ?, ?, ?, ?)",
[(i, 1 + i % 20, None, "2026-08-01 09:00:00", None, 10.0) for i in range(1, 101)])
db.commit()
finally:
db.close()
sid = await make_source(path, options=_on(), draft=["parking_records"])
before = _relation((await _notes(client, sid, "parking_records"))["notes"], "lot_id")
assert (before["to_table"], before["source"], before["status"]) == ("lots", "name", "proposed")
r = await client.post(_url(sid), json={"tables": ["parking_records"]}, headers=ACTOR)
assert r.status_code == 200, r.text
rel = _relation((await _notes(client, sid, "parking_records"))["notes"], "lot_id")
assert rel["status"] == "proposed" and rel["source"] == "profile"
assert rel["coverage"] == 0.15
assert "抽样覆盖率 15%" in rel["note"] and "可能不是这条关系" in rel["note"] and "20 个" in rel["note"]
finding = _table(r.json(), "parking_records")["findings"][0]
assert finding["status"] == "proposed" and finding["coverage"] == 0.15
# 两个日期类列:不提议业务日期,取值范围只在返回里
ranges = {d["column"] for d in _table(r.json(), "parking_records")["date_ranges"]}
assert ranges == {"entered_at"} # exited_at 全是空值
assert "business_date" not in (await _notes(client, sid, "parking_records"))["notes"]
async def test_confirmed_relation_only_gets_coverage_and_cardinality(client, make_source, scenic_db):
sid = await make_source(scenic_db, options=_on(), draft=["visits"])
detail = await _notes(client, sid, "visits")
gate = _relation(detail["notes"], "gate_id")
r = await client.post(f"/api/datasources/{sid}/catalog/visits/review",
json={"path": f"relations.{gate['id']}", "action": "confirm",
"if_version": detail["version"]})
assert r.status_code == 200, r.text
r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR)
assert r.status_code == 200, r.text
rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id")
assert (rel["source"], rel["status"]) == ("name", "confirmed")
assert rel["coverage"] == 1.0 and rel["cardinality"] == "many_to_one"
finding = next(f for f in _table(r.json(), "visits")["findings"] if f.get("columns") == ["gate_id"])
assert finding["status"] == "confirmed" and finding["confirmed"] is True
async def test_rerun_with_same_data_changes_nothing(client, make_source, scenic_db):
"""同一天、数据没变:第二次剖析不改目录、不升版本(冻结进快照的目录不会无故变)。"""
sid = await make_source(scenic_db, options=_on(), draft=["visits"])
first = (await client.post(_url(sid), json={"tables": ["visits"]})).json()
second = (await client.post(_url(sid), json={"tables": ["visits"]})).json()
a, b = _table(first, "visits"), _table(second, "visits")
assert (b["added"], b["updated"], b["removed"]) == (0, 0, 0)
assert b["version"] == a["version"]
# ---------------------------------------------------------------- 安全约束
async def test_masked_columns_are_never_queried(client, make_source, scenic_db, spy):
sid = await make_source(scenic_db, options={**_on(), "mask_columns": "member_id, Channel_Type"},
draft=["visits", "channels"])
watch = spy(scenic_db)
watch.clear()
r = await client.post(_url(sid), json={"tables": ["visits", "channels"]}, headers=ACTOR)
assert r.status_code == 200, r.text
assert watch.all_guarded()
assert watch.mentions("member_id") == [] and watch.mentions("channel_type") == []
visits, channels = _table(r.json(), "visits"), _table(r.json(), "channels")
masked = [s for s in visits["skipped"] + channels["skipped"] if s["reason"] == "masked"]
assert {s["target"] for s in masked} == {"member_id → members.id", "channel_type"}
assert all("遮罩" in s["detail"] for s in masked)
notes = (await _notes(client, sid, "visits"))["notes"]
assert _relation(notes, "member_id")["source"] == "name" # 没动
assert _relation(notes, "gate_id")["status"] == "verified" # 其余照常
assert "channel_type" not in ((await _notes(client, sid, "channels"))["notes"].get("columns") or {})
async def test_query_budget_stops_profiling_and_reports_the_rest(client, make_source, scenic_db, spy):
sid = await make_source(scenic_db, options=_on(max_queries=3), draft=["visits"])
watch = spy(scenic_db)
watch.clear()
r = await client.post(_url(sid), json={"tables": ["visits", "channels"]}, headers=ACTOR)
assert r.status_code == 200, r.text
body = r.json()
assert body["queries_used"] == 3 == len(watch.executed) and body["stopped"] == "budget"
visits, channels = _table(body, "visits"), _table(body, "channels")
assert "budget" in _reasons(visits)
assert channels["queries"] == 0 and _reasons(channels) == {"budget"}
assert all("查询次数" in s["detail"] for s in visits["skipped"] + channels["skipped"] if s["reason"] == "budget")
# 没查完的关系不改:原来的命名推断原样留着
rel = _relation((await _notes(client, sid, "visits"))["notes"], "member_id")
assert (rel["source"], rel["status"]) == ("name", "proposed")
async def test_single_query_timeout_is_recorded_and_profiling_continues(client, make_source, scenic_db, monkeypatch):
"""闸口的抽样查询换成一条停不下来的递归查询:1 秒时限到点由数据库掐断,记为超时,其余照常。"""
sid = await make_source(scenic_db, options=_on(query_timeout_s=1), draft=["visits"])
real = data_engine.run_query
endless = "WITH RECURSIVE r(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM r) SELECT COUNT(*) FROM r"
async def slow_gate(source, sql, *, limits=None):
if "gate_id" in sql and "DISTINCT" in sql and "COUNT" not in sql:
assert limits is not None and limits.timeout_seconds == 1
return await real(source, endless, limits=limits)
return await real(source, sql, limits=limits)
monkeypatch.setattr(data_engine, "run_query", slow_gate)
r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR)
assert r.status_code == 200, r.text
visits = _table(r.json(), "visits")
timeout = [s for s in visits["skipped"] if s["reason"] == "timeout"]
assert [s["target"] for s in timeout] == ["gate_id → gates.id"] and "超时" in timeout[0]["detail"]
assert r.json()["stopped"] is None
notes = (await _notes(client, sid, "visits"))["notes"]
assert _relation(notes, "gate_id")["source"] == "name" # 超时的不改
assert _relation(notes, "member_id")["status"] == "verified" # 其余照常
async def test_total_deadline_stops_profiling(make_source, scenic_db, monkeypatch):
sid = await make_source(scenic_db, draft=["visits"])
real = data_engine.run_query
async def slow(source, sql, *, limits=None):
await asyncio.sleep(0.5)
return await real(source, sql, limits=limits)
monkeypatch.setattr(data_engine, "run_query", slow)
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
settings = ProfileSettings(enabled=True, max_total_s=1.4)
report = await catalog_profile.profile_catalog(session, row, row, tables=["visits"], actor=None,
settings=settings)
assert report.stopped == "deadline"
assert 1 <= report.queries_used <= 3
reasons = {s.reason for t in report.tables for s in t.skipped}
assert "deadline" in reasons
async def test_large_tables_get_no_full_scans(client, make_source, scenic_db, spy):
"""行数超过整表统计上限(这里设成 100 行):不做 COUNT(DISTINCT)、MIN/MAX,抽样和取值分布都带上限。"""
sid = await make_source(scenic_db, options=_on(max_scan_rows=100), draft=["visits"])
watch = spy(scenic_db)
watch.clear()
r = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR)
assert r.status_code == 200, r.text
visits = _table(r.json(), "visits")
assert visits["row_estimate"] == {"rows": None, "method": "count", "at_least": 101}
on_visits = [s for s in watch.executed if "from visits" in s.lower()]
assert on_visits and all("LIMIT" in s for s in on_visits), on_visits
assert not any("MIN(" in s for s in on_visits)
assert "too_large" in _reasons(visits)
assert any("太大" in s["detail"] or "上限" in s["detail"] for s in visits["skipped"] if s["reason"] == "too_large")
# 父表有主键,覆盖率又够:照样有确证;子表一侧没核对唯一,按多对一记
rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id")
assert rel["status"] == "verified" and rel["cardinality"] == "many_to_one"
# ---------------------------------------------------------------- 接口
async def test_concurrent_profile_on_same_source_returns_409(client, make_source, scenic_db, monkeypatch):
sid = await make_source(scenic_db, options=_on(), draft=["visits"])
real = data_engine.run_query
started, release = asyncio.Event(), asyncio.Event()
async def held(source, sql, *, limits=None):
started.set()
await release.wait()
return await real(source, sql, limits=limits)
monkeypatch.setattr(data_engine, "run_query", held)
first = asyncio.create_task(client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR))
await asyncio.wait_for(started.wait(), 10)
second = await client.post(_url(sid), json={"tables": ["visits"]}, headers=ACTOR)
assert second.status_code == 409 and "正在进行数据剖析" in second.json()["detail"]
release.set()
done = await asyncio.wait_for(first, 30)
assert done.status_code == 200, done.text
# 做完以后可以再来
again = await client.post(_url(sid), json={"tables": ["channels"]}, headers=ACTOR)
assert again.status_code == 200, again.text
async def test_default_tables_are_used_tables_with_relation_candidates(client, make_source, scenic_db):
sid = await make_source(scenic_db, options=_on(), draft=[])
r = await client.post(_url(sid), json={}, headers=ACTOR)
assert r.status_code == 200, r.text
names = [t["table_name"] for t in r.json()["tables"]]
assert names and len(names) <= catalog_profile.PROFILE_DEFAULT_TABLES
async with SessionLocal() as session:
entries = await catalog.read_catalog(session, sid)
for name in names:
rels = entries[name].notes.get("relations") or []
assert any(rel["source"] not in ("fk",) for rel in rels), name
assert "parks" not in names and "channels" not in names # 没有待核实关系的表不选
async def test_unknown_table_and_missing_source(client, make_source, scenic_db):
sid = await make_source(scenic_db, options=_on(), draft=["visits"])
r = await client.post(_url(sid), json={"tables": ["nope"]}, headers=ACTOR)
assert r.status_code == 200
assert _table(r.json(), "nope")["error"] and r.json()["queries_used"] == 0
r = await client.post(_url("0" * 32), json={}, headers=ACTOR)
assert r.status_code == 404
+205
View File
@@ -0,0 +1,205 @@
"""数据剖析(阶段 2A)里不连库的部分:设置的解析与校验、SQL 字面量、各方言的剖析 SQL 都过得了守卫、
目录对剖析结果的容纳(待填含义的码值、撤销审阅回到剖析的结论)。"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.data import catalog, catalog_profile, guard
from app.data.catalog import make_item
from app.data.catalog_profile import PROFILE_OPTION, ProfileSettings, profile_settings, profile_settings_problem
from app.data.engine import build_url
AT = "2026-10-03T08:00:00+00:00"
# ---------------------------------------------------------------- 设置
def test_settings_default_off_and_parse_leniently():
s = profile_settings({})
assert s == ProfileSettings()
assert s.enabled is False and s.max_queries == 60 and s.query_timeout_s == 10 and s.sample_size == 2000
assert s.max_scan_rows == 100_000 and s.max_total_s == 120
on = profile_settings({PROFILE_OPTION: {"enabled": True, "max_queries": "30", "sample_size": 500}})
assert on.enabled is True and on.max_queries == 30 and on.sample_size == 500
# 库里存着的值不对(老数据、手改过):那一项用缺省,不因此开启,也不报错
odd = profile_settings({PROFILE_OPTION: {"enabled": "yes", "max_queries": 0, "query_timeout_s": "x"}})
assert odd.enabled is False and odd.max_queries == 60 and odd.query_timeout_s == 10
assert profile_settings({PROFILE_OPTION: "on"}) == ProfileSettings()
@pytest.mark.parametrize("value, label", [
({"max_queries": 0}, "查询次数上限"),
({"max_queries": 501}, "查询次数上限"),
({"max_queries": 2.5}, "查询次数上限"),
({"query_timeout_s": 61}, "单条查询时限"),
({"sample_size": 5}, "抽样键值数"),
({"max_scan_rows": -1}, "整表统计行数上限"),
({"max_total_s": 5}, "总时长上限"),
({"enabled": "true"}, "开启数据剖析"),
({"max_queries": True}, "查询次数上限"),
])
def test_settings_problem_names_the_field_in_chinese(value, label):
problem = profile_settings_problem({PROFILE_OPTION: value})
assert problem and label in problem and "数据剖析" in problem
def test_settings_problem_rejects_unknown_keys_and_wrong_shape():
assert "无法识别" in profile_settings_problem({PROFILE_OPTION: {"enabled": True, "rows": 1}})
assert profile_settings_problem({PROFILE_OPTION: ["enabled"]})
assert profile_settings_problem({}) is None
assert profile_settings_problem({PROFILE_OPTION: None}) is None
assert profile_settings_problem({PROFILE_OPTION: {"enabled": True, "max_queries": 500, "query_timeout_s": 1.5,
"sample_size": 10, "max_scan_rows": 0,
"max_total_s": 600}}) is None
def test_profile_option_never_reaches_the_connection_url():
"""剖析设置是 AgentLab 自己的配置:拼进连接串,驱动会把它当未知参数拒掉。"""
source = SimpleNamespace(kind="mysql", host="db.example", port=3306, database="shop", username="ro",
password=None, options={"charset": "utf8mb4", PROFILE_OPTION: {"enabled": True}})
url = build_url(source)
assert "charset=utf8mb4" in url and PROFILE_OPTION not in url and "enabled" not in url
# ---------------------------------------------------------------- 字面量
@pytest.mark.parametrize("value, kind, expected", [
(42, "number", "42"),
(-7, None, "-7"),
(3.0, "number", "3"),
("12345678901234567890", "number", "12345678901234567890"), # DECIMAL 经查询层转成了字符串
("A-01", "text", "'A-01'"),
("O'Neil", "text", "'O''Neil'"),
("12:30", "text", "'12:30'"),
])
def test_sql_literal_quotes_safe_values(value, kind, expected):
assert catalog_profile.sql_literal(value, kind) == expected
@pytest.mark.parametrize("value, kind", [
(True, "boolean"), # 布尔不当键
(2.5, "number"), # 非整数的数不当键:浮点相等比较靠不住
("12.50", "number"),
("1e3", "number"),
("2026-07-01", "date"), # 日期的字面量各家写法不同,不猜
("a\\b", "text"), # 反斜杠:MySQL 不同设置下读法不同,守卫会拒
("a :b", "text"), # 「:名字」会被查询层当成绑定参数
(":b", "text"),
("a\nb", "text"), # 控制字符
("x" * 201, "text"),
(None, "text"),
([1], None),
])
def test_sql_literal_refuses_unsafe_or_unsupported_values(value, kind):
assert catalog_profile.sql_literal(value, kind) is None
# ---------------------------------------------------------------- 各方言的剖析 SQL
_META = {"visits": {"schema": None, "columns": [{"name": "memberTagId", "type": "INTEGER"}], "primary_key": ["id"]},
"orders": {"schema": "sales", "columns": [{"name": "status", "type": "INTEGER"}], "primary_key": ["id"]}}
@pytest.mark.parametrize("kind", ["mysql", "mariadb", "postgresql", "oracle", "sqlite"])
def test_profile_sql_passes_readonly_guard_on_every_dialect(kind):
"""剖析的每一种查询(含读统计信息的系统视图)都得过只读守卫——守卫不为剖析放宽。"""
d = catalog_profile.SqlDialect(kind)
sqls = []
for table in ("visits", "orders"):
stats = d.stats_sql(_META[table], table)
if kind == "sqlite":
assert stats is None # SQLite 没有可读的统计信息,改为数到上限
else:
assert stats
sqls.append(stats)
tbl = d.table(_META[table], table)
col = d.quote(_META[table]["columns"][0]["name"])
sqls += [
d.bounded_count_sql(tbl, 100_001),
d.distinct_sample_sql(tbl, col, 2000, scan_cap=None),
d.distinct_sample_sql(tbl, col, 2000, scan_cap=100_000),
d.match_count_sql(tbl, col, ["1", "'A'"]),
d.unique_check_sql(tbl, col),
d.value_counts_sql(tbl, col, 21, scan_cap=None),
d.value_counts_sql(tbl, col, 21, scan_cap=100_000),
d.min_max_sql(tbl, [col]),
]
for sql in sqls:
assert guard.check(sql, readonly=True, dialect=kind) == sql, sql
def test_dialect_quoting_and_row_limits():
my, ora, pg = (catalog_profile.SqlDialect(k) for k in ("mysql", "oracle", "postgresql"))
assert my.quote("memberTagId") == "`memberTagId`" and pg.quote("memberTagId") == '"memberTagId"'
assert my.table(_META["orders"], "orders") == "sales.orders"
assert pg.table({"schema": "Sales"}, "memberTags") == '"Sales"."memberTags"'
# Oracle 没有 LIMIT(11g 也没有 FETCH FIRST):一律用 ROWNUM
sample = ora.distinct_sample_sql("visits", '"memberTagId"', 50, scan_cap=None)
assert "LIMIT" not in sample and "ROWNUM <= 50" in sample
assert "LIMIT 50" in pg.distinct_sample_sql("visits", "x", 50, scan_cap=None)
# 统计信息按方言读各自的系统视图
assert "information_schema.TABLES" in my.stats_sql(_META["visits"], "visits")
assert "pg_class" in pg.stats_sql(_META["visits"], "visits")
stats = ora.stats_sql(_META["orders"], "orders")
assert "ALL_TABLES" in stats and "'ORDERS'" in stats and "'SALES'" in stats
# ---------------------------------------------------------------- 目录对剖析结果的容纳
def test_codes_with_pending_meanings_are_valid_and_render_as_pending():
"""剖析得到的码值只有取值、含义待人填写:目录要收得下,给模型看时写明含义待填写。"""
notes = {"columns": {"status": {"codes": make_item({"1": "", "0": ""}, "profile", "proposed")},
"channel": {"codes": make_item({"WEB": "官网", "BOX": ""}, "profile", "proposed")}}}
assert catalog.validate_notes(notes) == []
text = catalog.render_table_notes(notes)
assert "1、0(含义待填写)" in text
assert "WEB=官网,BOX 的含义待填写" in text
# 码值仍得是对照表:值不是文字的照样拒收
assert catalog.validate_notes({"columns": {"s": {"codes": make_item({"1": 2}, "profile")}}})
def _profile_rel(coverage, cardinality, status):
return {"id": catalog.relation_id("visits", ["gate_id"], "gates", ["id"]), "columns": ["gate_id"],
"to_table": "gates", "to_columns": ["id"], "cardinality": cardinality, "coverage": coverage,
"source": "profile", "status": status}
def test_reset_returns_profile_relation_to_what_profiling_concluded():
"""撤销审阅回到「来源的初始状态」。剖析的结论随数据而定:覆盖率够、父键唯一(基数有值)就是有确证。"""
verified = {"relations": [_profile_rel(1.0, "many_to_one", "verified")]}
rid = verified["relations"][0]["id"]
out = catalog.review_item(verified, f"relations.{rid}", "confirm", at=AT)
assert out["relations"][0]["status"] == "confirmed"
out = catalog.review_item(out, f"relations.{rid}", "reset", at=AT)
assert out["relations"][0]["status"] == "verified"
out = catalog.review_item(catalog.review_item(out, f"relations.{rid}", "reject", at=AT),
f"relations.{rid}", "reset", at=AT)
assert out["relations"][0]["status"] == "verified"
# 覆盖率低、父键没核实唯一的:撤销后仍是推断
for coverage, cardinality in ((0.62, "many_to_one"), (1.0, None)):
low = {"relations": [_profile_rel(coverage, cardinality, "proposed")]}
out = catalog.review_item(catalog.review_item(low, f"relations.{rid}", "confirm", at=AT),
f"relations.{rid}", "reset", at=AT)
assert out["relations"][0]["status"] == "proposed"
# 剖析提的码值候选、业务日期永远是推断
codes = {"columns": {"status": {"codes": make_item({"1": ""}, "profile", "proposed")}}}
out = catalog.review_item(catalog.review_item(codes, "columns.status.codes", "confirm", at=AT),
"columns.status.codes", "reset", at=AT)
assert out["columns"]["status"]["codes"]["status"] == "proposed"
def test_human_edit_may_restore_profile_conclusion_but_not_invent_one():
rel = _profile_rel(1.0, "many_to_one", "confirmed")
existing = {"relations": [rel]}
back = catalog.apply_human_edit(existing, {"relations": [{**rel, "status": "verified"}]}, table="visits", at=AT)
assert back["relations"][0]["status"] == "verified" and back["relations"][0]["source"] == "profile"
low = _profile_rel(0.4, "many_to_one", "proposed")
kept = catalog.apply_human_edit({"relations": [low]}, {"relations": [{**low, "status": "verified"}]},
table="visits", at=AT)
assert kept["relations"][0]["status"] == "proposed"
+2 -2
View File
@@ -24,6 +24,7 @@ from app.db.models import DataSource, Run, RunEvent, Workflow, WorkflowVersion
from app.engine.runner import run_manager
from app.main import app
from tests.fixtures.catalog import scenic_notes
from tests.fixtures.sources import drop_source
_CACHE: dict[str, dict] = {}
SQL = "SELECT COUNT(*) AS n FROM visits v JOIN parks p ON p.id = v.park_id WHERE v.status = 1"
@@ -72,9 +73,8 @@ async def source(scenic_db):
for wid in made:
if (wf := await session.get(Workflow, wid)) is not None:
await session.delete(wf)
if (row := await session.get(DataSource, sid)) is not None:
await session.delete(row)
await session.commit()
await drop_source(sid)
def node(nid, ntype, **config):
+2
View File
@@ -7,6 +7,7 @@ from app.data import catalog
from app.data.catalog import make_item
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource, Run
from tests.fixtures.sources import drop_source
MARK = "(推断,未确认)"
@@ -184,4 +185,5 @@ async def test_table_usage_counts_run_queries_per_table(monkeypatch):
await session.commit()
source = await session.get(DataSource, sid)
usage = await catalog.table_usage(session, source)
await drop_source(sid)
assert usage == {"visits": 4, "明细": 2, "parks": 1}
+2 -2
View File
@@ -14,12 +14,12 @@ from types import SimpleNamespace
from app.core.artifact_store import canonical_json, content_hash, load
from app.data import catalog
from app.data.catalog import make_item
from app.data.engine import engines
from app.data.introspect import describe_table, introspect
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource
from app.tools.datasource import _store_schema, build_datasource_tools
from app.tools.registry import ToolContext
from tests.fixtures.sources import drop_source
MARK = "(推断,未确认)"
@@ -182,4 +182,4 @@ async def test_tools_show_and_freeze_catalog_and_record_query_tables(scenic_db):
ref = await session.get(Artifact, (result["artifact"], run_id))
assert ref.meta == {"source_id": row.id, "source": row.name, "tables": ["visits", "parks"]}
finally:
await engines.invalidate(row.id)
await drop_source(row.id)
+2 -4
View File
@@ -20,6 +20,7 @@ from app.data.engine import engines
from app.data.introspect import introspect
from app.data.sqlcheck import SqlChecker
from tests.fixtures.catalog import scenic_notes
from tests.fixtures.sources import drop_source
FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
"WHERE o.status = 1")
@@ -177,10 +178,7 @@ async def scenic_source(scenic_db):
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
source_id = row.id
yield name
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
row.enabled = False
await session.commit()
await drop_source(source_id)
async def _stream(monkeypatch, model, source):
+2 -4
View File
@@ -23,6 +23,7 @@ from app.engine.governance import lint_for_publish, publish_issues
from app.engine.schema import GraphSpec
from app.main import app
from tests.fixtures.catalog import scenic_notes
from tests.fixtures.sources import drop_source
FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
"WHERE o.status = 1")
@@ -133,10 +134,7 @@ async def scenic_source(scenic_db):
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
source_id = row.id
yield name
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
row.enabled = False
await session.commit()
await drop_source(source_id)
@pytest.fixture
+13 -2
View File
@@ -32,6 +32,7 @@ from app.main import app
from app.tools.datasource import build_datasource_tools
from app.tools.registry import ToolContext
from tests.fixtures.catalog import scenic_notes
from tests.fixtures.sources import drop_source
FANOUT_SQL = ("SELECT SUM(o.total_amount) AS gmv FROM orders o JOIN order_items i ON i.order_id = o.id "
"WHERE o.status = 1")
@@ -54,6 +55,16 @@ async def _schema(path: str) -> dict:
return _CACHE[path]
_CREATED: list[str] = []
@pytest.fixture(autouse=True)
async def _drop_created_sources():
yield
while _CREATED:
await drop_source(_CREATED.pop())
async def _source(path: str, *, with_catalog: bool = True) -> tuple[str, str]:
name = f"scenic_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
@@ -61,6 +72,7 @@ async def _source(path: str, *, with_catalog: bool = True) -> tuple[str, str]:
row.schema_cache = await _schema(path)
session.add(row)
await session.commit()
_CREATED.append(row.id)
if with_catalog:
for table, notes in scenic_notes.notes().items():
await catalog.write_entry(session, row.id, table, notes, if_version=0, actor="王敏")
@@ -69,9 +81,8 @@ async def _source(path: str, *, with_catalog: bool = True) -> tuple[str, str]:
@pytest.fixture
async def scenic_source(scenic_db):
source_id, name = await _source(scenic_db)
_, name = await _source(scenic_db)
yield name
await engines.invalidate(source_id)
async def _query_tool(name: str):
+10
View File
@@ -254,6 +254,16 @@ async def test_join_unconfirmed_negative(cache, sql):
assert "join_unconfirmed" not in codes(cache, sql)
async def test_sibling_join_through_a_shared_parent_is_only_a_note(cache):
"""两张表各自按 member_id 指向会员(都是外键),彼此按 member_id 相连:目录里没有这两张表之间的关系,
但这是经共同父表推得出来的连法,不是「连错了列」。只提示一句:关联后行数可能成倍增加。"""
sql = "SELECT COUNT(*) FROM orders o JOIN member_tag_links t ON t.member_id = o.member_id WHERE o.status = 1"
[check] = [c for c in run(cache, sql) if c.code == "join_unconfirmed"]
assert check.level == "info"
assert "会员" in check.message or "members" in check.message
assert "成倍" in check.message
async def test_join_level_follows_status(cache):
sql = "SELECT SUM(i.amount) FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.status = 1"
assert "join_unconfirmed" not in codes(cache, sql, "confirmed")
+2 -1
View File
@@ -34,6 +34,7 @@ from app.engine.expressions import TruncatedUse, eval_expression
from app.engine.issuance import decide_tier, incomplete_gaps
from app.engine.runner import run_manager
from app.main import app
from tests.fixtures.sources import drop_source
# --------------------------------------------------------------------------
# 求值器:整组用到截断结果时记一笔,单格不记
@@ -345,7 +346,7 @@ async def park(ticket_db, engine_up):
# 连接池按数据源 id 缓存:换了库文件不作废,查的还是上一个测试的库
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
await drop_source(source_id)
def node(nid, ntype, **config):