"""数据目录核心语义的回归(2026-10 完整性审查 F1)。 - A1:经对话提案确认一条关系,不抹掉数据剖析测得的基数和覆盖率;新增关系没写基数时按目标表的键推算, 推算不了就留空并在提案的这一项上说明。否则扇出检查(fanout_sum)看不到基数,静默失效。 - A2:在目录页去掉推断或外键来的关系即驳回,保留编号;只有人工新建的关系才真正删除。检查器、关系图 (助手挑表也用它)、重新起草三处都不能再出现被驳回的关系。 - A4:码值只补了一部分时不当作完整清单。「码值不在码值表中」只对标了「已列全」的码值报;「已列全」只有人工 明确确认、或者剖析没截断地看完全表取值时才有。 - B2:隔天重新剖析、数据没变,目录不升版本(剖析写的备注里只有日期变了不算变化)。 - B3:计算公式在人工整份提交、模型起草两条路径上也拦下(以前只拦提案)。 - 一致性:日期列的判定、表名的匹配、拆词各只有一份。 数据和名称全部虚构(tests/fixtures/catalog/scenic.py)。 """ from __future__ import annotations import re import sqlite3 import pytest from app.data import catalog, catalog_profile, sqlcheck from app.db.base import SessionLocal from app.db.models import DataSource from tests.fixtures.catalog import scenic, scenic_notes from tests.fixtures.catalog.fake_model import FakeModel from tests.test_catalog_patch import _schema from tests.test_catalog_profile import _notes, _on, _profile, _relation, _table, client, make_source # noqa: F401 AT = "2026-10-04T08:00:00+00:00" DAY1, DAY2, DAY3 = "2026-10-01T08:00:00+00:00", "2026-10-02T08:00:00+00:00", "2026-10-03T08:00:00+00:00" async def _checker(sid: str) -> sqlcheck.SqlChecker | None: async with SessionLocal() as session: row = await session.get(DataSource, sid) return (await sqlcheck.load_checkers(session, [row])).get(row.name) async def _found(sid: str, sql: str) -> list[tuple]: checker = await _checker(sid) return [(c.code, c.level, c.table, c.column) for c in (checker.check(sql) if checker else [])] async def _graph(sid: str) -> dict[str, list[catalog.JoinEdge]]: """助手挑表用的关系图:库里的目录原样(含驳回项)加表结构现推。""" async with SessionLocal() as session: row = await session.get(DataSource, sid) entries = await catalog.read_catalog(session, sid) return catalog.relation_graph({t: e.notes for t, e in entries.items()}, schema_cache=row.schema_cache) async def _put(client, sid: str, table: str, notes: dict, version: int): return await client.put(f"/api/datasources/{sid}/catalog/{table}", json={"notes": notes, "if_version": version}) def _profile_rel(table: str, columns: list[str], to_table: str, to_columns: list[str], *, cardinality: str | None = "many_to_one", coverage: float | None = 1.0, status: str = "verified") -> dict: return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns, "to_table": to_table, "to_columns": to_columns, "cardinality": cardinality, "coverage": coverage, "source": "profile", "status": status, "note": "数据剖析(2026-10-01):子表抽样 8 个不同键值,父表对上 8 个,覆盖率 100%;被指向列是主键。"} # ========================================================================== # A1:提案不抹掉基数和覆盖率 # ========================================================================== async def test_patch_confirming_a_profiled_relation_keeps_cardinality_and_coverage(scenic_db): tables = (await _schema(scenic_db))["tables"] gate = _profile_rel("visits", ["gate_id"], "gates", ["id"]) notes = {"relations": [gate]} value = {"columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"]} for path in ("relations.new", f"relations.{gate['id']}"): [change] = catalog.plan_patch(notes, [{"path": path, "value": value}], table="visits", tables=tables).changes assert change.after["cardinality"] == "many_to_one" and change.after["coverage"] == 1.0 # 两端、基数、覆盖率都和目录里的一样:保存即确认,来源不变 assert change.state == "confirm" out = catalog.apply_patch(notes, [{"path": "relations.new", "value": value}], table="visits", tables=tables, at=AT) [rel] = out["relations"] assert (rel["source"], rel["status"], rel["cardinality"], rel["coverage"]) == ( "profile", "confirmed", "many_to_one", 1.0) assert rel["note"] == gate["note"] async def test_patch_may_change_cardinality_but_never_coverage(scenic_db): tables = (await _schema(scenic_db))["tables"] gate = _profile_rel("visits", ["gate_id"], "gates", ["id"], coverage=0.97) value = {"columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"], "cardinality": "one_to_one", "coverage": 0.2} # 覆盖率是测量值:模型写了也不认 [change] = catalog.plan_patch({"relations": [gate]}, [{"path": "relations.new", "value": value}], table="visits", tables=tables).changes assert change.state == "change" assert (change.after["cardinality"], change.after["coverage"]) == ("one_to_one", 0.97) out = catalog.apply_patch({"relations": [gate]}, [{"path": "relations.new", "value": value}], table="visits", tables=tables, at=AT) [rel] = out["relations"] assert (rel["source"], rel["status"], rel["cardinality"], rel["coverage"]) == ( "human", "confirmed", "one_to_one", 0.97) assert rel["note"] == gate["note"] # 覆盖率留着,说明它的剖析备注也留着 async def test_new_relation_without_cardinality_is_inferred_from_target_keys(scenic_db): tables = (await _schema(scenic_db))["tables"] def plan(table: str, value: dict) -> catalog.PatchChange: [change] = catalog.plan_patch({}, [{"path": "relations.new", "value": value}], table=table, tables=tables).changes return change # 目标是主键、本表这一列不唯一:多对一 gate = plan("visits", {"columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"]}) assert (gate.after["cardinality"], gate.after["coverage"], gate.note) == ("many_to_one", None, None) assert "note" not in gate.as_dict() # 目标是唯一约束、本表这一列也唯一(票号有唯一索引):一对一 ticket = plan("visits", {"columns": ["ticket_no"], "to_table": "orders", "to_columns": ["order_no"]}) assert ticket.after["cardinality"] == "one_to_one" # 目标列不是主键也不是唯一约束:推算不了,基数留空,这一项上说明 park = plan("visits", {"columns": ["park_id"], "to_table": "gates", "to_columns": ["park_id"]}) assert park.after["cardinality"] is None assert park.note and "基数" in park.note and park.as_dict()["note"] == park.note # 不知道表结构(尚未探查):不推算,也说明 [blind] = catalog.plan_patch({}, [{"path": "relations.new", "value": { "columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"]}}], table="visits", tables=None).changes assert blind.after["cardinality"] is None and blind.note async def test_confirming_a_profiled_relation_by_patch_keeps_the_fanout_check(client, make_source, scenic_db): """审查探针 A1:剖析得出多对一、覆盖率 1.0,经对话提案确认这条关系之后,扇出检查照样报 error。""" sql = "SELECT COUNT(g.id) AS n FROM gates g JOIN visits v ON v.gate_id = g.id" sid = await make_source(scenic_db, options=_on(), draft=["visits", "gates"]) await _profile(client, sid, ["visits"]) detail = await _notes(client, sid, "visits") rel = _relation(detail["notes"], "gate_id") assert (rel["source"], rel["status"], rel["cardinality"], rel["coverage"]) == ( "profile", "verified", "many_to_one", 1.0) # 子表一侧数过、确有重复:基数用数据核实过,一对多确有其事 → error(A6) assert rel["cardinality_checked"] is True assert ("fanout_sum", "error", "gates", "id") in await _found(sid, sql) changes = [{"path": "relations.new", "value": {"columns": ["gate_id"], "to_table": "gates", "to_columns": ["id"]}, "reason": "用户说 gate_id 对应 gates.id"}] r = await client.post(f"/api/datasources/{sid}/catalog/visits/patch", json={"changes": changes, "if_version": detail["version"]}) assert r.status_code == 200, r.text after = _relation(r.json()["notes"], "gate_id") assert (after["status"], after["cardinality"], after["coverage"]) == ("confirmed", "many_to_one", 1.0) assert ("fanout_sum", "error", "gates", "id") in await _found(sid, sql) async def test_profiled_relation_without_child_check_only_warns(client, make_source, scenic_db): """A6:子表超过整表统计上限、没核对子表一侧唯不唯一的关系,剖析照样记有确证、按多对一记,但基数不算用数据 核实过:扇出检查只给 warning(「可能重复计算」),不给 error、不让出具降档。""" sql = "SELECT COUNT(g.id) AS n FROM gates g JOIN visits v ON v.gate_id = g.id" sid = await make_source(scenic_db, options=_on(max_scan_rows=100), draft=["visits", "gates"]) await _profile(client, sid, ["visits"]) rel = _relation((await _notes(client, sid, "visits"))["notes"], "gate_id") assert (rel["source"], rel["status"], rel["cardinality"]) == ("profile", "verified", "many_to_one") assert not rel.get("cardinality_checked") found = await _found(sid, sql) assert ("fanout_sum", "warning", "gates", "id") in found assert ("fanout_sum", "error", "gates", "id") not in found # ========================================================================== # A2:去掉推断或外键来的关系即驳回 # ========================================================================== def _rel(columns, to_table, to_columns, source="name", status=None, table="visits"): return {"id": catalog.relation_id(table, columns, to_table, to_columns), "columns": columns, "to_table": to_table, "to_columns": to_columns, "cardinality": "many_to_one", "coverage": None, "source": source, "status": status or catalog.initial_status(source)} def test_human_edit_rejects_removed_inferred_relations_and_deletes_human_ones(): park, gate = _rel(["park_id"], "parks", ["id"], "fk"), _rel(["gate_id"], "gates", ["id"], "name") mine = _rel(["member_id"], "members", ["id"], "human") out = catalog.apply_human_edit({"relations": [park, gate, mine]}, {"relations": []}, table="visits", at=AT) by_id = {r["id"]: r for r in out["relations"]} assert set(by_id) == {park["id"], gate["id"]} # 人工新建的那条真正删掉 for old in (park, gate): assert by_id[old["id"]] == {**old, "status": "rejected", "updated_at": AT} # 编号、来源、两端都留着 # 已经驳回的再去掉一次:不变 again = catalog.apply_human_edit(out, {"relations": []}, table="visits", at="2026-10-05T00:00:00+00:00") assert again == out def test_human_edit_moving_an_inferred_relation_rejects_the_old_one(): gate = _rel(["gate_id"], "gates", ["id"], "name") moved = {**gate, "to_table": "parks", "to_columns": ["id"]} out = catalog.apply_human_edit({"relations": [gate]}, {"relations": [moved]}, table="visits", at=AT) new_id = catalog.relation_id("visits", ["gate_id"], "parks", ["id"]) by_id = {r["id"]: r for r in out["relations"]} assert (by_id[new_id]["source"], by_id[new_id]["status"]) == ("human", "confirmed") assert by_id[gate["id"]]["status"] == "rejected" and by_id[gate["id"]]["source"] == "name" def test_human_edit_rejects_a_removed_human_relation_that_structure_would_bring_back(): """人工改过基数的外键关系记成了人工填写;去掉它时若表结构还推得出来,删掉就会被现推回来,只能驳回。""" park = {**_rel(["park_id"], "parks", ["id"], "fk"), "cardinality": "one_to_one", "source": "human", "status": "confirmed"} out = catalog.apply_human_edit({"relations": [park]}, {"relations": []}, table="visits", at=AT, inferable={park["id"]}) assert [(r["id"], r["status"]) for r in out["relations"]] == [(park["id"], "rejected")] assert catalog.apply_human_edit({"relations": [park]}, {"relations": []}, table="visits", at=AT) == {} def test_human_edit_cannot_forge_a_data_checked_cardinality(): """A6:「基数用数据核实过」只能由数据剖析写。人工改了关系(记成人工确认),提交里带的 cardinality_checked 不作数; 值没动的关系保留剖析原来写的。""" gate = {**_rel(["gate_id"], "gates", ["id"], "profile", "verified"), "coverage": 1.0, "cardinality_checked": True} park = _rel(["park_id"], "parks", ["id"], "name") forged = {**park, "cardinality": "one_to_many", "cardinality_checked": True} out = catalog.apply_human_edit({"relations": [gate, park]}, {"relations": [gate, forged]}, table="visits", at=AT) by_id = {r["id"]: r for r in out["relations"]} assert by_id[gate["id"]]["cardinality_checked"] is True assert (by_id[park["id"]]["source"], by_id[park["id"]]["status"]) == ("human", "confirmed") assert "cardinality_checked" not in by_id[park["id"]] async def test_removed_or_rejected_relations_stay_out_of_checks_graph_and_redraft(client, make_source, scenic_db): sid = await make_source(scenic_db, draft=["visits"]) detail = await _notes(client, sid, "visits") park, gate = _relation(detail["notes"], "park_id"), _relation(detail["notes"], "gate_id") ticket = _relation(detail["notes"], "ticket_type_id") assert (park["source"], gate["source"], ticket["source"]) == ("fk", "name", "fk") gone = {park["id"], gate["id"]} # 目录页编辑时去掉两条(外键来的、命名推断的):转为驳回 notes = {**detail["notes"], "relations": [r for r in detail["notes"]["relations"] if r["id"] not in gone]} r = await _put(client, sid, "visits", notes, detail["version"]) assert r.status_code == 200, r.text saved = {x["id"]: x for x in r.json()["notes"]["relations"]} assert {saved[i]["status"] for i in gone} == {"rejected"} # 单项审阅里驳回第三条(界面上非人工来源的关系只给「驳回」) r = await client.post(f"/api/datasources/{sid}/catalog/visits/review", json={"path": f"relations.{ticket['id']}", "action": "reject", "if_version": r.json()["version"]}) assert r.status_code == 200, r.text gone.add(ticket["id"]) def absent(edges) -> bool: return not any(e.relation_id in gone for e in edges) # 检查器:被驳回的关系不当作「对上了」,也不拿来判断基数 checker = await _checker(sid) for target in ("parks", "gates", "ticket_types"): assert checker.edges_between("visits", target) == [] joined = await _found(sid, "SELECT COUNT(*) FROM visits v JOIN gates g ON v.gate_id = g.id") assert ("join_unconfirmed", "warning", "gates", None) in joined # 关系图(助手挑表也用它):不按表结构现推回来 graph = await _graph(sid) assert absent(graph.get("visits", [])) and absent(graph.get("parks", [])) and absent(graph.get("gates", [])) # 重新起草:仍是驳回 r = await client.post(f"/api/datasources/{sid}/catalog/draft", json={"tables": ["visits"]}) assert r.status_code == 200, r.text again = {x["id"]: x for x in (await _notes(client, sid, "visits"))["notes"]["relations"]} assert {again[i]["status"] for i in gone} == {"rejected"} assert absent((await _graph(sid)).get("visits", [])) def test_relation_graph_keeps_structure_relations_of_partially_annotated_tables(): """只做过部分注释的表(目录里没有关系)照样按结构补上外键和命名推断;这张表目录里驳回的不补。""" cache = {"tables": { "visits": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "park_id", "type": "INTEGER"}, {"name": "gate_id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": [{"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"]}]}, "parks": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []}, "gates": {"columns": [{"name": "id", "type": "INTEGER"}], "primary_key": ["id"], "foreign_keys": []}, }} labelled = {"visits": {"label": catalog.make_item("入园记录", "human")}} edges = {(e.to_table, e.source) for e in catalog.relation_graph(labelled, schema_cache=cache)["visits"]} assert edges == {("parks", "fk"), ("gates", "name")} rejected = {"visits": {"relations": [_rel(["park_id"], "parks", ["id"], "fk", "rejected")]}} edges = {e.to_table for e in catalog.relation_graph(rejected, schema_cache=cache)["visits"]} assert edges == {"gates"} # ========================================================================== # A4:码值「已列全」 # ========================================================================== def _codes(value: dict, *, complete: bool | None, status: str = "confirmed") -> dict: item = catalog.make_item(value, "human" if status == "confirmed" else "profile", status) if complete is not None: item["complete"] = complete return item async def test_unknown_code_only_for_codes_marked_complete(scenic_db): cache = await _schema(scenic_db) sql = "SELECT COUNT(*) FROM visits WHERE status = 3" def found(item: dict) -> list[str]: notes = scenic_notes.notes() notes["visits"]["columns"]["status"]["codes"] = item checker = sqlcheck.SqlChecker(kind="sqlite", schema_cache=cache, notes=notes, source_name="scenic") return [c.code for c in checker.check(sql)] assert "unknown_code" not in found(_codes({"1": "有效", "0": "作废"}, complete=None)) assert "unknown_code" in found(_codes({"1": "有效", "0": "作废"}, complete=True)) async def test_partial_codes_from_a_patch_are_not_treated_as_the_full_list(client, make_source, scenic_db): """审查探针 A4:经提案只写了 status=9 表示作废,之后 status = 1 不该被报「码值不在码值表中」。""" sid = await make_source(scenic_db, draft=["orders"]) detail = await _notes(client, sid, "orders") r = await client.post(f"/api/datasources/{sid}/catalog/orders/patch", json={ "changes": [{"path": "columns.status.codes", "value": {"9": "作废"}}], "if_version": detail["version"]}) assert r.status_code == 200, r.text codes = r.json()["notes"]["columns"]["status"]["codes"] assert codes["value"] == {"9": "作废"} and "complete" not in codes sql = "SELECT COUNT(*) FROM orders WHERE status = 1" assert not any(c[0] == "unknown_code" for c in await _found(sid, sql)) # 人工在目录页把码值补全,并勾上「已列出全部取值」:这时才报 notes = r.json()["notes"] notes["columns"]["status"]["codes"] = {**codes, "value": {"1": "已支付", "2": "已退款", "9": "作废"}, "complete": True} r = await _put(client, sid, "orders", notes, r.json()["version"]) assert r.status_code == 200, r.text saved = r.json()["notes"]["columns"]["status"]["codes"] assert (saved["source"], saved["status"], saved.get("complete")) == ("human", "confirmed", True) assert not any(c[0] == "unknown_code" for c in await _found(sid, sql)) checker = await _checker(sid) [check] = [c for c in checker.check("SELECT COUNT(*) FROM orders WHERE status = 5") if c.code == "unknown_code"] assert check.level == "warning" # 给模型的话:提示核对取值,不叫它「照目录里的码值改」 assert "照目录里的码值改" not in check.for_model and "核对" in check.for_model def test_codes_complete_flag_is_validated_and_counts_as_an_edit(): base = {"columns": {"status": {"codes": _codes({"1": "", "0": ""}, complete=None, status="proposed")}}} assert catalog.validate_notes(base) == [] assert catalog.validate_notes({"columns": {"status": {"codes": _codes({"1": "有效"}, complete=True)}}}) == [] bad = catalog.validate_notes({"columns": {"status": {"codes": {**_codes({"1": "有效"}, complete=None), "complete": "是"}}}}) assert bad and "码值" in bad[0] wrong_place = catalog.validate_notes({"label": {**catalog.make_item("入园记录", "human"), "complete": True}}) assert wrong_place and "complete" in wrong_place[0] # 值没动、只勾上「已列全」:算人工改动,记为人工填写、已确认 ticked = {"columns": {"status": {"codes": {**base["columns"]["status"]["codes"], "complete": True}}}} out = catalog.apply_human_edit(base, ticked, table="visits", at=AT) codes = out["columns"]["status"]["codes"] assert (codes["source"], codes["status"], codes["complete"]) == ("human", "confirmed", True) # 取消勾选:不留 complete: false,和没有这个标记写法一样 unticked = {"columns": {"status": {"codes": {**codes, "complete": False}}}} out = catalog.apply_human_edit(out, unticked, table="visits", at=AT) assert "complete" not in out["columns"]["status"]["codes"] async def test_profile_marks_codes_complete_only_when_the_whole_table_was_read(client, make_source, scenic_db): whole = await make_source(scenic_db, options=_on(), draft=["visits"]) await _profile(client, whole, ["visits"]) codes = (await _notes(client, whole, "visits"))["notes"]["columns"]["status"]["codes"] assert "统计全表" in codes["note"] and codes.get("complete") is True # 不做整表统计(只看前若干行):取值可能没看全,不标 capped = await make_source(scenic_db, options=_on(max_scan_rows=0, sample_size=10), draft=["visits"]) await _profile(client, capped, ["visits"]) codes = (await _notes(client, capped, "visits"))["notes"]["columns"]["status"]["codes"] assert "按存储顺序取前 10 行统计" in codes["note"] and "complete" not in codes # ========================================================================== # B2:隔天重新剖析、数据没变,不升版本 # ========================================================================== async def test_rerun_on_another_day_with_same_data_keeps_the_version(client, make_source, tmp_path, monkeypatch): path = scenic.build(tmp_path / "b2.db") sid = await make_source(path, 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 monkeypatch.setattr(catalog, "now_iso", lambda: DAY1) first = _table(await _profile(client, sid, ["visits"]), "visits") notes_day1 = (await _notes(client, sid, "visits"))["notes"] monkeypatch.setattr(catalog, "now_iso", lambda: DAY2) second = _table(await _profile(client, sid, ["visits"]), "visits") assert (second["added"], second["updated"], second["removed"]) == (0, 0, 0) assert second["version"] == first["version"] assert (await _notes(client, sid, "visits"))["notes"] == notes_day1 # 备注里还是第一天的日期 # 数据变了(多了一种状态),结论跟着变:照常升版本 db = sqlite3.connect(path) try: db.execute("INSERT INTO visits VALUES (1501, 'TK9001501', 1, 1, 1, NULL, '2026-09-30 10:00:00', 1, 2)") db.commit() finally: db.close() monkeypatch.setattr(catalog, "now_iso", lambda: DAY3) third = _table(await _profile(client, sid, ["visits"]), "visits") assert third["version"] == second["version"] + 1 and third["updated"] >= 1 codes = (await _notes(client, sid, "visits"))["notes"]["columns"]["status"]["codes"] assert set(codes["value"]) == {"0", "1", "2"} and codes["updated_at"] == DAY3 # A7:说明里不写日期(以前写的是 UTC 日期,和界面上的本地时间对不上),时间以这一项的 updated_at 为准 assert codes["note"].startswith("数据剖析:") and "2026-10-03" not in codes["note"] #: 升级前剖析写的说法 → 现在的说法(下一轮 C1:大表看的是按存储顺序的前 N 行,不是抽样)。测试里反过来用:把新写法 #: 改回旧写法,模拟升级前写进目录的备注 _WORDING = (("覆盖率 ", "抽样覆盖率 ", ",可能不是这条关系"), ("子表取了 ", "子表抽样 ", None), ("(按存储顺序只看前 ", "(只看前 ", None), ("按存储顺序取前 ", "按前 ", None)) def _legacy(note: str) -> str: out = note for new, old, tail in _WORDING: if tail: out = re.sub(re.escape(new) + r"(\S+?)" + re.escape(tail), lambda m: old + m.group(1) + tail, out) else: out = out.replace(new, old) return out def _profile_notes(notes: dict) -> list[dict]: """目录里剖析写了备注的项(关系、码值、业务日期)。""" found = [r for r in notes.get("relations") or [] if catalog.is_profile_note(r.get("note"))] for column in (notes.get("columns") or {}).values(): found += [item for item in column.values() if isinstance(item, dict) and catalog.is_profile_note(item.get("note"))] bdate = notes.get("business_date") return found + ([bdate] if isinstance(bdate, dict) and catalog.is_profile_note(bdate.get("note")) else []) def test_profile_notes_in_the_old_wording_are_the_same_note(): """改过的四处说法:旧写法和新写法一字之外都一样才算同一句;数变了、或者不是剖析写的备注,照旧算不同。""" old = "数据剖析:抽样覆盖率 15%,可能不是这条关系;子表抽样 20 个不同键值(只看前 100 行),父表对上 3 个;被指向列是主键。" new = "数据剖析:覆盖率 15%,可能不是这条关系;子表取了 20 个不同键值(按存储顺序只看前 100 行),父表对上 3 个;被指向列是主键。" assert catalog.same_profile_note(old, new) and catalog.same_profile_note(new, old) assert catalog.same_profile_note(old.replace("数据剖析:", "数据剖析(2026-10-01):"), new) assert not catalog.same_profile_note(old, new.replace("对上 3 个", "对上 4 个")) codes_old = "数据剖析(2026-10-01):按前 10 行统计,10 行非空值共 2 个取值:1(90%)、0(10%)。" codes_new = "数据剖析:按存储顺序取前 10 行统计,10 行非空值共 2 个取值:1(90%)、0(10%)。" assert catalog.same_profile_note(codes_old, codes_new) assert not catalog.same_profile_note(codes_old, codes_new.replace("前 10 行", "前 20 行")) # 起草写的有效行过滤备注:只认接在后面的剖析核对那句 filter_old = "依据:status 为 1 才是有效记录。剖析核对:按前 100,000 行统计,排除 3% 的行" filter_new = "依据:status 为 1 才是有效记录。剖析核对:按存储顺序取前 100,000 行统计,排除 3% 的行" assert catalog.same_profile_note(filter_old, filter_new) assert not catalog.same_profile_note(filter_old, filter_new.replace("3%", "4%")) # 不是剖析写的备注:「抽样覆盖率」是人写的原话,不换 assert not catalog.same_profile_note("人工备注:抽样覆盖率 15%,可能不是这条关系", "人工备注:覆盖率 15%,可能不是这条关系") assert not catalog.same_profile_note(new, "覆盖率 15%,可能不是这条关系") async def test_rerun_after_upgrade_keeps_the_version_when_only_the_wording_changed(client, make_source, tmp_path): """升级前写进目录的剖析备注用的是旧说法(「抽样覆盖率」「子表抽样」「(只看前 N 行)」「按前 N 行统计」)。数据 没变、升级后第一次重新剖析:结论一字不差、只是措辞改了,不算变化——否则每张有剖析结论的表都升一个版本,发布时 记下的目录版本对不上,之后每次正式运行都提醒「目录有变化」。""" path = scenic.build(tmp_path / "wording.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() tables = ["visits", "parking_records"] whole = await make_source(path, options=_on(), draft=tables) capped = await make_source(path, options=_on(max_scan_rows=0, sample_size=10), draft=tables) seen: set[str] = set() for sid in (whole, capped): await _profile(client, sid, tables) for table in tables: detail = await _notes(client, sid, table) notes = detail["notes"] for item in _profile_notes(notes): old = _legacy(item["note"]) seen |= {o for _, o, _ in _WORDING if o in old} item["note"] = old async with SessionLocal() as session: await catalog.write_entry(session, sid, table, notes, if_version=detail["version"], actor=None) assert seen == {o for _, o, _ in _WORDING}, "四处旧说法都写进了目录" for sid in (whole, capped): before = {t: await _notes(client, sid, t) for t in tables} body = await _profile(client, sid, tables) for table in tables: done = _table(body, table) assert (done["added"], done["updated"], done["removed"]) == (0, 0, 0), (table, done) assert done["version"] == before[table]["version"] assert (await _notes(client, sid, table))["notes"] == before[table]["notes"] def test_merge_ignores_a_profile_note_that_only_changed_its_date(): old = {"columns": {"status": {"codes": catalog.make_item( {"1": "", "0": ""}, "profile", "proposed", note="数据剖析(2026-10-01):统计全表,1500 行非空值共 2 个取值。", at=DAY1)}}} same = {"columns": {"status": {"codes": catalog.make_item( {"1": "", "0": ""}, "profile", "proposed", note="数据剖析(2026-10-02):统计全表,1500 行非空值共 2 个取值。")}}} merged, stats = catalog.merge_notes(old, same, covered={"profile"}, at=DAY2) assert merged == old and stats == catalog.MergeStats() changed = {"columns": {"status": {"codes": catalog.make_item( {"1": "", "0": ""}, "profile", "proposed", note="数据剖析(2026-10-02):统计全表,1600 行非空值共 2 个取值。")}}} merged, stats = catalog.merge_notes(old, changed, covered={"profile"}, at=DAY2) assert stats.updated == 1 and "2026-10-02" in merged["columns"]["status"]["codes"]["note"] def test_profile_notes_without_date_stay_compatible_with_dated_ones(): """A7:剖析写的说明不再带日期(「数据剖析:…」)。升级前写的「数据剖析(2026-10-01):…」照样认得是剖析写的; 结论一样、只是新的不带日期,不算变化——升级后第一次重新剖析不能因此给每张表升一个版本。""" dated = "数据剖析(2026-10-01):统计全表,1500 行非空值共 2 个取值。" plain = "数据剖析:统计全表,1500 行非空值共 2 个取值。" assert catalog.is_profile_note(dated) and catalog.is_profile_note(plain) assert not catalog.is_profile_note("人工备注:数据剖析(2026-10-01)") and not catalog.is_profile_note(None) assert catalog.same_profile_note(dated, plain) and catalog.same_profile_note(plain, dated) assert catalog.same_profile_note("数据剖析(2026-10-02):统计全表,1500 行非空值共 2 个取值。", dated) assert not catalog.same_profile_note(dated, "数据剖析:统计全表,1600 行非空值共 2 个取值。") assert not catalog.same_profile_note(plain, "统计全表,1500 行非空值共 2 个取值。") old = {"columns": {"status": {"codes": catalog.make_item({"1": "", "0": ""}, "profile", "proposed", note=dated, at=DAY1)}}} new = {"columns": {"status": {"codes": catalog.make_item({"1": "", "0": ""}, "profile", "proposed", note=plain)}}} merged, stats = catalog.merge_notes(old, new, covered={"profile"}, at=DAY2) assert merged == old and stats == catalog.MergeStats() # ========================================================================== # B3:公式在人工编辑、模型起草两条路径上也拦下 # ========================================================================== def test_human_edit_rejects_formulas_in_changed_text(): existing = {"columns": {"amount": {"label": catalog.make_item("实收金额", "llm")}}} for submitted in ( {"columns": {"amount": {"label": catalog.make_item("实收金额", "llm"), "meaning": {"value": "客单价=金额/人数"}}}}, {"description": {"value": "转化率 = 下单数 / 访问数"}}, {"columns": {"amount": {"label": {"value": "SUM(amount)"}}}}, ): with pytest.raises(catalog.CatalogInvalid) as e: catalog.apply_human_edit(existing, submitted, table="orders", at=AT) assert "口径卡" in str(e.value) # 库里早就有的含公式的项,值没动就照常提交:不能因为旧数据挡住这张表的任何写入 legacy = {"columns": {"amount": {"meaning": catalog.make_item("客单价=金额/人数", "llm")}}} out = catalog.apply_human_edit(legacy, {**legacy, "label": {"value": "订单"}}, table="orders", at=AT) assert out["columns"]["amount"]["meaning"]["value"] == "客单价=金额/人数" assert catalog.validate_notes(legacy) == [] # 结构校验不查公式 async def test_put_with_a_formula_is_422_and_says_where_formulas_go(client, make_source, scenic_db): sid = await make_source(scenic_db, draft=["orders"]) detail = await _notes(client, sid, "orders") notes = {**detail["notes"], "columns": {"total_amount": {"meaning": {"value": "客单价=金额/人数"}}}} r = await _put(client, sid, "orders", notes, detail["version"]) assert r.status_code == 422 and "口径卡" in r.json()["detail"] assert (await _notes(client, sid, "orders"))["version"] == detail["version"] async def test_model_draft_drops_formula_items(): from types import SimpleNamespace meta = {"qualified": "orders", "columns": [{"name": "id", "type": "INTEGER"}, {"name": "total_amount", "type": "REAL"}], "primary_key": ["id"], "foreign_keys": []} source = SimpleNamespace(id="src", name="shop", kind="sqlite", origin="manual", schema_cache={"tables": {"orders": meta}}) model = FakeModel({"orders": {"label": "客单价=金额/人数", "grain": "每笔订单一行", "columns": [ {"name": "total_amount", "label": "订单金额", "meaning": "= SUM(明细金额)", "unit": "元"}]}}) notes = (await catalog.draft_with_model(model, source, ["orders"]))["orders"].notes assert "label" not in notes and notes["grain"]["value"] == "每笔订单一行" assert set(notes["columns"]["total_amount"]) == {"label", "unit"} # ========================================================================== # 一致性 # ========================================================================== @pytest.mark.parametrize("name, type_, expected", [ ("visit_time", "TEXT", True), ("ordered_at", "TEXT", True), ("joined_on", "VARCHAR(20)", True), ("createdAt", "", True), ("biz_date", "DATE", True), ("paid", "DATETIME", True), ("loaded", "TIMESTAMP", True), ("dt", "TEXT", True), ("date", "TEXT", True), ("created_at", "INTEGER", False), # 整数存的时间戳:取值不是日期,剖析和检查都不当日期列 ("open_time", "TIME", False), # 只有时刻、没有日期 ("on_hand", "INTEGER", False), ("status", "TEXT", False), ("at", "TEXT", False), ("ticket_no", "TEXT", False), ]) def test_date_column_rule_is_shared(name, type_, expected): assert catalog.is_date_column(name, type_) is expected # 剖析认的日期列(业务日期提议)和 SQL 检查认的时间列(按别的时间列统计)是同一套 assert catalog_profile._is_date_column({"name": name, "type": type_}) is expected table = sqlcheck._Table(alias="t", name="t", meta={"columns": [{"name": name, "type": type_}]}, notes={}, order=0) assert table.temporal(name) is expected def test_table_names_match_by_name_key_everywhere(): """表名只把 ASCII 字母转小写(name_key,和 SQLite 比较标识符一致):非 ASCII 的大小写不同就是两张表。""" cache = {"tables": {"CAFÉ_ORDERS": {"columns": []}, "Visits": {"columns": []}}} assert catalog.resolve_table_name(cache, "visits") == "Visits" assert catalog.resolve_table_name(cache, "main.VISITS") == "Visits" assert catalog.resolve_table_name(cache, "cafÉ_orders") == "CAFÉ_ORDERS" assert catalog.resolve_table_name(cache, "café_orders") is None checker = sqlcheck.SqlChecker(kind="sqlite", schema_cache=cache, notes={"CAFÉ_ORDERS": { "label": catalog.make_item("咖啡订单", "human")}}) assert checker.notes_of("cafÉ_orders")["label"]["value"] == "咖啡订单" assert checker.notes_of("café_orders") == {} def test_word_splitting_has_one_implementation(): assert catalog.name_words("memberTagId") == ["member", "tag", "id"] assert catalog.name_words("MEMBER_TAG") == ["member", "tag"] assert not hasattr(catalog_profile, "_words") and not hasattr(catalog_profile, "_WORDS")