mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:21:59 +00:00
154 lines
8.2 KiB
Python
154 lines
8.2 KiB
Python
"""数据目录的存储:catalog_notes 表、版本号与乐观锁、结构校验。
|
|
|
|
目录是多人可改的长期资产(助手起草、人工确认、阶段 2 的数据剖析都往里写),所以每次写入必须带上
|
|
「我是基于第几版改的」:两个人同时打开同一张表各改各的,后提交的那个要被拦下来重新载入,不能悄悄
|
|
盖掉前一个人的确认。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.data import catalog
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import CatalogNote, DataSource
|
|
|
|
|
|
async def _source() -> str:
|
|
async with SessionLocal() as session:
|
|
row = DataSource(name=f"cat_{uuid.uuid4().hex[:10]}", kind="sqlite", database=":memory:")
|
|
session.add(row)
|
|
await session.commit()
|
|
return row.id
|
|
|
|
|
|
def _label(value: str, source: str = "human", status: str = "confirmed") -> dict:
|
|
return {"value": value, "source": source, "status": status}
|
|
|
|
|
|
async def test_first_write_creates_version_one_and_records_actor():
|
|
sid = await _source()
|
|
async with SessionLocal() as session:
|
|
entry = await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")},
|
|
if_version=0, actor="王敏")
|
|
assert entry.version == 1
|
|
assert entry.updated_by == "王敏"
|
|
assert entry.notes["label"]["value"] == "入园记录"
|
|
async with SessionLocal() as session:
|
|
again = await catalog.read_entry(session, sid, "visits")
|
|
assert again is not None and again.version == 1 and again.updated_at is not None
|
|
|
|
|
|
async def test_each_write_bumps_version_and_stale_version_conflicts():
|
|
sid = await _source()
|
|
async with SessionLocal() as session:
|
|
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
|
|
second = await catalog.write_entry(session, sid, "visits", {"label": _label("入园明细")},
|
|
if_version=1, actor="李雷")
|
|
assert second.version == 2
|
|
# 另一个人还拿着第 1 版:被拦下,内容不变
|
|
with pytest.raises(catalog.CatalogConflict):
|
|
await catalog.write_entry(session, sid, "visits", {"label": _label("别的名字")}, if_version=1, actor="韩梅")
|
|
# 以为这张表还没有目录(if_version=0),其实已经有了:同样是冲突
|
|
with pytest.raises(catalog.CatalogConflict):
|
|
await catalog.write_entry(session, sid, "visits", {"label": _label("别的名字")}, if_version=0, actor=None)
|
|
async with SessionLocal() as session:
|
|
entry = await catalog.read_entry(session, sid, "visits")
|
|
assert entry.version == 2 and entry.notes["label"]["value"] == "入园明细" and entry.updated_by == "李雷"
|
|
|
|
|
|
async def test_unchanged_notes_do_not_bump_version():
|
|
"""内容没变就不升版本:冻结进表结构快照的是 {版本, 内容},空写一次也升版本的话,快照哈希会无故变化。"""
|
|
sid = await _source()
|
|
notes = {"label": _label("渠道")}
|
|
async with SessionLocal() as session:
|
|
first = await catalog.write_entry(session, sid, "channels", notes, if_version=0, actor="甲")
|
|
same = await catalog.write_entry(session, sid, "channels", dict(notes), if_version=1, actor="乙")
|
|
assert same.version == first.version == 1
|
|
assert same.updated_by == "甲"
|
|
|
|
|
|
async def test_unique_per_source_and_table():
|
|
sid = await _source()
|
|
async with SessionLocal() as session:
|
|
session.add(CatalogNote(source_id=sid, table_name="visits", notes={}, version=1))
|
|
await session.commit()
|
|
session.add(CatalogNote(source_id=sid, table_name="visits", notes={}, version=1))
|
|
with pytest.raises(IntegrityError):
|
|
await session.commit()
|
|
|
|
|
|
async def test_deleting_source_cascades():
|
|
sid = await _source()
|
|
async with SessionLocal() as session:
|
|
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
|
|
row = await session.get(DataSource, sid)
|
|
await session.delete(row)
|
|
await session.commit()
|
|
async with SessionLocal() as session:
|
|
left = (await session.execute(select(CatalogNote).where(CatalogNote.source_id == sid))).scalars().all()
|
|
assert left == []
|
|
|
|
|
|
async def test_read_catalog_lists_all_tables_of_a_source():
|
|
sid, other = await _source(), await _source()
|
|
async with SessionLocal() as session:
|
|
await catalog.write_entry(session, sid, "visits", {"label": _label("入园记录")}, if_version=0, actor=None)
|
|
await catalog.write_entry(session, sid, "stores", {"label": _label("门店")}, if_version=0, actor=None)
|
|
await catalog.write_entry(session, other, "visits", {"label": _label("别处")}, if_version=0, actor=None)
|
|
entries = await catalog.read_catalog(session, sid)
|
|
assert set(entries) == {"visits", "stores"}
|
|
assert entries["stores"].notes["label"]["value"] == "门店"
|
|
|
|
|
|
@pytest.mark.parametrize("notes, fragment", [
|
|
({"label": "入园记录"}, "中文名"), # 不是项
|
|
({"label": {"value": "x", "source": "guess", "status": "proposed"}}, "来源"), # 来源不在取值里
|
|
({"label": {"value": "x", "source": "llm", "status": "maybe"}}, "状态"),
|
|
({"kind": _label("table")}, "表类型"),
|
|
({"keys": _label("visit_id")}, "业务主键"), # 应为字符串列表
|
|
({"business_date": _label({"column": "visit_date", "rule": 1})}, "业务日期"),
|
|
({"columns": {"amount": {"measure": _label("sum")}}}, "列 amount 的度量类型"),
|
|
({"columns": {"status": {"codes": _label({"1": 2})}}}, "列 status 的码值"),
|
|
({"formula": _label("sum(amount)")}, "formula"), # 目录不放公式
|
|
({"relations": [{"id": "r1", "columns": ["a"], "to_table": "t", "to_columns": [],
|
|
"source": "fk", "status": "verified"}]}, "关联关系 r1"),
|
|
# 基数是否用数据核实过(数据剖析写):只能是 true / false
|
|
({"relations": [{"id": "r1", "columns": ["a"], "to_table": "t", "to_columns": ["id"], "cardinality": "many_to_one",
|
|
"source": "profile", "status": "verified", "cardinality_checked": "yes"}]}, "关联关系 r1"),
|
|
])
|
|
def test_validate_rejects_malformed_notes(notes, fragment):
|
|
problems = catalog.validate_notes(notes)
|
|
assert problems, notes
|
|
assert any(fragment in p for p in problems), problems
|
|
|
|
|
|
def test_validate_accepts_full_example():
|
|
notes = {
|
|
"label": _label("入园记录"), "description": _label("每次检票入园记一行", "llm", "proposed"),
|
|
"grain": _label("每次入园"), "keys": _label(["visit_id"]), "kind": _label("fact"),
|
|
"business_date": _label({"column": "visit_date", "rule": "按检票时间计", "timezone": "+08:00"}),
|
|
"valid_filter": _label("status = 1"), "dedup": _label("同一票号只计一次"),
|
|
"columns": {"amount": {"label": _label("实收金额"), "meaning": _label("扣除优惠后的金额"),
|
|
"unit": _label("元"), "measure": _label("flow"),
|
|
"codes": _label({"1": "有效"}, "comment", "proposed")}},
|
|
"relations": [{"id": catalog.relation_id("visits", ["store_id"], "stores", ["id"]), "columns": ["store_id"],
|
|
"to_table": "stores", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": None,
|
|
"source": "fk", "status": "verified"},
|
|
{"id": catalog.relation_id("visits", ["gate_id"], "gates", ["id"]), "columns": ["gate_id"],
|
|
"to_table": "gates", "to_columns": ["id"], "cardinality": "many_to_one", "coverage": 1.0,
|
|
"source": "profile", "status": "verified", "cardinality_checked": True}],
|
|
}
|
|
assert catalog.validate_notes(notes) == []
|
|
|
|
|
|
async def test_write_rejects_invalid_notes():
|
|
sid = await _source()
|
|
async with SessionLocal() as session:
|
|
with pytest.raises(catalog.CatalogInvalid) as e:
|
|
await catalog.write_entry(session, sid, "visits", {"label": "入园记录"}, if_version=0, actor=None)
|
|
assert e.value.problems
|