Files
AgentLab/backend/tests/test_schema_changes.py
T
ThearchyHelios 8d59ca1581 W3-a 表结构变化:前后差异、影响面、结构版本与 schema.drift(next-round 03 B1)
以前重新探查是整份替换表结构、不比前后:删了一列,那一列上人工确认过的目录项、写死这一列的已发布模板都收不到提醒,
正式运行要到查询那一步才报错。现在:

- data/schema_diff.py:纯函数,同一张表前后两次的结构比出变化(加列、删列、类型、可空、注释、主键与唯一约束、外键);
  疑似改名(类型相同、夹在同样两列之间、只有一对)只说「可能」;读不到唯一约束、外键的一边不比;升级后第一次读到
  (结构版本 0)只建基线,不把通用写法换成方言完整写法说成类型变了。
- 后台探查顺带记变化(data/introspect_jobs.py):刷新对象索引记新出现的表、真删除的表(第一次建索引只建基线);开始
  执行时记离开范围的有目录的表、范围规则和上一次不同;逐批读完拿上一次的结构比,读不到结构的记 table_reflect_failed。
  离开范围、读不到结构不算删除。记变化出错只记日志,不挡探查。
- 变化记录 source_changes(db/models_sources.py 的 SourceChange,格式 agentlab-change/1,seq 自增不复用、按指纹去重),
  记录与读取在 data/source_changes.py;取值观察的表 TableObservation 同文件建好。
- 影响面(data/catalog_impact.structure_impact):目录里说到被删、被改的列的项(列级项、业务主键、业务日期、有效记录条件
  按 parse_valid_filter 取列、两端的关联关系)带状态,人工确认过的升为要处理;已发布模板写死的 SQL 换上新结构再跑
  sql_unknown_columns,说得出「这条 SQL 现在会报列不存在」。sql_unknown_columns 从 api/copilot.py 挪到
  data/sqlcheck_columns.py 共用,copilot 那边只改 import。
- 发布时记 WorkflowVersion.schema_versions(只记直接引用、结构版本大于 0 的表);正式运行发起时比对,变了写 schema.drift
  (core/events.py 登记,只提醒、不拦);版本详情给出 schema_versions、schema_changes;发布前检查把这一版直接引用的表上
  还没处理的结构变化列成警告(schema.change_open),不拦发布。

测试:test_schema_diff(纯函数)、test_schema_changes(SQLite 夹具 ALTER 前后、影响面、去重、真删除与离开范围、
schema.drift、发布前检查、升级后基线)。
2026-10-16 16:26:11 +08:00

264 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""表结构变化:前后差异进变化记录、影响面、结构版本与 schema.drift、发布前检查(next-round 03 B1)。
用一个 SQLite 夹具(虚构的店铺库:stores、orders、refunds)走一遍真实路径:后台探查建基线 → 写目录(人工确认的码值、
有效记录条件、关联关系)→ 发布一个写死 SQL 的模板(记下结构版本)→ ALTER(删列、加列、改名、改类型、加注释)→ 重新探查:
- 变化恰好就是这几条,疑似改名只说「可能」;第一次探查只建基线、不报变化;
- 删掉的列上人工确认过的项、写死这一列的已发布模板都列进影响面,这条变化是「要处理」(action),有效记录条件用到它记
rules_touched;
- 再探查一次(结构没变)不重复记;表真删了记 table_removed,范围外 ≠ 删除;
- 从这一版发起的正式运行写一条 schema.drift、照常发起;发布前检查列出这张表上还没处理的结构变化(警告,不拦)。
"""
from __future__ import annotations
import asyncio
import sqlite3
import uuid
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.data import catalog
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
from app.db.models_sources import SourceChange, SourceObject
from app.engine.runner import run_manager
from app.main import app
from tests.fixtures.sources import drop_source, introspect_now
SQL = "SELECT o.status, o.amount FROM orders o WHERE o.status = 'paid'"
def _build(path: Path) -> None:
db = sqlite3.connect(path)
db.executescript("""
CREATE TABLE stores (id INTEGER PRIMARY KEY, name VARCHAR(40));
CREATE TABLE orders (id INTEGER PRIMARY KEY, store_id INTEGER REFERENCES stores(id), status VARCHAR(10),
amount NUMERIC(12, 2), note VARCHAR(40), paid_on DATE);
CREATE TABLE refunds (id INTEGER PRIMARY KEY, order_id INTEGER, amount NUMERIC(12, 2));
INSERT INTO stores VALUES (1, 'east'), (2, 'west');
INSERT INTO orders VALUES (1, 1, 'paid', 10.5, 'a', '2026-10-01'), (2, 2, 'void', 3, 'b', '2026-10-02');
""")
db.commit()
db.close()
def _alter(path: Path) -> None:
"""删 status、加 channel、note 改名为 remark(同位置同类型)、amount 改类型(SQLite 没有改类型:整表重建)。"""
db = sqlite3.connect(path)
db.executescript("""
CREATE TABLE orders_new (id INTEGER PRIMARY KEY, store_id INTEGER REFERENCES stores(id),
amount NUMERIC(14, 2), remark VARCHAR(40), paid_on DATE, channel VARCHAR(8));
INSERT INTO orders_new SELECT id, store_id, amount, note, paid_on, 'web' FROM orders;
DROP TABLE orders;
ALTER TABLE orders_new RENAME TO orders;
""")
db.commit()
db.close()
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def shop(client, tmp_path):
path = tmp_path / "shop.db"
_build(path)
name = f"shop_{uuid.uuid4().hex[:8]}"
r = await client.post("/api/datasources", json={"name": name, "kind": "sqlite", "database": str(path),
"readonly": True})
assert r.status_code == 201, r.text
sid = r.json()["id"]
made: list[str] = []
yield {"id": sid, "name": name, "path": path, "made": made}
async with SessionLocal() as session:
for wid in made:
if (wf := await session.get(Workflow, wid)) is not None:
await session.delete(wf)
await session.commit()
await drop_source(sid)
async def _changes(sid: str) -> list[SourceChange]:
async with SessionLocal() as session:
return list((await session.execute(select(SourceChange).where(SourceChange.source_id == sid)
.order_by(SourceChange.seq))).scalars())
async def _write_notes(sid: str) -> None:
async with SessionLocal() as session:
await catalog.write_entry(session, sid, "orders", {
"label": catalog.make_item("订单", "human"),
"valid_filter": catalog.make_item("status <> 'void'", "human"),
"columns": {"status": {"codes": catalog.make_item({"paid": "已支付", "void": "作废"}, "human", complete=True)},
"note": {"label": catalog.make_item("备注", "llm")}},
}, if_version=0, actor="测试员")
await catalog.write_entry(session, sid, "refunds", {
"relations": [{"id": catalog.relation_id("refunds", ["order_id"], "orders", ["id"]),
"columns": ["order_id"], "to_table": "orders", "to_columns": ["id"],
"cardinality": "many_to_one", "source": "human", "status": "confirmed"}],
}, if_version=0, actor="测试员")
def _graph(source: str) -> dict:
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
nodes = [node("start", "input"), node("fetch", "tool", tool=f"db_query__{source}", args={"sql": SQL}),
node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.fetch }}"}])]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def _publish(client, shop) -> str:
r = await client.post("/api/workflows", json={"name": f"订单日报-{uuid.uuid4().hex[:6]}",
"graph": _graph(shop["name"])})
wf = r.json()["id"]
shop["made"].append(wf)
body = (await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"})).json()
assert body["ok"] is True, body
return wf
async def _settle(run_id: str) -> Run:
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
async def test_first_introspect_is_baseline_and_alter_reports_exactly_the_changes(client, shop):
sid = shop["id"]
await introspect_now(client, sid)
assert await _changes(sid) == [], "第一次探查只建基线:不报「新出现的表」,也不报列"
async with SessionLocal() as session:
orders = (await session.execute(select(SourceObject).where(
SourceObject.source_id == sid, SourceObject.name == "orders"))).scalar_one()
assert orders.struct_version == 1
await _write_notes(sid)
wf = await _publish(client, shop)
async with SessionLocal() as session:
snap = (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == wf, WorkflowVersion.version == 1))).scalar_one()
assert snap.schema_versions == {shop["name"]: {"orders": 1}}, "只记写死的 SQL 直接引用的表"
detail = (await client.get(f"/api/workflows/{wf}/versions/1")).json()
assert detail["schema_versions"] == {shop["name"]: {"orders": 1}} and detail["schema_changes"] == []
_alter(shop["path"])
await introspect_now(client, sid)
got = [(c.kind, c.column_name) for c in await _changes(sid) if c.table_name == "orders"]
assert sorted(got) == sorted([("column_removed", "status"), ("column_removed", "note"), ("column_added", "remark"),
("column_added", "channel"), ("column_type_changed", "amount")]), got
by = {(c.kind, c.column_name): c for c in await _changes(sid)}
removed = by[("column_removed", "status")]
assert removed.struct_version_before == 1 and removed.struct_version_after == 2
assert removed.severity == "action", "人工确认过的码值、有效记录条件、写死它的模板都还说着这一列"
paths = {(i["table"], i["path"]) for i in removed.affects["catalog"]}
assert ("orders", "columns.status.codes") in paths and ("orders", "valid_filter") in paths
assert removed.affects["rules_touched"] is True, "有效记录条件用到了被删的列"
assert all(i["needs_action"] for i in removed.affects["catalog"] if i["status"] == "confirmed")
[tpl] = removed.affects["templates"]
assert tpl["workflow_id"] == wf and tpl["impact"] == "direct"
[fetch] = [n for n in tpl["nodes"] if n["node_id"] == "fetch"]
assert fetch["breaks"] is True and any(c.endswith("status") for c in fetch["unknown_columns"]), fetch
# 疑似改名只说「可能」:note → remark 同位置同类型
note = by[("column_removed", "note")]
assert note.before["rename"] == {"from": "note", "to": "remark"} and note.kind == "column_removed"
assert note.severity != "action", "只有推断的中文名说着这一列:不是要处理"
assert by[("column_type_changed", "amount")].before == "NUMERIC(12, 2)"
assert by[("column_type_changed", "amount")].after == "NUMERIC(14, 2)"
assert all(c.detected_by["job_kind"] == "introspect" and c.detected_by["trigger"] == "manual"
for c in await _changes(sid))
# 结构没再变:再探查一次不重复记
count = len(await _changes(sid))
await introspect_now(client, sid)
assert len(await _changes(sid)) == count
# 正式运行:写一条 schema.drift,照常发起
r = await client.post("/api/runs", json={"workflow_id": wf, "run_class": "formal"})
assert r.status_code == 201, r.text
run_id = r.json()["id"]
await _settle(run_id)
async with SessionLocal() as session:
drift = [e.data for e in (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "schema.drift"))).scalars()]
assert len(drift) == 1 and drift[0]["count"] == 1
[table] = drift[0]["tables"]
assert table["table"] == "orders" and table["published"] == 1 and table["current"] == 2 and not table["removed"]
assert {c["kind"] for c in table["changes"]} >= {"column_removed", "column_type_changed"}
detail = (await client.get(f"/api/workflows/{wf}/versions/1")).json()
assert [(c["table"], c["published"], c["current"]) for c in detail["schema_changes"]] == [("orders", 1, 2)]
# 发布前检查:这一版直接引用的 orders 上有还没处理的结构变化(警告,不拦)
check = (await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published"})).json()
[issue] = [i for i in check["issues"] if i.get("code") == "schema.change_open"]
assert issue["level"] == "warning" and issue["node_id"] == "fetch" and "orders" in issue["message"]
async def test_table_removed_is_a_deletion_with_impact(client, shop):
sid = shop["id"]
await introspect_now(client, sid)
await _write_notes(sid)
db = sqlite3.connect(shop["path"])
db.execute("DROP TABLE refunds")
db.commit()
db.close()
await introspect_now(client, sid)
[gone] = [c for c in await _changes(sid) if c.kind == "table_removed"]
assert gone.table_name == "refunds" and gone.struct_version_before == 1
assert gone.severity == "action", "人工确认过的关联关系还在这张表上"
assert ("refunds", f"relations.{catalog.relation_id('refunds', ['order_id'], 'orders', ['id'])}") in {
(i["table"], i["path"]) for i in gone.affects["catalog"]}
async def test_leaving_scope_is_not_a_deletion(client, shop):
sid = shop["id"]
await introspect_now(client, sid)
await _write_notes(sid)
r = await client.put(f"/api/datasources/{sid}/introspect-scope",
json={"scope": {"mode": "rules", "include": ["stores", "orders"], "removed": ["refunds"]}})
assert r.status_code == 200, r.text
await introspect_now(client, sid)
kinds = {(c.kind, c.table_name) for c in await _changes(sid)}
assert ("table_left_scope", "refunds") in kinds, "有目录的表离开了范围"
assert ("table_removed", "refunds") not in kinds, "范围外 ≠ 删除"
assert any(k == "scope_changed" for k, _ in kinds), "范围规则和上一次探查不同"
async def test_first_introspect_after_upgrade_only_builds_a_baseline(client, shop):
"""升级前同步探查写的老缓存(类型是通用写法 VARCHAR、没有 scope 摘要):第一次按对象索引读到只建基线,不把写法的差异
说成「类型变了」。"""
from app.db.models import DataSource
sid = shop["id"]
legacy = {"tables": {"orders": {"qualified": "orders", "schema": None, "comment": None, "is_view": False,
"primary_key": ["id"],
"columns": [{"name": "id", "type": "INTEGER", "nullable": False},
{"name": "status", "type": "VARCHAR", "nullable": True}]}},
"truncated": False, "total": 1, "schema": None, "synced_at": "2026-09-01T00:00:00+00:00"}
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
row.schema_cache = legacy
await session.commit()
await introspect_now(client, sid)
assert await _changes(sid) == [], "老缓存接进来的结构版本是 0:这一次只建基线"
async with SessionLocal() as session:
orders = (await session.execute(select(SourceObject).where(
SourceObject.source_id == sid, SourceObject.name == "orders"))).scalar_one()
assert orders.struct_version == 1