mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
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、发布前检查、升级后基线)。
This commit is contained in:
+2
-132
@@ -25,6 +25,7 @@ from app.api.copilot_gen import ( # noqa: F401
|
||||
# 起别名:本文件底下有个叫 explain 的接口,generate 里又有个局部变量叫 raw,同名会互相盖掉
|
||||
from app.core.errors import explain as explain_error, graph_error, raw as raw_error
|
||||
from app.data.sqlcheck import CHECK_CODES
|
||||
from app.data.sqlcheck_columns import sql_unknown_columns
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Workflow
|
||||
from app.engine.layout import CORRIDOR_MIN, MIN_ROW_GAP, NODE_W, _height, auto_layout
|
||||
@@ -1347,80 +1348,9 @@ def _catalog_issues(node_id: str, sql: str, checker: Any) -> list[dict[str, Any]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SQL 里的列名对照数据源结构
|
||||
#
|
||||
# 只做有把握的判断,宁可漏判不可误判:这条会把图打回去让模型改。CTE、FROM 里的子查询、
|
||||
# 引号括起来的标识符、结构里找不到的表、结构没探查过或者探查被截断的,一律不判。
|
||||
# SQL 里的列名对照数据源结构(sql_unknown_columns)在 data/sqlcheck_columns.py:表结构变化的影响面也用它
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: @ 开头的是变量(@region、@@session),: 开头的是参数,. 后面的是限定名
|
||||
_SQL_IDENT = re.compile(r"(?<![\w$#.:@])([A-Za-z_][A-Za-z0-9_$#]*)(?![\w$#])")
|
||||
_FROM_ITEMS = re.compile(
|
||||
r"\b(?:from|join)\s+(.+?)(?=\bwhere\b|\bgroup\b|\border\b|\bhaving\b|\blimit\b|\bfetch\b|\boffset\b"
|
||||
r"|\bunion\b|\bintersect\b|\bexcept\b|\bon\b|\busing\b|\b(?:left|right|inner|outer|full|cross|natural)\b"
|
||||
r"|\bjoin\b|\bwindow\b|\)|$)", re.I | re.S)
|
||||
_TABLE_ITEM = re.compile(r"^\s*([A-Za-z_][\w$#]*(?:\.[A-Za-z_][\w$#]*)*)(?:\s+(?:as\s+)?([A-Za-z_][\w$#]*))?\s*$",
|
||||
re.I)
|
||||
_ALIAS_AS = re.compile(r"\bas\s+([A-Za-z_][\w$#]*)", re.I)
|
||||
#: 不写 AS 的列别名:「SUM(amount) total,」「amount total FROM」
|
||||
_ALIAS_BARE = re.compile(r"(\)|\b[A-Za-z_][\w$#]*|\d)\s+([A-Za-z_][\w$#]*)\s*(?=,|\bfrom\b)", re.I)
|
||||
_QUALIFIED = re.compile(r"(?<![\w$#.])([A-Za-z_][\w$#]*)\s*\.\s*([A-Za-z_][\w$#]*|\*)(?!\s*\()")
|
||||
_SQL_WORDS = frozenset("""
|
||||
select distinct all from where and or not in is null like ilike between exists case when then else end as on using
|
||||
join inner left right outer full cross natural group by order having limit offset fetch first next rows row only
|
||||
union intersect except asc desc nulls last with over partition range preceding following unbounded current true
|
||||
false interval year month day hour minute second week quarter dow doy epoch date time timestamp datetime zone
|
||||
cast integer int bigint smallint tinyint decimal numeric real float double precision varchar char character text
|
||||
boolean bool signed unsigned binary top percent escape collate any some values current_date current_time
|
||||
current_timestamp localtime localtimestamp sysdate systimestamp rownum level dual unknown filter within
|
||||
at nocase rtrim glob regexp rlike similar to div mod xor isnull notnull minus prior connect start nocycle siblings
|
||||
grouping sets rollup cube ignore respect lateral apply qualify window exclude include ties others no groups
|
||||
for update of share nowait skip locked lock mode array sounds overlaps language query expansion separator
|
||||
charset utf8 utf8mb4 latin1 ascii unicode years months weeks days hours minutes seconds microsecond microseconds
|
||||
millisecond milliseconds year_month day_hour day_minute day_second hour_minute hour_second minute_second
|
||||
isodow isoyear century decade millennium julian timezone timezone_hour timezone_minute
|
||||
sql_calc_found_rows sql_no_cache sql_cache sql_small_result sql_big_result sql_buffer_result high_priority
|
||||
straight_join distinctrow unique recursive tablesample bernoulli sample repeatable pivot unpivot keep ordinality
|
||||
symmetric asymmetric leading trailing both placing force use index into default without local
|
||||
user current_user session_user system_user current_schema current_catalog current_role
|
||||
""".split())
|
||||
|
||||
#: 后面跟的一定是操作数、不会是别名的词:「SELECT created_at FROM」里 created_at 是列。
|
||||
#: END、NULL、TRUE、CURRENT_DATE 这类「一个值到此为止」的词不在里面——「… END flag,」的 flag 是别名
|
||||
_OPERAND_BEFORE = frozenset("""
|
||||
select distinct distinctrow all unique by as and or not xor is in like ilike glob regexp rlike similar to between
|
||||
when then else case on where having exists div mod escape interval collate using any some from join
|
||||
sql_calc_found_rows sql_no_cache sql_cache sql_small_result sql_big_result sql_buffer_result high_priority
|
||||
straight_join
|
||||
""".split())
|
||||
#: 不是列的名字:排序规则名(COLLATE x)、字符集名(USING x、CHARSET x、CHARACTER SET x)
|
||||
_NAME_AFTER = re.compile(
|
||||
r"\b(?:collate|charset|character\s+set|using(?!\s*\())\s+([A-Za-z_][\w$#]*)", re.I)
|
||||
#: DATEADD(dd, …)、DATEDIFF(wk, …) 的第一个参数是日期部分的缩写,不是列。只在这个位置放过:
|
||||
#: 放进关键字表的话,别处打错成 wk、dd 的列名就查不出来了
|
||||
_DATEPART_ARG = re.compile(
|
||||
r"\b(?:dateadd|datediff|datediff_big|datepart|datename|datetrunc|date_bucket|timestampadd|timestampdiff)"
|
||||
r"\s*\(\s*([A-Za-z_][\w$#]*)", re.I)
|
||||
#: 命名窗口:WINDOW w AS (…)、…, w2 AS (…)
|
||||
_WINDOW_NAME = re.compile(r"(?:\bwindow|,)\s*([A-Za-z_][\w$#]*)\s+as\s*\(", re.I)
|
||||
|
||||
|
||||
def _blank_unicode_identifiers(sql: str) -> str:
|
||||
"""带引号、名字里有非 ASCII 字符的标识符(中文别名 AS "月份"、AS `曝光量`)连引号一起换成空格。
|
||||
|
||||
这项核对只认 ASCII 的列名,中文名本来就不判;不抹掉的话,SQL 里一出现带引号的标识符就整条放弃核对,照「结果列
|
||||
起中文别名」写的 SQL 会把猜错的英文列名一起放过去。英文的带引号标识符("StatDate")大小写、空格都可能,照旧不判。"""
|
||||
from app.data.guard import _GENERIC, _tokens
|
||||
|
||||
out = []
|
||||
for token in _tokens(sql, _GENERIC):
|
||||
if token.kind == "ident" and not token.text.isascii():
|
||||
out.append(" " * len(token.text))
|
||||
else:
|
||||
out.append(token.text)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def sql_source_prefix(sql: str, source: Any) -> list[str]:
|
||||
"""SQL 里 FROM / JOIN 后面写成「数据源名.表名」的表(去重、按出现顺序)。
|
||||
|
||||
@@ -1444,66 +1374,6 @@ def sql_source_prefix(sql: str, source: Any) -> list[str]:
|
||||
return list(dict.fromkeys(f"{name}.{m.group(1)}" for m in pattern.finditer(text)))
|
||||
|
||||
|
||||
def sql_unknown_columns(sql: str, source: Any) -> tuple[list[str], list[str]] | None:
|
||||
"""SQL 里用到、数据源结构里查不到的列:(查不到的列, 这几张表的全部列)。判断不了、或者都查得到时返回 None。"""
|
||||
from app.data import introspect as _introspect
|
||||
from app.data.guard import _blank_quoted, first_verb, strip_comments
|
||||
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
if not cache.get("tables") or cache.get("failed") or first_verb(sql) != "select":
|
||||
return None
|
||||
text = _blank_unicode_identifiers(re.sub(r"\{\{.*?\}\}", " 0 ", strip_comments(sql), flags=re.S))
|
||||
if '"' in text.replace("''", "") and _blank_quoted(text) != _blank_quoted(text, identifiers=False):
|
||||
return None # 引号括起来的标识符:大小写、空格都可能,不猜
|
||||
text = _blank_quoted(text)
|
||||
if re.search(r"\bwith\b", text, re.I) or re.search(r"\b(?:from|join)\s*\(", text, re.I) or "`" in text \
|
||||
or "[" in text:
|
||||
return None # [方括号] 标识符、数组下标:同样不猜
|
||||
tables: dict[str, dict[str, Any]] = {} # 表名、别名(小写)→ 表结构
|
||||
spans: list[tuple[int, int]] = []
|
||||
for m in _FROM_ITEMS.finditer(text):
|
||||
spans.append(m.span(1))
|
||||
for item in m.group(1).split(","):
|
||||
parsed = _TABLE_ITEM.match(item)
|
||||
if not parsed:
|
||||
return None
|
||||
name, alias = parsed.group(1), parsed.group(2)
|
||||
meta = _introspect.find_table(source, name)
|
||||
if meta is None:
|
||||
return None # 结构里找不到的表(视图、同义词、截断了的结构)不判
|
||||
tables[name.lower()] = tables[name.rsplit(".", 1)[-1].lower()] = meta
|
||||
if alias and alias.lower() not in _SQL_WORDS:
|
||||
tables[alias.lower()] = meta
|
||||
if not tables:
|
||||
return None
|
||||
known = list(dict.fromkeys(c["name"] for meta in {id(m): m for m in tables.values()}.values()
|
||||
for c in meta.get("columns") or []))
|
||||
columns = {c.lower() for c in known}
|
||||
# 「SELECT created_at FROM」里 created_at 前面是关键字,它是列,不是别名
|
||||
aliases = {a.lower() for a in _ALIAS_AS.findall(text)} | {
|
||||
m.group(2).lower() for m in _ALIAS_BARE.finditer(text) if m.group(1).lower() not in _OPERAND_BEFORE} | {
|
||||
a.lower() for a in _WINDOW_NAME.findall(text)}
|
||||
names = {m.start(1) for pattern in (_NAME_AFTER, _DATEPART_ARG) for m in pattern.finditer(text)}
|
||||
unknown: list[str] = []
|
||||
for m in _QUALIFIED.finditer(text):
|
||||
if any(a <= m.start() < b for a, b in spans):
|
||||
continue
|
||||
meta = tables.get(m.group(1).lower())
|
||||
col = m.group(2)
|
||||
if meta is not None and col != "*" and col.lower() not in {c["name"].lower() for c in meta.get("columns") or []}:
|
||||
unknown.append(f"{m.group(1)}.{col}")
|
||||
for m in _SQL_IDENT.finditer(text):
|
||||
word = m.group(1)
|
||||
low = word.lower()
|
||||
if any(a <= m.start() < b for a, b in spans) or low in _SQL_WORDS or low in columns or low in aliases \
|
||||
or low in tables or m.start() in names or re.match(r"\s*[.(]", text[m.end():]) \
|
||||
or text[m.end():m.end() + 1] == "'": # N'…'、X'…'、_utf8mb4'…':字面量的前缀
|
||||
continue
|
||||
unknown.append(word)
|
||||
unknown = list(dict.fromkeys(unknown))
|
||||
return (unknown, known) if unknown else None
|
||||
|
||||
|
||||
def _source_of(tool: str) -> str | None:
|
||||
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ from app.core.errors import graph_error
|
||||
from app.api.coded import CodedHTTPException
|
||||
from app.api.runs import actor_of
|
||||
from app.core.artifact_store import graph_hash
|
||||
from app.data.catalog_impact import catalog_drift, catalog_versions_for, recorded_versions
|
||||
from app.data.catalog_impact import (
|
||||
catalog_drift, catalog_versions_for, open_structure_issues, recorded_versions, schema_drift, schema_versions_for,
|
||||
)
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Run, Workflow, WorkflowVersion
|
||||
from app.engine.governance import publish_issues
|
||||
@@ -345,6 +347,10 @@ class VersionDetailOut(VersionOut):
|
||||
catalog_versions: dict[str, Any] | None = None
|
||||
#: 发布之后目录有变化的表:[{source, source_id, table, label, published, current, impact}]
|
||||
catalog_changes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
#: 发布时记下的表结构版本 {源名: {表名: 结构版本}}(只记写死的 SQL 直接引用的表,next-round 03 B1);记录之前发布的为 null
|
||||
schema_versions: dict[str, Any] | None = None
|
||||
#: 发布之后结构版本变了的表:[{source, source_id, table, label, published, current, removed, changes, more}]
|
||||
schema_changes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
#: 入口字段换成表单(agentlab.form/1):发起表单、应用入口用同一个渲染器(X2)
|
||||
input_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
#: 账本(L4):固定这一版的应用、生效记录数、正式运行数
|
||||
@@ -388,6 +394,8 @@ async def get_version(
|
||||
published=workflow.published_version == snapshot.version, level=snapshot.level,
|
||||
catalog_versions=recorded_versions(snapshot.catalog_versions),
|
||||
catalog_changes=await catalog_drift(session, snapshot.catalog_versions),
|
||||
schema_versions=snapshot.schema_versions,
|
||||
schema_changes=await schema_drift(session, snapshot.schema_versions),
|
||||
input_schema=input_schema(fields), published_at=snapshot.published_at, published_by=snapshot.published_by,
|
||||
retired_at=snapshot.retired_at, retired_by=snapshot.retired_by, retire_note=snapshot.retire_note,
|
||||
usage=await usage(session, workflow_id, snapshot.version),
|
||||
@@ -535,7 +543,9 @@ async def gate_issues(session: AsyncSession, graph: Any, level: str) -> list[dic
|
||||
checkers = await _sql_checkers(session, spec)
|
||||
providers = await _providers(session, spec)
|
||||
with with_subgraphs(await load_subgraphs(session, spec)):
|
||||
return [i.model_dump() for i in publish_issues(spec, level=level, checkers=checkers, providers=providers)]
|
||||
issues = [i.model_dump() for i in publish_issues(spec, level=level, checkers=checkers, providers=providers)]
|
||||
# 这一版直接引用的表上还没处理的结构变化(next-round 03 B1):只是警告,不拦发布
|
||||
return issues + await open_structure_issues(session, spec)
|
||||
|
||||
|
||||
async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: WorkflowVersion, level: str,
|
||||
@@ -554,6 +564,8 @@ async def commit_publish(session: AsyncSession, workflow: Workflow, snapshot: Wo
|
||||
snapshot.level = level
|
||||
# 发布时的目录版本也记在这一版上:重新发布同一版等于按现在的目录重新认可它,跟着更新
|
||||
snapshot.catalog_versions = await catalog_versions_for(session, spec)
|
||||
# 表的结构版本同理(next-round 03 B1):正式运行发起时和现在的比,变了写 schema.drift,只提醒、不拦
|
||||
snapshot.schema_versions = await schema_versions_for(session, spec)
|
||||
# 发布人、发布时间、用到的能力版本也记在这一版上(F5):工作流上的 published_by 只记最近一次。退役过的版本重新
|
||||
# 发布等于重新认可它,退役信息清掉
|
||||
snapshot.published_at = utcnow()
|
||||
@@ -673,7 +685,10 @@ async def check_graph(session: AsyncSession, graph: Any, level: str) -> dict[str
|
||||
checkers = await _sql_checkers(session, spec)
|
||||
providers = await _providers(session, spec)
|
||||
with with_subgraphs(await load_subgraphs(session, spec)):
|
||||
return check(spec, level=level, versions=versions, cards=cards, checkers=checkers, providers=providers)
|
||||
out = check(spec, level=level, versions=versions, cards=cards, checkers=checkers, providers=providers)
|
||||
# 这一版直接引用的表上还没处理的结构变化(next-round 03 B1):发布弹窗照别的警告一样列出来,不拦发布
|
||||
out["issues"] = [*out["issues"], *await open_structure_issues(session, spec)]
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/autofix")
|
||||
|
||||
@@ -77,6 +77,10 @@ class EventType(StrEnum):
|
||||
# 每张表带 impact:direct 写死的 SQL 用到的,possible Agent 可能查询的。
|
||||
# 只提醒、不拦运行;落在封存范围内,出具物上看得到「当时的目录和发布时不一样」
|
||||
CATALOG_DRIFT = "catalog.drift"
|
||||
# 从发布版本发起的正式运行:发布之后这一版直接引用的表的结构版本变了(data/catalog_impact.schema_drift,next-round
|
||||
# 03 B1)。每张表带发布时和现在的结构版本、是否已删除、还没处理的结构变化。和 catalog.drift 分开记(老的渲染只认整数的
|
||||
# 目录版本),界面上合到同一块横幅里。只提醒、不拦运行;落在封存范围内
|
||||
SCHEMA_DRIFT = "schema.drift"
|
||||
# 报告撰写节点的自查结果:报告文档工件 id、统计、违规清单。落在封存范围内,
|
||||
# 证据接口靠它找到文档——不去翻不受封存保护的 artifacts 表
|
||||
REPORT_CHECKED = "report.checked"
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
Agent、协作成员绑定了查询工具的源里所有有目录的表。从这一版发起正式运行时和当前的比(catalog_drift),有变化
|
||||
只提醒、不拦运行——目录是给人和助手看的说明,不是运行的输入;拦下来等于让一次文字修订卡住正式出具。
|
||||
|
||||
- **表结构变化**(next-round 03 B1):结构变化的影响面(structure_impact:目录里说到被删、被改的列的项和状态,人工确认过的
|
||||
升为「要处理」;已发布模板写死的 SQL 换上新结构再跑 sql_unknown_columns,说得出「这条 SQL 现在会报列不存在」);发布时记下
|
||||
直接引用的表的结构版本(schema_versions_for,写法照 catalog_versions_for),正式运行发起时比对(schema_drift,变了写
|
||||
schema.drift,只提醒、不拦);发布前检查列出这一版用到的表上还没处理的结构变化(open_structure_issues,警告)。
|
||||
|
||||
表名的取法和证据台账同一个口径:evidence.sql_tables 取 FROM / JOIN 后面的表(去掉注释、字符串、CTE),
|
||||
去掉 schema 前缀后按 name_key 对到表结构上(和 catalog.table_usage 一致)。哪些节点算「写死的查询」以
|
||||
sqlcheck.tool_query 为准(tool_queries),发布记录、影响面、SQL 检查和助手的上下文认的是同一种节点。
|
||||
@@ -402,5 +407,325 @@ async def catalog_drift(session: AsyncSession, recorded: Mapping[str, Any] | Non
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["IMPACTS", "CatalogVersions", "agent_bindings", "agent_sources", "catalog_drift", "catalog_versions_for",
|
||||
"graph_sql_tables", "recorded_versions", "sql_table_keys", "table_impact", "template_refs", "tool_queries"]
|
||||
# ==========================================================================
|
||||
# 表结构变化(next-round 03 B1):影响面、发布时的结构版本、正式运行发起时比对、发布前检查
|
||||
# ==========================================================================
|
||||
|
||||
#: 发布时记下的结构版本:{源名: {表名: 结构版本}},只记写死的 SQL 直接引用的表(写法照 catalog_versions_for)
|
||||
SchemaVersions = dict[str, dict[str, int]]
|
||||
|
||||
|
||||
async def published_specs(session: AsyncSession) -> list[tuple[Any, Any, Any]]:
|
||||
"""每个发布过的模板、它当前的已发布版本、解析好的图:[(Workflow, WorkflowVersion, GraphSpec)]。图解析不了的跳过(发起
|
||||
不了正式运行,也就谈不上影响)。一次后台探查里各张表的影响面共用这一份,不每张表重读、重解析一遍。"""
|
||||
from app.engine.schema import GraphSpec
|
||||
|
||||
out: list[tuple[Any, Any, Any]] = []
|
||||
for workflow, version in await _published(session):
|
||||
try:
|
||||
spec = GraphSpec.model_validate(version.graph or {})
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
out.append((workflow, version, spec))
|
||||
return out
|
||||
|
||||
|
||||
def _rel_columns(rel: Mapping[str, Any], side: str) -> set[str]:
|
||||
return {str(c).lower() for c in rel.get(side) or []}
|
||||
|
||||
|
||||
def catalog_refs(notes_by_table: Mapping[str, Mapping[str, Any]], table: str, columns: set[str] | None, *,
|
||||
kind: str | None = None, before_meta: Mapping[str, Any] | None = None,
|
||||
source_kind: str | None = None) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""目录里说到这张表这几列的项:([{table, path, label, status, source}], 有效记录条件用没用到它们)。
|
||||
|
||||
columns 为 None 是整张表(表删了):这张表的每一项,外加别的表指向它的关联关系。否则是说到这几列的:
|
||||
columns.<列>.*、业务主键、业务日期、有效记录条件(按 sqlcheck.parse_valid_filter 取它用到的列,按变化之前的结构解析)、
|
||||
这张表的关联关系里用到这几列的,以及别的表指向这几列的关联关系。kind 是 fk_changed 时只看外键约束来的关联关系。
|
||||
列名按不分大小写比。"""
|
||||
from app.data import catalog, catalog_review, sqlcheck
|
||||
|
||||
own = notes_by_table.get(table) or {}
|
||||
wanted = {c.lower() for c in columns} if columns is not None else None
|
||||
out: list[dict[str, Any]] = []
|
||||
rules_touched = False
|
||||
|
||||
def add(owner: str, key: tuple[str, ...], item: Mapping[str, Any]) -> None:
|
||||
out.append({"table": owner, "path": catalog_review.slot_path(key), "label": catalog_review.item_label(key, item),
|
||||
"status": item.get("status"), "source": item.get("source")})
|
||||
|
||||
for key, item in catalog._slots(own).items():
|
||||
if wanted is None:
|
||||
add(table, key, item)
|
||||
if key == ("t", "valid_filter"):
|
||||
rules_touched = True
|
||||
continue
|
||||
if kind == "fk_changed":
|
||||
if key[0] == "r" and item.get("source") == "fk" and _rel_columns(item, "columns") & wanted:
|
||||
add(table, key, item)
|
||||
continue
|
||||
hit = False
|
||||
if key[0] == "c":
|
||||
hit = key[1].lower() in wanted
|
||||
elif key == ("t", "keys"):
|
||||
hit = any(str(c).lower() in wanted for c in item.get("value") or [])
|
||||
elif key == ("t", "business_date"):
|
||||
hit = str((item.get("value") or {}).get("column") or "").lower() in wanted
|
||||
elif key == ("t", "valid_filter"):
|
||||
parsed = sqlcheck.parse_valid_filter(item.get("value"), before_meta, kind=source_kind, table=table)
|
||||
hit = any(c.lower() in wanted for c in parsed.columns)
|
||||
rules_touched = rules_touched or hit
|
||||
elif key[0] == "r":
|
||||
hit = bool(_rel_columns(item, "columns") & wanted)
|
||||
if hit:
|
||||
add(table, key, item)
|
||||
target = name_key(table)
|
||||
for other, notes in notes_by_table.items():
|
||||
if other == table:
|
||||
continue
|
||||
for key, item in catalog._slots(notes).items():
|
||||
if key[0] != "r" or name_key(str(item.get("to_table") or "").rsplit(".", 1)[-1]) != target:
|
||||
continue
|
||||
if kind == "fk_changed":
|
||||
continue
|
||||
if wanted is None or _rel_columns(item, "to_columns") & wanted:
|
||||
add(other, key, item)
|
||||
return out, rules_touched
|
||||
|
||||
|
||||
def _short(column: str) -> str:
|
||||
return str(column).rsplit(".", 1)[-1].lower()
|
||||
|
||||
|
||||
def template_hits(specs: list[tuple[Any, Any, Any]], source_name: str, table: str, *,
|
||||
tables_after: Mapping[str, Any] | None, removed_columns: set[str] | None,
|
||||
table_removed: bool = False) -> list[dict[str, Any]]:
|
||||
"""已发布模板里用到这张表的:[{workflow_id, name, version, level, impact, nodes: [{node_id, label, type, impact,
|
||||
breaks, unknown_columns?}]}],直接引用的在前。
|
||||
|
||||
直接引用(写死的 SQL)的那几个节点,拿新结构(tables_after)再跑一遍 sql_unknown_columns(data/sqlcheck_columns):报出的
|
||||
列里有被删的列(removed_columns),就是「这条 SQL 现在会报列不存在」(breaks)。表真删了,直接引用的节点都 breaks。
|
||||
判断不了的 SQL(CTE、引号标识符……)不说会坏。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.data.sqlcheck_columns import sql_unknown_columns
|
||||
|
||||
removed = {c.lower() for c in removed_columns or set()}
|
||||
known = list(tables_after) if tables_after is not None else None
|
||||
probe = SimpleNamespace(name=source_name, schema_cache={"tables": dict(tables_after or {})})
|
||||
out: list[dict[str, Any]] = []
|
||||
for workflow, version, spec in specs:
|
||||
refs = template_refs(spec, source_name, table, known if not table_removed else None)
|
||||
if not refs:
|
||||
continue
|
||||
sqls = {getattr(node, "id", None): sql for node, name, sql in tool_queries(getattr(spec, "nodes", []) or [])
|
||||
if name == source_name}
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for ref in refs:
|
||||
entry = {k: ref[k] for k in ("node_id", "label", "type", "impact")}
|
||||
entry["breaks"] = False
|
||||
if ref["impact"] == "direct" and ref["node_id"] in sqls:
|
||||
if table_removed:
|
||||
entry["breaks"] = True
|
||||
elif removed:
|
||||
found = sql_unknown_columns(sqls[ref["node_id"]], probe)
|
||||
hit = [c for c in (found[0] if found else []) if _short(c) in removed]
|
||||
if hit:
|
||||
entry.update(breaks=True, unknown_columns=hit)
|
||||
nodes.append(entry)
|
||||
level = version.level or (workflow.status if workflow.status in ("published", "governed") else "published")
|
||||
out.append({"workflow_id": workflow.id, "name": workflow.name, "version": version.version, "level": level,
|
||||
"impact": "direct" if any(n["impact"] == "direct" for n in nodes) else "possible", "nodes": nodes})
|
||||
out.sort(key=lambda t: (t["impact"] != "direct", t["name"]))
|
||||
return out
|
||||
|
||||
|
||||
async def structure_impact(session: AsyncSession, row: Any, table: str, diffs: list[Any], *,
|
||||
before_meta: Mapping[str, Any] | None, tables_after: Mapping[str, Any] | None,
|
||||
specs: list[tuple[Any, Any, Any]] | None = None,
|
||||
notes_by_table: Mapping[str, Mapping[str, Any]] | None = None,
|
||||
) -> list[tuple[dict[str, Any], str]]:
|
||||
"""一张表的每条结构变化(schema_diff.Diff,或者表级的那几种)→ (影响面, 严重程度),和 diffs 一一对应。
|
||||
|
||||
影响面 {catalog: [{table, path, label, status, source, needs_action}], templates: [...], rules_touched}:
|
||||
- 删了表、删了列、类型或可空变了:目录里说到它们的项(catalog_refs),已发布模板里用到这张表的(template_hits);
|
||||
- 外键变了:外键约束来的关联关系;其余(加列、注释、离开范围、读不到结构)没有影响面。
|
||||
严重程度:删了表、删了列时,人工确认过的项还说着它(needs_action)、或者写死的 SQL 现在会报列不存在,是 action(要处理);
|
||||
否则按种类的缺省(schema_diff.DEFAULT_SEVERITY)。读库出错只记日志、影响面为空:影响面是提示,不挡探查。"""
|
||||
from app.data import catalog
|
||||
from app.data import schema_diff as sd
|
||||
|
||||
try:
|
||||
if notes_by_table is None:
|
||||
notes_by_table = {t: e.notes for t, e in (await catalog.read_catalog(session, row.id)).items()}
|
||||
if specs is None:
|
||||
specs = await published_specs(session)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("计算 %s 的结构变化影响面时读库出错,本次不给影响面", table)
|
||||
return [({"catalog": [], "templates": [], "rules_touched": False}, d.severity) for d in diffs]
|
||||
out: list[tuple[dict[str, Any], str]] = []
|
||||
for d in diffs:
|
||||
catalog_items: list[dict[str, Any]] = []
|
||||
templates: list[dict[str, Any]] = []
|
||||
rules = False
|
||||
destructive = d.kind in sd.DESTRUCTIVE
|
||||
if d.kind == sd.TABLE_REMOVED:
|
||||
catalog_items, rules = catalog_refs(notes_by_table, table, None, before_meta=before_meta,
|
||||
source_kind=getattr(row, "kind", None))
|
||||
templates = template_hits(specs, row.name, table, tables_after=tables_after, removed_columns=None,
|
||||
table_removed=True)
|
||||
elif d.kind in (sd.COLUMN_REMOVED, sd.COLUMN_TYPE, sd.COLUMN_NULLABLE, sd.FK) and d.column:
|
||||
catalog_items, rules = catalog_refs(notes_by_table, table, {d.column}, kind=d.kind, before_meta=before_meta,
|
||||
source_kind=getattr(row, "kind", None))
|
||||
if d.kind in (sd.COLUMN_REMOVED, sd.COLUMN_TYPE):
|
||||
templates = template_hits(specs, row.name, table, tables_after=tables_after,
|
||||
removed_columns={d.column} if d.kind == sd.COLUMN_REMOVED else None)
|
||||
for item in catalog_items:
|
||||
item["needs_action"] = destructive and item.get("status") == "confirmed"
|
||||
breaks = any(n.get("breaks") for t in templates for n in t["nodes"])
|
||||
severity = "action" if destructive and (breaks or any(i["needs_action"] for i in catalog_items)) else d.severity
|
||||
out.append(({"catalog": catalog_items, "templates": templates, "rules_touched": rules}, severity))
|
||||
return out
|
||||
|
||||
|
||||
async def _index_rows(session: AsyncSession, row: Any, names: Iterable[str]) -> dict[str, Any]:
|
||||
"""对象索引里这个源(配置的 schema 下)这几张表的行:{name_key: 行}。"""
|
||||
from app.data import source_objects
|
||||
from app.db.models_sources import SourceObject
|
||||
|
||||
keys = {name_key(str(n).rsplit(".", 1)[-1]) for n in names}
|
||||
if not keys:
|
||||
return {}
|
||||
rows = (await session.execute(select(SourceObject).where(
|
||||
SourceObject.source_id == row.id, SourceObject.schema_name == source_objects.index_schema(row),
|
||||
SourceObject.name_key.in_(list(keys))))).scalars()
|
||||
return {r.name_key: r for r in rows}
|
||||
|
||||
|
||||
async def schema_versions_for(session: AsyncSession, spec: Any) -> SchemaVersions | None:
|
||||
"""发布时要记下的结构版本:{源名: {表名: 结构版本}},只记调用工具节点写死的 SQL 直接引用的表(写法照
|
||||
catalog_versions_for)。对象索引里没有的表、结构版本还是 0 的表(升级后还没按对象索引读过)不记:不知道那时是哪一版,
|
||||
之后不能说它变了。读库出错返回 None(和没记过一样,不提醒)——记不下版本不该挡住发布。"""
|
||||
try:
|
||||
sources = await _sources_by_name(session, graph_sql_tables(spec).keys())
|
||||
tables_by_source = {name: _schema_tables(view) for name, (_, view) in sources.items()}
|
||||
used = graph_sql_tables(spec, {k: v for k, v in tables_by_source.items() if v is not None})
|
||||
out: SchemaVersions = {}
|
||||
for name, tables in sorted(used.items()):
|
||||
if name not in sources:
|
||||
continue
|
||||
found = await _index_rows(session, sources[name][0], tables)
|
||||
pinned = {t: int(found[name_key(t)].struct_version) for t in sorted(tables)
|
||||
if name_key(t) in found and (found[name_key(t)].struct_version or 0) > 0}
|
||||
if pinned:
|
||||
out[name] = pinned
|
||||
return out
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("记录发布时的结构版本失败,本次不记")
|
||||
return None
|
||||
|
||||
|
||||
async def _pending_structure(session: AsyncSession, source_id: str, table: str) -> list[Any]:
|
||||
from app.data import schema_diff as sd
|
||||
from app.data.source_changes import PENDING
|
||||
from app.db.models_sources import SourceChange
|
||||
|
||||
return list((await session.execute(select(SourceChange).where(
|
||||
SourceChange.source_id == source_id, SourceChange.table_name == table,
|
||||
SourceChange.kind.in_(sd.STRUCT_KINDS), SourceChange.status.in_(PENDING))
|
||||
.order_by(SourceChange.seq))).scalars())
|
||||
|
||||
|
||||
#: 漂移里每张表最多列几条没处理的变化
|
||||
DRIFT_CHANGES_SHOWN = 5
|
||||
|
||||
|
||||
async def schema_drift(session: AsyncSession, recorded: Mapping[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""发布时记下的结构版本和现在比:变了的表 [{source, source_id, table, label, published, current, removed, changes,
|
||||
more}]。current 是现在的结构版本(表已删除时为 None、removed 为真);changes 是这张表上在发布之后测到、还没处理的结构
|
||||
变化([{id, kind, column, severity, summary}],最多 DRIFT_CHANGES_SHOWN 条,more 是另外还有几条)。
|
||||
|
||||
没记过(加这一列之前发布的版本)返回空列表;现在找不到的源跳过;对象索引里找不到的表跳过(换了 schema、索引重建):
|
||||
不知道它现在是哪一版,不能说它变了。读库出错返回空列表:提醒是附加的。"""
|
||||
from app.data import catalog, source_changes
|
||||
|
||||
if not isinstance(recorded, Mapping) or not recorded:
|
||||
return []
|
||||
try:
|
||||
sources = await _sources_by_name(session, [str(k) for k in recorded])
|
||||
out: list[dict[str, Any]] = []
|
||||
for name in sorted(recorded):
|
||||
pinned = recorded.get(name)
|
||||
if name not in sources or not isinstance(pinned, Mapping):
|
||||
continue
|
||||
row = sources[name][0]
|
||||
found = await _index_rows(session, row, pinned)
|
||||
entries = None
|
||||
for table in sorted(pinned):
|
||||
then = pinned[table]
|
||||
obj = found.get(name_key(table))
|
||||
if obj is None or not isinstance(then, int):
|
||||
continue
|
||||
removed = obj.removed_at is not None
|
||||
now = None if removed else int(obj.struct_version or 0)
|
||||
if not removed and now == then:
|
||||
continue
|
||||
pending = [c for c in await _pending_structure(session, row.id, obj.name)
|
||||
if (c.struct_version_after or 0) > then or c.kind == "table_removed"]
|
||||
if entries is None:
|
||||
entries = await catalog.read_catalog(session, row.id)
|
||||
out.append({"source": name, "source_id": row.id, "table": obj.name,
|
||||
"label": _label(entries.get(obj.name)), "published": then, "current": now,
|
||||
"removed": removed,
|
||||
"changes": [{"id": c.id, "kind": c.kind, "column": c.column_name, "severity": c.severity,
|
||||
"summary": source_changes.summary(c)} for c in pending[:DRIFT_CHANGES_SHOWN]],
|
||||
"more": max(0, len(pending) - DRIFT_CHANGES_SHOWN)})
|
||||
return out
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("比对发布时的结构版本失败,本次不提醒")
|
||||
return []
|
||||
|
||||
|
||||
#: 发布前检查里「这张表上还有没处理的结构变化」的机读码
|
||||
SCHEMA_CHANGE_OPEN = "schema.change_open"
|
||||
|
||||
|
||||
async def open_structure_issues(session: AsyncSession, spec: Any) -> list[dict[str, Any]]:
|
||||
"""发布前检查要列的:这一版写死的 SQL 直接引用的表上,还没处理的结构变化(警告、要处理两级;说明性的不列)。每张表
|
||||
一条警告,挂在第一个直接引用它的节点上,不拦发布:结构变了不一定影响这条 SQL,真删了列查询本身会照实报错。读库出错
|
||||
返回空列表。"""
|
||||
from app.data import source_changes
|
||||
|
||||
try:
|
||||
sources = await _sources_by_name(session, graph_sql_tables(spec).keys())
|
||||
tables_by_source = {name: _schema_tables(view) for name, (_, view) in sources.items()}
|
||||
used = graph_sql_tables(spec, {k: v for k, v in tables_by_source.items() if v is not None})
|
||||
issues: list[dict[str, Any]] = []
|
||||
for name, tables in sorted(used.items()):
|
||||
if name not in sources:
|
||||
continue
|
||||
row = sources[name][0]
|
||||
for table in sorted(tables):
|
||||
pending = [c for c in await _pending_structure(session, row.id, table)
|
||||
if c.severity in ("warning", "action")]
|
||||
if not pending:
|
||||
continue
|
||||
refs = [r for r in template_refs(spec, name, table, tables_by_source.get(name)) if r["impact"] == "direct"]
|
||||
shown = ";".join(source_changes.summary(c) for c in pending[:3])
|
||||
more = f"等 {len(pending)} 条" if len(pending) > 3 else ""
|
||||
issues.append({
|
||||
"level": "warning", "node_id": refs[0]["node_id"] if refs else None, "edge_id": None, "field": None,
|
||||
"code": SCHEMA_CHANGE_OPEN, "fix": None,
|
||||
"message": (f"数据源「{name}」的表 {table} 有还没处理的结构变化:{shown}{more}。这一版写死的 SQL 用到了"
|
||||
"这张表,发布前请核对这几处 SQL 是否还对(不拦发布)"),
|
||||
})
|
||||
return issues
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("发布前检查结构变化时出错,本次不列")
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["IMPACTS", "SCHEMA_CHANGE_OPEN", "CatalogVersions", "SchemaVersions", "agent_bindings", "agent_sources",
|
||||
"catalog_drift", "catalog_refs", "catalog_versions_for", "graph_sql_tables", "open_structure_issues",
|
||||
"published_specs", "recorded_versions", "schema_drift", "schema_versions_for", "sql_table_keys",
|
||||
"structure_impact", "table_impact", "template_hits", "template_refs", "tool_queries"]
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
- **保留**(prune):每个源保留最近 KEEP_JOBS 个结束了的探查任务。
|
||||
|
||||
任务最后的状态:有表读不到是 partial,否则 succeeded;整体出错(数据源没了、连不上)是 failed。
|
||||
|
||||
**结构变化**(第 3 波,next-round 03 B1):探查顺带把前后差异记进 source_changes(data/source_changes.py),每条带影响面
|
||||
(data/catalog_impact.structure_impact)。刷新对象索引时记新出现的表、真删除的表(第一次建索引只建基线);开始执行时记
|
||||
离开范围的、有数据目录的表,以及范围规则和上一次探查不同(scope_changed);逐批读完时拿上一次的结构和这一次的比
|
||||
(data/schema_diff.diff_table),在范围里却读不到结构的记 table_reflect_failed(不算删除)。升级后第一次读到(结构版本 0)
|
||||
只建基线。记变化出错只记日志,不挡探查。任务上记 trigger(manual 人点的 / schedule 定时维护)和定时的 fire_at。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -271,6 +277,7 @@ async def preview(session: AsyncSession, row: Any, *, schema: str | None = None,
|
||||
return out
|
||||
async with so.source_lock(row.id):
|
||||
stats = await so.refresh_index(session, row.id, configured, result, legacy=row.schema_cache)
|
||||
await _record_index_changes(session, row, stats, _detected(None, "manual", None))
|
||||
await session.commit()
|
||||
plan = await so.plan_for(session, row, rules)
|
||||
out.update(stored=True, objects=[o.name for o in result.objects], scope=plan.summary(), added=stats.added,
|
||||
@@ -278,9 +285,10 @@ async def preview(session: AsyncSession, row: Any, *, schema: str | None = None,
|
||||
return out
|
||||
|
||||
|
||||
async def prepare(session: AsyncSession, row: Any, *, actor: str | None,
|
||||
trigger: str = "manual") -> tuple[IntrospectJob, int]:
|
||||
"""建一个排队的探查任务(还没开始,调用方接着 manager.start)。返回 (任务, 几张表)。
|
||||
async def prepare(session: AsyncSession, row: Any, *, actor: str | None, trigger: str = "manual",
|
||||
fire_at: str | None = None, schedule_id: str | None = None) -> tuple[IntrospectJob, int]:
|
||||
"""建一个排队的探查任务(还没开始,调用方接着 manager.start)。返回 (任务, 几张表)。trigger 是 manual(人点的)或
|
||||
schedule(定时维护:fire_at、schedule_id 记进任务的 scope,留痕这一次是哪一次触发建的)。
|
||||
|
||||
上传表格、记录表抛 IntrospectRefused;同源已有排队或进行中的探查任务抛 IntrospectBusy;对象清单拿不到抛
|
||||
IntrospectFailed(失败照实记进 schema_cache);规则命中超过上限抛 ScopeTooLarge(对象索引照样刷新了)。
|
||||
@@ -306,6 +314,7 @@ async def prepare(session: AsyncSession, row: Any, *, actor: str | None,
|
||||
rules = so.ScopeRules.from_options(row.options)
|
||||
plan = await so.plan_for(session, row, rules)
|
||||
if plan.over:
|
||||
await _record_index_changes(session, row, stats, _detected(None, trigger, actor, fire_at))
|
||||
await session.commit()
|
||||
raise ScopeTooLarge(plan)
|
||||
summary = {**plan.summary(rules), "index_method": stats.method, "index_capped": stats.capped,
|
||||
@@ -313,6 +322,10 @@ async def prepare(session: AsyncSession, row: Any, *, actor: str | None,
|
||||
"adopted": stats.adopted}
|
||||
if candidates:
|
||||
summary["available_schemas"] = candidates
|
||||
if fire_at:
|
||||
summary["fire_at"] = fire_at
|
||||
if schedule_id:
|
||||
summary["schedule_id"] = schedule_id
|
||||
job = IntrospectJob(id=new_id(), source_id=source_id, status="queued", trigger=trigger, schema_name=schema,
|
||||
scope=summary, created_by=actor)
|
||||
session.add(job)
|
||||
@@ -323,6 +336,7 @@ async def prepare(session: AsyncSession, row: Any, *, actor: str | None,
|
||||
raise IntrospectBusy(busy_message(SimpleNamespace(name=name), None)) from e
|
||||
session.add_all([IntrospectTask(job_id=job.id, seq=i, table_name=table, reason=reason)
|
||||
for i, (table, reason) in enumerate(plan.in_scope.items())])
|
||||
await _record_index_changes(session, row, stats, _detected(job.id, trigger, actor, fire_at))
|
||||
await session.commit()
|
||||
try:
|
||||
await prune(session, source_id)
|
||||
@@ -345,6 +359,93 @@ async def prune(session: AsyncSession, source_id: str, *, keep: int = KEEP_JOBS)
|
||||
return len(victims)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 结构变化(next-round 03 B1)
|
||||
# ==========================================================================
|
||||
|
||||
#: 一次刷新对象索引最多记几条「新出现的表」(几千个按月新建的分区表这类:照实记前面的,其余只进日志)
|
||||
ADDED_RECORDED = 200
|
||||
|
||||
|
||||
def _detected(job_id: str | None, trigger: str, actor: str | None, fire_at: str | None = None,
|
||||
task_seq: int | None = None) -> Any:
|
||||
from app.data.source_changes import Detected
|
||||
|
||||
return Detected(job_kind="introspect", job_id=job_id, task_seq=task_seq, trigger=trigger or "manual",
|
||||
actor=actor, fire_at=fire_at)
|
||||
|
||||
|
||||
async def _record_index_changes(session: AsyncSession, row: Any, stats: so.RefreshStats, detected: Any) -> None:
|
||||
"""刷新对象索引得出的表级变化(不提交):新出现的表(table_added;这个 schema 第一次建索引时全是新的,只建基线)、
|
||||
真删除的表(table_removed,带影响面:目录里这张表的项、别的表指向它的关联关系、写死它的已发布模板)。出错只记日志。"""
|
||||
if not stats.removed_names and not (stats.added_names and not stats.first_build):
|
||||
return
|
||||
from app.data import catalog_impact
|
||||
from app.data import schema_diff as sd
|
||||
from app.data import source_changes as sc
|
||||
|
||||
try:
|
||||
now = utcnow().isoformat()
|
||||
changes: list[sc.NewChange] = []
|
||||
added = [] if stats.first_build else list(stats.added_names)
|
||||
if len(added) > ADDED_RECORDED:
|
||||
logger.warning("数据源 %s 这次新出现 %d 个对象,变化记录只记前 %d 个", row.name, len(added), ADDED_RECORDED)
|
||||
for name in added[:ADDED_RECORDED]:
|
||||
changes.append(sc.NewChange(kind=sd.TABLE_ADDED, table=name, severity="info", key=now))
|
||||
if stats.removed_names:
|
||||
schema = so.index_schema(row)
|
||||
gone = {r.name: r for r in (await session.execute(select(SourceObject).where(
|
||||
SourceObject.source_id == row.id, SourceObject.schema_name == schema,
|
||||
SourceObject.name.in_(stats.removed_names)))).scalars()}
|
||||
tables_after = dict((row.schema_cache or {}).get("tables") or {})
|
||||
specs = await catalog_impact.published_specs(session)
|
||||
for name in stats.removed_names:
|
||||
obj = gone.get(name)
|
||||
version = int(obj.struct_version or 0) if obj is not None else 0
|
||||
meta = obj.meta if obj is not None and isinstance(obj.meta, dict) else None
|
||||
[(affects, severity)] = await catalog_impact.structure_impact(
|
||||
session, row, name, [sd.Diff(sd.TABLE_REMOVED)], before_meta=meta,
|
||||
tables_after={k: v for k, v in tables_after.items() if k != name}, specs=specs)
|
||||
changes.append(sc.NewChange(
|
||||
kind=sd.TABLE_REMOVED, table=name, severity=severity, affects=affects,
|
||||
before={"kind": obj.kind if obj is not None else "table"}, struct_version_before=version or None,
|
||||
key=(obj.removed_at.isoformat() if obj is not None and obj.removed_at else now)))
|
||||
await sc.record_all(session, row, changes, detected)
|
||||
except Exception: # noqa: BLE001 - 记变化出错不挡探查
|
||||
logger.exception("数据源 %s 记录表级的结构变化时出错", getattr(row, "name", "?"))
|
||||
|
||||
|
||||
async def _record_scope_changes(session: AsyncSession, row: Any, job: IntrospectJob, leaving: list[str],
|
||||
entering: int) -> None:
|
||||
"""开始执行、把范围写进对象索引时(不提交):有数据目录的表离开了范围(table_left_scope,不算删除),和范围规则跟上一次
|
||||
探查不同(scope_changed:前后的规则、进出各几张)。出错只记日志。"""
|
||||
from app.data import catalog
|
||||
from app.data import schema_diff as sd
|
||||
from app.data import source_changes as sc
|
||||
|
||||
try:
|
||||
detected = _detected(job.id, job.trigger, job.created_by, (job.scope or {}).get("fire_at"))
|
||||
changes: list[sc.NewChange] = []
|
||||
if leaving:
|
||||
noted = set((await catalog.read_catalog(session, row.id)).keys())
|
||||
for name in leaving:
|
||||
if name in noted:
|
||||
changes.append(sc.NewChange(kind=sd.TABLE_LEFT_SCOPE, table=name, severity="info", key=job.id))
|
||||
previous = (await session.execute(
|
||||
select(IntrospectJob).where(IntrospectJob.source_id == job.source_id, IntrospectJob.id != job.id,
|
||||
IntrospectJob.started_at.is_not(None),
|
||||
IntrospectJob.created_at <= job.created_at)
|
||||
.order_by(IntrospectJob.created_at.desc()).limit(1))).scalar_one_or_none()
|
||||
before = (previous.scope or {}).get("rules") if previous is not None else None
|
||||
after = (job.scope or {}).get("rules")
|
||||
if isinstance(before, dict) and isinstance(after, dict) and before != after:
|
||||
changes.append(sc.NewChange(kind=sd.SCOPE, table="", severity="info", before={"rules": before},
|
||||
after={"rules": after, "entered": entering, "left": len(leaving)}, key=job.id))
|
||||
await sc.record_all(session, row, changes, detected)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("数据源 %s 记录探查范围的变化时出错", getattr(row, "name", "?"))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 收尾、停止、启动时的整理
|
||||
# ==========================================================================
|
||||
@@ -425,12 +526,32 @@ async def startup() -> int:
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _side(diff: Any, which: str) -> Any:
|
||||
"""一条差异的前(before)或后(after),疑似改名的另一半记在这一侧({…, rename})。"""
|
||||
value = getattr(diff, which)
|
||||
if diff.rename:
|
||||
base = dict(value) if isinstance(value, dict) else ({"value": value} if value is not None else {})
|
||||
return {**base, "rename": dict(diff.rename)}
|
||||
return value
|
||||
|
||||
|
||||
def json_key(value: Any) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class _IntrospectRun(runtime.JobRun):
|
||||
"""一个探查任务在后台的一次执行(进队、停止、收尾的框架在 runtime.JobRun)。"""
|
||||
|
||||
kind = "introspect"
|
||||
model = IntrospectJob
|
||||
|
||||
def __init__(self, job_id: str) -> None:
|
||||
super().__init__(job_id)
|
||||
#: 已发布模板和解析好的图(catalog_impact.published_specs):这次探查里各批的影响面共用,第一次用到时读
|
||||
self._specs: list[Any] | None = None
|
||||
|
||||
async def _stopped(self) -> None:
|
||||
if self.stop_reason == "user":
|
||||
await self._end("cancelled", None, STOPPED_ERROR)
|
||||
@@ -510,8 +631,15 @@ class _IntrospectRun(runtime.JobRun):
|
||||
counts = {r: 0 for r in so.SCOPE_REASONS}
|
||||
for reason in reasons.values():
|
||||
counts[reason] = counts.get(reason, 0) + 1
|
||||
before = set((await session.execute(select(SourceObject.name).where(
|
||||
SourceObject.source_id == source_id, SourceObject.schema_name == schema,
|
||||
SourceObject.in_scope.is_(True)))).scalars())
|
||||
plan = so.ScopePlan(in_scope=reasons, counts=counts, cap=0, index_total=0, out_of_scope=0)
|
||||
await so.apply_scope(session, source_id, schema, plan)
|
||||
row = await session.get(DataSource, source_id)
|
||||
if job is not None and row is not None:
|
||||
await _record_scope_changes(session, row, job, sorted(before - set(reasons)),
|
||||
len(set(reasons) - before))
|
||||
await session.commit()
|
||||
|
||||
async def _recheck(self, session: AsyncSession, source_id: str) -> Any:
|
||||
@@ -540,8 +668,15 @@ class _IntrospectRun(runtime.JobRun):
|
||||
|
||||
async def _save_batch(self, session: AsyncSession, source_id: str, schema: str, batch: list[tuple[int, str]],
|
||||
got: so.Reflected) -> None:
|
||||
"""一批读完:读到的结构写进对象索引,逐表记 ok / failed 和结构版本。"""
|
||||
"""一批读完:读到的结构写进对象索引,逐表记 ok / failed 和结构版本;拿上一次的结构和这一次的比,变化记进
|
||||
source_changes(同一个事务)。"""
|
||||
old = {r.name: r for r in (await session.execute(
|
||||
select(SourceObject.name, SourceObject.meta, SourceObject.struct_version, SourceObject.fingerprint,
|
||||
SourceObject.reflect_error).where(
|
||||
SourceObject.source_id == source_id, SourceObject.schema_name == schema,
|
||||
SourceObject.name.in_([name for _, name in batch])))).all()}
|
||||
versions = await so.save_reflected(session, source_id, schema, got)
|
||||
await self._record_diffs(session, source_id, batch, got, old, versions)
|
||||
now = utcnow()
|
||||
for seq, name in batch:
|
||||
if name in got.tables:
|
||||
@@ -554,6 +689,60 @@ class _IntrospectRun(runtime.JobRun):
|
||||
.execution_options(synchronize_session=False))
|
||||
await session.commit()
|
||||
|
||||
async def _record_diffs(self, session: AsyncSession, source_id: str, batch: list[tuple[int, str]],
|
||||
got: so.Reflected, old: dict[str, Any], versions: dict[str, int]) -> None:
|
||||
"""这一批的结构差异(不提交):读到的表指纹变了、又不是基线(schema_diff.is_baseline)的,逐条比出变化、算影响面;
|
||||
以前读得到、这次读不到的记 table_reflect_failed。出错只记日志,不挡探查。"""
|
||||
from app.data import catalog_impact
|
||||
from app.data import schema_diff as sd
|
||||
from app.data import source_changes as sc
|
||||
|
||||
try:
|
||||
todo: list[tuple[int, str, list[Any], dict[str, Any] | None]] = []
|
||||
failed: list[tuple[int, str]] = []
|
||||
for seq, name in batch:
|
||||
prev = old.get(name)
|
||||
if prev is None:
|
||||
continue
|
||||
if name in got.tables:
|
||||
meta = got.tables[name]
|
||||
if sd.is_baseline(prev.struct_version, prev.meta) or so.fingerprint(meta) == prev.fingerprint:
|
||||
continue
|
||||
diffs = sd.diff_table(prev.meta, meta)
|
||||
if diffs:
|
||||
todo.append((seq, name, diffs, prev.meta))
|
||||
elif name in got.skipped and isinstance(prev.meta, dict) and prev.meta and not prev.reflect_error:
|
||||
failed.append((seq, name))
|
||||
if not todo and not failed:
|
||||
return
|
||||
job = await session.get(IntrospectJob, self.job_id)
|
||||
row = await session.get(DataSource, source_id)
|
||||
if job is None or row is None:
|
||||
return
|
||||
fire_at = (job.scope or {}).get("fire_at")
|
||||
if self._specs is None:
|
||||
self._specs = await catalog_impact.published_specs(session)
|
||||
tables_after = {**dict((row.schema_cache or {}).get("tables") or {}), **got.tables}
|
||||
for seq, name, diffs, before in todo:
|
||||
impacts = await catalog_impact.structure_impact(session, row, name, diffs, before_meta=before,
|
||||
tables_after=tables_after, specs=self._specs)
|
||||
after_v, before_v = versions.get(name), old[name].struct_version
|
||||
changes = [sc.NewChange(kind=d.kind, table=name, column=d.column, severity=severity,
|
||||
before=_side(d, "before"), after=_side(d, "after"), affects=affects,
|
||||
struct_version_before=before_v, struct_version_after=after_v,
|
||||
key=(after_v, d.key, json_key(d.before), json_key(d.after)))
|
||||
for d, (affects, severity) in zip(diffs, impacts)]
|
||||
await sc.record_all(session, row, changes,
|
||||
_detected(self.job_id, job.trigger, job.created_by, fire_at, seq))
|
||||
for seq, name in failed:
|
||||
await sc.record(session, row, sc.NewChange(
|
||||
kind=sd.TABLE_REFLECT_FAILED, table=name, severity="warning",
|
||||
after={"error": got.skipped.get(name)}, struct_version_before=old[name].struct_version,
|
||||
key=(old[name].struct_version, got.skipped.get(name))),
|
||||
_detected(self.job_id, job.trigger, job.created_by, fire_at, seq))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("探查任务 %s 记录结构变化时出错", self.job_id)
|
||||
|
||||
async def _finish(self, session: AsyncSession, source_id: str) -> None:
|
||||
"""读完:物化 schema_cache、盖同步时间;有表读不到是 partial,否则 succeeded。"""
|
||||
failed = int((await session.execute(select(func.count()).select_from(IntrospectTask).where(
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""表结构的前后差异(docs/design/next-round/03-数据接入与目录维护.md B1):纯函数,不连库、不读目录。
|
||||
|
||||
**为什么要有它。** 以前重新探查是整份替换 schema_cache,不比前后:删了一列,那一列上人工确认过的目录项、写死了这一列的
|
||||
已发布模板都收不到提醒,正式运行要到查询那一步才报错。现在每张表有结构版本(data/source_objects:指纹变了才加 1),
|
||||
后台探查读到新结构时拿上一次的结构和这一次的比(diff_table),变化写进 source_changes(data/source_changes.py),再由
|
||||
影响面(data/catalog_impact.structure_impact)说清哪些目录项、哪些写死的 SQL 会受影响。
|
||||
|
||||
**表级**的四种由调用方判(它知道对象索引和范围):table_added(对象索引里新出现的)、table_removed(对象索引里也没了,
|
||||
真删除)、table_left_scope(离开了探查范围)、table_reflect_failed(在范围里、这次读不到结构)。后两种**不算删除**
|
||||
(is_deletion):范围外、读不到结构时,目录和模板里说到它的照旧有效,只是这次没看到。
|
||||
|
||||
**列级**按列名比:column_added、column_removed、column_type_changed(比的是按方言编译的完整写法,VARCHAR2(20 CHAR)、
|
||||
ENUM('a','b'),见 introspect.type_text)、column_nullable_changed、comment_changed(列注释,表注释的 column 为 None)。
|
||||
**键**:主键、唯一约束的增删(key_changed),外键的增删(fk_changed)。反射时读不到唯一约束、外键的表不写那两个键:
|
||||
两边有一边没有就不比(不知道 ≠ 删了)。
|
||||
|
||||
**疑似改名**:被删的列和新加的列类型相同、在列清单里的位置相同(夹在同样的两列之间),而且这样的配对只有一对时,两条变化都带 rename,
|
||||
说法只写「可能是改名(未确认)」——库里只看得到删了一列、加了一列,不下结论。
|
||||
|
||||
**基线**(is_baseline):升级后第一次按对象索引读到这张表(结构版本还是 0:升级前同步探查的老缓存接进来的那份、或者
|
||||
从没读过)只建基线、不出变化。列类型的写法第 1 波从通用改成了方言完整写法(目录-D10),拿老缓存的 VARCHAR2 和新读的
|
||||
VARCHAR2(20 CHAR) 比,所有 Oracle 列都会被说成「类型变了」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
#: 表级的四种(调用方判)
|
||||
TABLE_ADDED = "table_added"
|
||||
TABLE_REMOVED = "table_removed"
|
||||
TABLE_LEFT_SCOPE = "table_left_scope"
|
||||
TABLE_REFLECT_FAILED = "table_reflect_failed"
|
||||
#: 列级、键
|
||||
COLUMN_ADDED = "column_added"
|
||||
COLUMN_REMOVED = "column_removed"
|
||||
COLUMN_TYPE = "column_type_changed"
|
||||
COLUMN_NULLABLE = "column_nullable_changed"
|
||||
COMMENT = "comment_changed"
|
||||
KEY = "key_changed"
|
||||
FK = "fk_changed"
|
||||
#: 探查范围的规则改了(源级,table 为空)
|
||||
SCOPE = "scope_changed"
|
||||
|
||||
STRUCT_KINDS = (TABLE_ADDED, TABLE_REMOVED, TABLE_LEFT_SCOPE, TABLE_REFLECT_FAILED, COLUMN_ADDED, COLUMN_REMOVED,
|
||||
COLUMN_TYPE, COLUMN_NULLABLE, KEY, FK, COMMENT, SCOPE)
|
||||
|
||||
#: 没有影响面时的严重程度(影响面会升级:已确认的项、写死的 SQL 指向被删的列时是 action)
|
||||
DEFAULT_SEVERITY = {
|
||||
TABLE_ADDED: "info", TABLE_REMOVED: "warning", TABLE_LEFT_SCOPE: "info", TABLE_REFLECT_FAILED: "warning",
|
||||
COLUMN_ADDED: "info", COLUMN_REMOVED: "warning", COLUMN_TYPE: "warning", COLUMN_NULLABLE: "info",
|
||||
KEY: "warning", FK: "warning", COMMENT: "info", SCOPE: "info",
|
||||
}
|
||||
|
||||
#: 会让说到它的目录项、写死的 SQL 失效的:表真删了、列删了
|
||||
DESTRUCTIVE = frozenset({TABLE_REMOVED, COLUMN_REMOVED})
|
||||
|
||||
|
||||
def is_deletion(kind: str) -> bool:
|
||||
"""这种变化是不是删除。离开范围、读不到结构都不是:范围外的表还在库里,读不到结构的上一次的结构照旧留着。"""
|
||||
return kind in DESTRUCTIVE
|
||||
|
||||
|
||||
def is_baseline(old_version: int | None, old_meta: Mapping[str, Any] | None) -> bool:
|
||||
"""这一次读到的结构只建基线、不和以前比:结构版本还是 0(升级前的老缓存接进来的、从没按对象索引读过),或者上一次的
|
||||
结构不在手上(离开过范围,结构清掉了)。"""
|
||||
return not old_version or not isinstance(old_meta, Mapping) or not old_meta.get("columns")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diff:
|
||||
"""一条变化。column 是列级变化的那一列(表注释、键、外键为 None);before / after 是前后各是什么(列的类型、可空、注释,
|
||||
键的列组,外键的两端);rename 是疑似改名的另一半 {from, to}(只说可能);key 是键的种类(primary / unique)。"""
|
||||
|
||||
kind: str
|
||||
column: str | None = None
|
||||
before: Any = None
|
||||
after: Any = None
|
||||
rename: dict[str, str] | None = None
|
||||
key: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def severity(self) -> str:
|
||||
return DEFAULT_SEVERITY.get(self.kind, "info")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"kind": self.kind, "column": self.column, "before": self.before, "after": self.after}
|
||||
if self.rename:
|
||||
out["rename"] = dict(self.rename)
|
||||
if self.key:
|
||||
out["key"] = self.key
|
||||
if self.extra:
|
||||
out.update(self.extra)
|
||||
return out
|
||||
|
||||
|
||||
def _columns(meta: Mapping[str, Any] | None) -> list[dict[str, Any]]:
|
||||
return [c for c in (meta or {}).get("columns") or [] if isinstance(c, Mapping) and c.get("name") is not None]
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
text = str(value).strip() if value is not None else ""
|
||||
return text or None
|
||||
|
||||
|
||||
def _groups(raw: Any) -> set[tuple[str, ...]]:
|
||||
return {tuple(str(c) for c in g) for g in raw or [] if isinstance(g, (list, tuple)) and g}
|
||||
|
||||
|
||||
def _fks(raw: Any) -> dict[tuple[Any, ...], dict[str, Any]]:
|
||||
out: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||||
for fk in raw or []:
|
||||
if not isinstance(fk, Mapping):
|
||||
continue
|
||||
cols = tuple(str(c) for c in fk.get("columns") or [])
|
||||
key = (cols, str(fk.get("to_table") or ""), tuple(str(c) for c in fk.get("to_columns") or []))
|
||||
out[key] = {"columns": list(key[0]), "to_table": key[1], "to_columns": list(key[2])}
|
||||
return out
|
||||
|
||||
|
||||
def _slots_of(names: list[str], common: set[str]) -> dict[str, tuple[str | None, str | None]]:
|
||||
"""每一列在列清单里的「位置」:它前后最近的、两次都在的那两列。按下标比会被别处的删列、加列带偏(删了前面一列,后面
|
||||
的下标全都前移一位),按前后的邻居比才说得出「还在原来那个位置」。"""
|
||||
out: dict[str, tuple[str | None, str | None]] = {}
|
||||
for i, name in enumerate(names):
|
||||
before = next((n for n in reversed(names[:i]) if n in common), None)
|
||||
after = next((n for n in names[i + 1:] if n in common), None)
|
||||
out[name] = (before, after)
|
||||
return out
|
||||
|
||||
|
||||
def diff_table(before: Mapping[str, Any] | None, after: Mapping[str, Any] | None) -> list[Diff]:
|
||||
"""同一张表前后两次的结构 → 变化(列级、键)。顺序:删掉的列、加上的列(各按列清单的顺序),再是各列的类型、可空、
|
||||
注释,表注释,主键、唯一约束,外键。两边一样返回空列表。是不是基线(该不该比)由调用方先用 is_baseline 判。"""
|
||||
old_cols = _columns(before)
|
||||
new_cols = _columns(after)
|
||||
old_by = {str(c["name"]): c for c in old_cols}
|
||||
new_by = {str(c["name"]): c for c in new_cols}
|
||||
removed = [n for n in old_by if n not in new_by]
|
||||
added = [n for n in new_by if n not in old_by]
|
||||
common = set(old_by) & set(new_by)
|
||||
old_pos = _slots_of([str(c["name"]) for c in old_cols], common)
|
||||
new_pos = _slots_of([str(c["name"]) for c in new_cols], common)
|
||||
|
||||
# 疑似改名:被删的和新加的类型相同、位置相同(夹在同样的两列之间);一个被删的只配得上一个新加的、反过来也是,才算
|
||||
pairs: dict[str, str] = {}
|
||||
for r in removed:
|
||||
hits = [a for a in added if old_pos[r] == new_pos[a] and old_by[r].get("type") == new_by[a].get("type")]
|
||||
if len(hits) == 1:
|
||||
back = [x for x in removed if old_pos[x] == new_pos[hits[0]]
|
||||
and old_by[x].get("type") == new_by[hits[0]].get("type")]
|
||||
if back == [r]:
|
||||
pairs[r] = hits[0]
|
||||
reverse = {a: r for r, a in pairs.items()}
|
||||
|
||||
out: list[Diff] = []
|
||||
for name in removed:
|
||||
col = old_by[name]
|
||||
out.append(Diff(COLUMN_REMOVED, name, before={"type": col.get("type"), "nullable": col.get("nullable", True)},
|
||||
rename={"from": name, "to": pairs[name]} if name in pairs else None))
|
||||
for name in added:
|
||||
col = new_by[name]
|
||||
out.append(Diff(COLUMN_ADDED, name, after={"type": col.get("type"), "nullable": col.get("nullable", True)},
|
||||
rename={"from": reverse[name], "to": name} if name in reverse else None))
|
||||
for name, col in new_by.items():
|
||||
old = old_by.get(name)
|
||||
if old is None:
|
||||
continue
|
||||
if (old.get("type") or "") != (col.get("type") or ""):
|
||||
out.append(Diff(COLUMN_TYPE, name, before=old.get("type"), after=col.get("type")))
|
||||
if bool(old.get("nullable", True)) != bool(col.get("nullable", True)):
|
||||
out.append(Diff(COLUMN_NULLABLE, name, before=bool(old.get("nullable", True)),
|
||||
after=bool(col.get("nullable", True))))
|
||||
if _text(old.get("comment")) != _text(col.get("comment")):
|
||||
out.append(Diff(COMMENT, name, before=_text(old.get("comment")), after=_text(col.get("comment"))))
|
||||
if _text((before or {}).get("comment")) != _text((after or {}).get("comment")):
|
||||
out.append(Diff(COMMENT, None, before=_text((before or {}).get("comment")),
|
||||
after=_text((after or {}).get("comment"))))
|
||||
|
||||
old_pk = [str(c) for c in (before or {}).get("primary_key") or []]
|
||||
new_pk = [str(c) for c in (after or {}).get("primary_key") or []]
|
||||
if old_pk != new_pk:
|
||||
out.append(Diff(KEY, None, before=old_pk or None, after=new_pk or None, key="primary"))
|
||||
# 唯一约束、外键:反射读不到时不写这个键,两边都有才比
|
||||
if "unique" in (before or {}) and "unique" in (after or {}):
|
||||
old_u, new_u = _groups(before.get("unique")), _groups(after.get("unique")) # type: ignore[union-attr]
|
||||
for g in sorted(old_u - new_u):
|
||||
out.append(Diff(KEY, g[0] if len(g) == 1 else None, before=list(g), after=None, key="unique"))
|
||||
for g in sorted(new_u - old_u):
|
||||
out.append(Diff(KEY, g[0] if len(g) == 1 else None, before=None, after=list(g), key="unique"))
|
||||
if "foreign_keys" in (before or {}) and "foreign_keys" in (after or {}):
|
||||
old_f, new_f = _fks(before.get("foreign_keys")), _fks(after.get("foreign_keys")) # type: ignore[union-attr]
|
||||
for k in sorted(set(old_f) - set(new_f)):
|
||||
cols = old_f[k]["columns"]
|
||||
out.append(Diff(FK, cols[0] if len(cols) == 1 else None, before=old_f[k], after=None))
|
||||
for k in sorted(set(new_f) - set(old_f)):
|
||||
cols = new_f[k]["columns"]
|
||||
out.append(Diff(FK, cols[0] if len(cols) == 1 else None, before=None, after=new_f[k]))
|
||||
return out
|
||||
|
||||
|
||||
def touched_columns(diffs: list[Diff]) -> dict[str, set[str]]:
|
||||
"""各条变化说到的列,按种类分:{"removed": {…}, "changed": {…}}。removed 是被删的列(含疑似改名的旧名),changed 是
|
||||
类型、可空变了的列。影响面按它找目录里说到这些列的项。"""
|
||||
removed = {d.column for d in diffs if d.kind == COLUMN_REMOVED and d.column}
|
||||
changed = {d.column for d in diffs if d.kind in (COLUMN_TYPE, COLUMN_NULLABLE) and d.column}
|
||||
return {"removed": removed, "changed": changed}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COLUMN_ADDED", "COLUMN_NULLABLE", "COLUMN_REMOVED", "COLUMN_TYPE", "COMMENT", "DEFAULT_SEVERITY", "DESTRUCTIVE",
|
||||
"Diff", "FK", "KEY", "SCOPE", "STRUCT_KINDS", "TABLE_ADDED", "TABLE_LEFT_SCOPE", "TABLE_REFLECT_FAILED",
|
||||
"TABLE_REMOVED", "diff_table", "is_baseline", "is_deletion", "touched_columns",
|
||||
]
|
||||
@@ -0,0 +1,492 @@
|
||||
"""数据源的变化记录(docs/design/next-round/03-数据接入与目录维护.md B3):结构变化、取值变化的记录、去重、处理和拉取。
|
||||
|
||||
**为什么要有它。** 以前表结构变了、码值多了一个新取值都没有地方记:审阅页看不到,通知发不了,口径账本的召回也没有可以
|
||||
订阅的源。现在全站只有 source_changes 这一份记录(格式 agentlab-change/1):
|
||||
|
||||
- **记**(record):后台探查读到的结构差异(data/schema_diff → introspect_jobs)、取值观察和同口径历史的比较
|
||||
(data/value_drift → catalog_profile_observe)都经这里写。fingerprint 去重:同一个变化再次测到只更新 last_seen_at、
|
||||
seen_count(还开着的顺带换上最新的影响面和严重程度),不另起一条。
|
||||
- **处理**(handle / resolve):动作都落到目录已有的写入接口上——清理目录里说到被删的列的项(catalog.write_entry,
|
||||
整份写、乐观锁)、加入码值(catalog.save_patch:apply_patch 配 _merge_codes,含义待填写,依据写这条变化的编号)、重新
|
||||
起草(建起草任务)、重新剖析(建剖析任务)、知道了、误报(必须写理由)。每个动作写 resolution,动了目录的记下目录版本,
|
||||
建了任务的记下任务编号。
|
||||
- **拉取**(list_changes / affecting):给召回按 seq 游标拉;seq 自增、不复用。触发召回的只有结构类、处理时改了目录的、
|
||||
以及 rules_touched 的新码值(triggers_recall);纯取值漂移只算数据质量提醒。
|
||||
- **说法**(summary):一条变化给人看的一句(接口、日志用);通知里不放它(不放表名、列名、取值,D9),见
|
||||
notify/service.notify_source_changed。
|
||||
|
||||
不记遮罩列的取值(观察查询本来就不碰遮罩列)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.data import schema_diff as sd
|
||||
from app.data.names import name_key
|
||||
from app.db.base import utcnow
|
||||
from app.db.models_sources import SourceChange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = "agentlab-change/1"
|
||||
|
||||
#: 取值类的变化(data/value_drift)
|
||||
CODES_NEW = "codes_new"
|
||||
CODES_SHIFT = "codes_shift"
|
||||
DATE_STALLED = "date_stalled"
|
||||
DATE_GAP = "date_gap"
|
||||
VOLUME_DROP = "volume_drop"
|
||||
ROWS_DROP = "rows_drop"
|
||||
ROWS_JUMP = "rows_jump"
|
||||
COVERAGE_DROP = "coverage_drop"
|
||||
VALUE_KINDS = (CODES_NEW, CODES_SHIFT, DATE_STALLED, DATE_GAP, VOLUME_DROP, ROWS_DROP, ROWS_JUMP, COVERAGE_DROP)
|
||||
KINDS = sd.STRUCT_KINDS + VALUE_KINDS
|
||||
|
||||
SEVERITIES = ("info", "warning", "action")
|
||||
SEVERITY_RANK = {"info": 1, "warning": 2, "action": 3}
|
||||
STATUSES = ("open", "acknowledged", "resolved", "dismissed")
|
||||
#: 还要人看的:开着的、知道了但还没处理的
|
||||
PENDING = ("open", "acknowledged")
|
||||
|
||||
#: 处理动作:clean_catalog 清理目录里说到这些列的项、add_codes 加入码值、redraft 重新起草这张表、reprofile 重新剖析、
|
||||
#: acknowledge 知道了、dismiss 误报(必须写理由)
|
||||
ACTIONS = ("clean_catalog", "add_codes", "redraft", "reprofile", "acknowledge", "dismiss")
|
||||
#: 动作之后的状态
|
||||
ACTION_STATUS = {"clean_catalog": "resolved", "add_codes": "resolved", "redraft": "resolved", "reprofile": "resolved",
|
||||
"acknowledge": "acknowledged", "dismiss": "dismissed"}
|
||||
#: 理由最长几个字
|
||||
NOTE_MAX = 500
|
||||
|
||||
#: 变化的叫法(接口给人看的 summary 用;前端第 4 波的变化标签页在 lib/terms.ts 另有一份,逐字对照)
|
||||
KIND_LABEL = {
|
||||
sd.TABLE_ADDED: "新出现的表", sd.TABLE_REMOVED: "表已删除", sd.TABLE_LEFT_SCOPE: "表离开了探查范围",
|
||||
sd.TABLE_REFLECT_FAILED: "这次读不到表结构", sd.COLUMN_ADDED: "新加了列", sd.COLUMN_REMOVED: "删除了列",
|
||||
sd.COLUMN_TYPE: "列的类型变了", sd.COLUMN_NULLABLE: "列的可空变了", sd.KEY: "键变了", sd.FK: "外键变了",
|
||||
sd.COMMENT: "注释变了", sd.SCOPE: "探查范围的规则变了",
|
||||
CODES_NEW: "出现了新码值", CODES_SHIFT: "码值的比例变化大", DATE_STALLED: "最新业务日期没有前进",
|
||||
DATE_GAP: "有的日子没有数据", VOLUME_DROP: "按日行数大幅下跌", ROWS_DROP: "行数大幅下跌", ROWS_JUMP: "行数大幅上涨",
|
||||
COVERAGE_DROP: "关联的覆盖率下降",
|
||||
}
|
||||
|
||||
|
||||
class ChangeNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ChangeFinished(Exception):
|
||||
"""这条变化已经处理过(resolved / dismissed)了。"""
|
||||
|
||||
|
||||
class ChangeInvalid(ValueError):
|
||||
"""处理动作不对:不认识的动作、误报没写理由、这种变化做不了这个动作。message 给人看。"""
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 记
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def fingerprint(*parts: Any) -> str:
|
||||
"""去重用的指纹:几段内容的规范 JSON 的 sha256。"""
|
||||
text = json.dumps(list(parts), sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detected:
|
||||
"""谁、经什么测到的(detected_by):job_kind 是 introspect / profile / on_demand,trigger 是 manual / schedule /
|
||||
on_demand,actor 是署名。"""
|
||||
|
||||
job_kind: str
|
||||
job_id: str | None = None
|
||||
task_seq: int | None = None
|
||||
trigger: str = "manual"
|
||||
actor: str | None = None
|
||||
fire_at: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out = {"job_kind": self.job_kind, "job_id": self.job_id, "task_seq": self.task_seq, "trigger": self.trigger,
|
||||
"actor": self.actor}
|
||||
if self.fire_at:
|
||||
out["fire_at"] = self.fire_at
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewChange:
|
||||
"""要记的一条变化(还没去重)。key 是去重指纹的组成部分(除了源、种类、表、列以外的那部分:结构版本、码值……)。"""
|
||||
|
||||
kind: str
|
||||
table: str
|
||||
column: str | None = None
|
||||
severity: str = "info"
|
||||
before: Any = None
|
||||
after: Any = None
|
||||
basis: dict[str, Any] | None = None
|
||||
affects: dict[str, Any] | None = None
|
||||
struct_version_before: int | None = None
|
||||
struct_version_after: int | None = None
|
||||
key: Any = None
|
||||
|
||||
|
||||
async def record(session: AsyncSession, source: Any, change: NewChange, detected: Detected) -> tuple[SourceChange, bool]:
|
||||
"""记一条变化(不提交)。同一个指纹已经有了:只更新 last_seen_at、seen_count(还开着的另换上最新的影响面、前后值、
|
||||
严重程度),返回 (那一条, False);新的返回 (新的一条, True)。"""
|
||||
if change.kind not in KINDS:
|
||||
raise ValueError(f"不认识的变化种类:{change.kind}")
|
||||
if change.severity not in SEVERITIES:
|
||||
raise ValueError(f"不认识的严重程度:{change.severity}")
|
||||
fp = fingerprint(change.kind, change.table, change.column, change.key)
|
||||
now = utcnow()
|
||||
found = (await session.execute(select(SourceChange).where(
|
||||
SourceChange.source_id == source.id, SourceChange.fingerprint == fp))).scalar_one_or_none()
|
||||
if found is not None:
|
||||
found.last_seen_at = now
|
||||
found.seen_count = int(found.seen_count or 0) + 1
|
||||
if found.status == "open":
|
||||
found.affects, found.before, found.after = change.affects, change.before, change.after
|
||||
if SEVERITY_RANK[change.severity] > SEVERITY_RANK.get(found.severity, 0):
|
||||
found.severity = change.severity
|
||||
return found, False
|
||||
row = SourceChange(source_id=source.id, source_name=str(getattr(source, "name", "") or ""),
|
||||
table_name=change.table or "", column_name=change.column, kind=change.kind,
|
||||
severity=change.severity, detected_at=now, last_seen_at=now, seen_count=1,
|
||||
detected_by=detected.to_dict(), basis=change.basis, before=change.before, after=change.after,
|
||||
affects=change.affects, struct_version_before=change.struct_version_before,
|
||||
struct_version_after=change.struct_version_after, fingerprint=fp, status="open",
|
||||
schema_tag=SCHEMA)
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
session.add(row)
|
||||
except IntegrityError:
|
||||
# 同一个指纹刚被别处写进去(单进程下几乎不会):按已有的那条更新
|
||||
found = (await session.execute(select(SourceChange).where(
|
||||
SourceChange.source_id == source.id, SourceChange.fingerprint == fp))).scalar_one()
|
||||
found.last_seen_at, found.seen_count = now, int(found.seen_count or 0) + 1
|
||||
return found, False
|
||||
return row, True
|
||||
|
||||
|
||||
async def record_all(session: AsyncSession, source: Any, changes: Iterable[NewChange],
|
||||
detected: Detected) -> list[SourceChange]:
|
||||
"""一批变化逐条 record(不提交),返回这次新记的那几条。"""
|
||||
fresh: list[SourceChange] = []
|
||||
for change in changes:
|
||||
row, new = await record(session, source, change, detected)
|
||||
if new:
|
||||
fresh.append(row)
|
||||
return fresh
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 读
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def _iso(value: Any) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def summary(row: Any) -> str:
|
||||
"""一条变化给人看的一句(接口、日志):「orders:删除了列 amount(可能是改名为 total,未确认)」。"""
|
||||
kind = row.kind
|
||||
label = KIND_LABEL.get(kind, kind)
|
||||
table = row.table_name or ""
|
||||
column = row.column_name
|
||||
where = f"{table}.{column}" if table and column else table
|
||||
text = f"{where}:{label}" if where else label
|
||||
after = row.after if isinstance(row.after, Mapping) else {}
|
||||
before = row.before if isinstance(row.before, Mapping) else {}
|
||||
rename = after.get("rename") or before.get("rename")
|
||||
if isinstance(rename, Mapping):
|
||||
text += f"(可能是 {rename.get('from')} 改名为 {rename.get('to')},未确认)"
|
||||
elif kind == sd.COLUMN_TYPE:
|
||||
text += f"({row.before} → {row.after})"
|
||||
elif kind == CODES_NEW and isinstance(after.get("codes"), list):
|
||||
text += f"({'、'.join(str(c) for c in after['codes'][:8])})"
|
||||
return text
|
||||
|
||||
|
||||
def change_out(row: SourceChange) -> dict[str, Any]:
|
||||
"""接口给的一条变化(agentlab-change/1)。"""
|
||||
return {
|
||||
"schema": row.schema_tag or SCHEMA, "seq": row.seq, "id": row.id, "source_id": row.source_id,
|
||||
"source_name": row.source_name, "table": row.table_name or None, "column": row.column_name, "kind": row.kind,
|
||||
"severity": row.severity, "summary": summary(row), "detected_at": _iso(row.detected_at),
|
||||
"last_seen_at": _iso(row.last_seen_at), "seen_count": row.seen_count or 1,
|
||||
"detected_by": dict(row.detected_by or {}), "basis": row.basis, "before": row.before, "after": row.after,
|
||||
"affects": row.affects or {"catalog": [], "templates": [], "rules_touched": False},
|
||||
"struct_version_before": row.struct_version_before, "struct_version_after": row.struct_version_after,
|
||||
"status": row.status, "resolution": row.resolution,
|
||||
}
|
||||
|
||||
|
||||
async def load(session: AsyncSession, source_id: str, change_id: str) -> SourceChange | None:
|
||||
row = (await session.execute(select(SourceChange).where(SourceChange.id == change_id))).scalar_one_or_none()
|
||||
return row if row is not None and row.source_id == source_id else None
|
||||
|
||||
|
||||
async def counts(session: AsyncSession, source_id: str) -> dict[str, Any]:
|
||||
"""一个源还要人看的变化(开着的、知道了还没处理的):{pending, by_severity: {action, warning, info}, open}。数据源卡片的
|
||||
角标「N 个变化要处理」按 by_severity.action(第 4 波)。"""
|
||||
rows = (await session.execute(select(SourceChange.status, SourceChange.severity, func.count()).where(
|
||||
SourceChange.source_id == source_id, SourceChange.status.in_(PENDING))
|
||||
.group_by(SourceChange.status, SourceChange.severity))).all()
|
||||
by = dict.fromkeys(SEVERITIES, 0)
|
||||
opened = 0
|
||||
for status, severity, n in rows:
|
||||
by[severity] = by.get(severity, 0) + n
|
||||
if status == "open":
|
||||
opened += n
|
||||
return {"pending": sum(by.values()), "open": opened, "by_severity": by}
|
||||
|
||||
|
||||
async def list_for_source(session: AsyncSession, source: Any, *, status: str | None = "pending",
|
||||
kinds: Iterable[str] | None = None, table: str | None = None, limit: int = 100,
|
||||
offset: int = 0) -> tuple[list[SourceChange], int]:
|
||||
"""一个源的变化(审阅页「变化」标签页):按「使用次数权重 × 严重程度」排(catalog_review 的权重),同分新的在前。
|
||||
status:pending 开着的和知道了的(缺省)、all、或者某一个状态。返回 (这一页, 一共几条)。"""
|
||||
from app.data import catalog_review
|
||||
|
||||
filt = [SourceChange.source_id == source.id]
|
||||
if status == "pending":
|
||||
filt.append(SourceChange.status.in_(PENDING))
|
||||
elif status and status != "all":
|
||||
filt.append(SourceChange.status == status)
|
||||
if kinds:
|
||||
filt.append(SourceChange.kind.in_(list(kinds)))
|
||||
if table:
|
||||
filt.append(SourceChange.table_name == table)
|
||||
total = int((await session.execute(select(func.count()).select_from(SourceChange).where(*filt))).scalar() or 0)
|
||||
rows = list((await session.execute(select(SourceChange).where(*filt))).scalars())
|
||||
usage = (await catalog_review.table_usage_counts(session, source)).queries if rows else {}
|
||||
|
||||
def rank(r: SourceChange) -> tuple[float, int]:
|
||||
weight = catalog_review.usage_weight(usage.get(r.table_name, 0)) * SEVERITY_RANK.get(r.severity, 1)
|
||||
return -weight, -int(r.seq or 0)
|
||||
|
||||
rows.sort(key=rank)
|
||||
return rows[offset:offset + limit], total
|
||||
|
||||
|
||||
async def list_changes(session: AsyncSession, *, since_seq: int = 0, source_id: str | None = None,
|
||||
kinds: Iterable[str] | None = None, status: Iterable[str] | str | None = None,
|
||||
limit: int = 200) -> list[dict[str, Any]]:
|
||||
"""按 seq 游标拉取(给召回):seq 大于 since_seq 的,按 seq 从小到大,最多 limit 条。拉到的最后一条的 seq 就是下一次的
|
||||
since_seq。"""
|
||||
filt = [SourceChange.seq > int(since_seq or 0)]
|
||||
if source_id:
|
||||
filt.append(SourceChange.source_id == source_id)
|
||||
if kinds:
|
||||
filt.append(SourceChange.kind.in_(list(kinds)))
|
||||
if isinstance(status, str):
|
||||
filt.append(SourceChange.status == status)
|
||||
elif status:
|
||||
filt.append(SourceChange.status.in_(list(status)))
|
||||
rows = (await session.execute(select(SourceChange).where(*filt).order_by(SourceChange.seq)
|
||||
.limit(max(1, min(int(limit), 1000))))).scalars()
|
||||
return [change_out(r) for r in rows]
|
||||
|
||||
|
||||
async def affecting(session: AsyncSession, source_name: str, tables: Iterable[str], since: int | datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""动到这个源这几张表的变化(给召回反查「这次答案用到的表之后有没有变」):表名按 name_key 比;since 是 seq(拉取的
|
||||
游标)或时刻(测到的时间晚于它的)。源级的变化(范围规则改了)不算。"""
|
||||
keys = {name_key(str(t).rsplit(".", 1)[-1]) for t in tables if t}
|
||||
if not keys:
|
||||
return []
|
||||
filt = [SourceChange.source_name == source_name, SourceChange.table_name != ""]
|
||||
if isinstance(since, datetime):
|
||||
filt.append(or_(SourceChange.detected_at > since, SourceChange.last_seen_at > since))
|
||||
elif since:
|
||||
filt.append(SourceChange.seq > int(since))
|
||||
rows = (await session.execute(select(SourceChange).where(*filt).order_by(SourceChange.seq))).scalars()
|
||||
return [change_out(r) for r in rows if name_key(r.table_name) in keys]
|
||||
|
||||
|
||||
def triggers_recall(change: Mapping[str, Any]) -> bool:
|
||||
"""这条变化触发不触发召回:结构类、处理时改了目录的(resolution 里有目录版本)、触及有效记录条件的新码值。纯取值漂移
|
||||
只算数据质量提醒。"""
|
||||
kind = change.get("kind")
|
||||
if kind in sd.STRUCT_KINDS:
|
||||
return True
|
||||
if (change.get("resolution") or {}).get("catalog_version"):
|
||||
return True
|
||||
return kind == CODES_NEW and bool((change.get("affects") or {}).get("rules_touched"))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 处理
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
def resolve(row: SourceChange, *, action: str, by: str | None, note: str | None = None,
|
||||
catalog_version: int | None = None, job_id: str | None = None) -> SourceChange:
|
||||
"""把一条变化记成处理过(不提交):状态按动作(ACTION_STATUS),resolution 写谁、什么时候、什么动作、理由、目录版本、
|
||||
任务编号。已经 resolved / dismissed 的抛 ChangeFinished;误报没写理由抛 ChangeInvalid。"""
|
||||
if action not in ACTIONS:
|
||||
raise ChangeInvalid(f"不认识的处理动作:{action}。可选:{'、'.join(ACTIONS)}")
|
||||
if row.status in ("resolved", "dismissed"):
|
||||
raise ChangeFinished("这条变化已经处理过了")
|
||||
text = (note or "").strip()
|
||||
if action == "dismiss" and not text:
|
||||
raise ChangeInvalid("标为误报要写明理由")
|
||||
if len(text) > NOTE_MAX:
|
||||
raise ChangeInvalid(f"理由最多 {NOTE_MAX} 个字")
|
||||
resolution: dict[str, Any] = {"by": by, "at": utcnow().isoformat(), "action": action, "note": text or None}
|
||||
if catalog_version is not None:
|
||||
resolution["catalog_version"] = catalog_version
|
||||
if job_id is not None:
|
||||
resolution["job_id"] = job_id
|
||||
row.status = ACTION_STATUS[action]
|
||||
row.resolution = resolution
|
||||
return row
|
||||
|
||||
|
||||
def _catalog_paths(row: SourceChange) -> list[dict[str, Any]]:
|
||||
"""影响面里这张表上的目录项(清理目录时删的就是它们)。"""
|
||||
items = ((row.affects or {}).get("catalog") or [])
|
||||
return [i for i in items if isinstance(i, Mapping) and i.get("table") == row.table_name and i.get("path")]
|
||||
|
||||
|
||||
def without_paths(notes: Mapping[str, Any], paths: Iterable[str]) -> dict[str, Any]:
|
||||
"""目录去掉这几项(路径同审阅接口:字段名、columns.<列>.<字段>、relations.<编号>)。"""
|
||||
from app.data import catalog
|
||||
|
||||
slots = catalog._slots(notes)
|
||||
for path in paths:
|
||||
try:
|
||||
slots.pop(catalog.parse_path(path), None)
|
||||
except catalog.CatalogPathError:
|
||||
continue
|
||||
return catalog._assemble(slots)
|
||||
|
||||
|
||||
async def handle(session: AsyncSession, source: Any, row: SourceChange, action: str, *, actor: str | None,
|
||||
note: str | None = None, codes: Mapping[str, str] | None = None) -> dict[str, Any]:
|
||||
"""执行一个处理动作并记下 resolution(提交)。返回 {change, catalog_version?, job?}。
|
||||
|
||||
- clean_catalog:把影响面里这张表上说到这些列的项从目录里去掉(整份写入,乐观锁),记目录版本。影响面里没有目录项的
|
||||
抛 ChangeInvalid。
|
||||
- add_codes:码值的新取值加进目录(catalog.save_patch,含义待填写;依据写「数据源变化 #编号」),只给 codes_new。codes
|
||||
给了就按它加({码: 含义},含义可以空),不给就加这条变化测到的新码值。
|
||||
- redraft:建一个只含这张表的起草任务;reprofile:建一个只含这张表的剖析任务。建不了(模型没配、剖析没开、同源正忙)
|
||||
抛 ChangeInvalid,原因照实。
|
||||
- acknowledge、dismiss(必须写理由)只改状态。
|
||||
"""
|
||||
from app.data import catalog
|
||||
|
||||
if action not in ACTIONS:
|
||||
raise ChangeInvalid(f"不认识的处理动作:{action}。可选:{'、'.join(ACTIONS)}")
|
||||
if row.status in ("resolved", "dismissed"):
|
||||
raise ChangeFinished("这条变化已经处理过了")
|
||||
if action == "dismiss" and not (note or "").strip():
|
||||
raise ChangeInvalid("标为误报要写明理由")
|
||||
out: dict[str, Any] = {}
|
||||
version: int | None = None
|
||||
job_id: str | None = None
|
||||
table = row.table_name
|
||||
if action == "clean_catalog":
|
||||
paths = [str(i["path"]) for i in _catalog_paths(row)]
|
||||
if not paths:
|
||||
raise ChangeInvalid("这条变化的影响面里没有目录项,不需要清理")
|
||||
entry = await catalog.read_entry(session, source.id, table)
|
||||
if entry is None:
|
||||
raise ChangeInvalid(f"{table} 已经没有数据目录了")
|
||||
notes = without_paths(entry.notes, paths)
|
||||
written = await catalog.write_entry(session, source.id, table, notes, if_version=entry.version, actor=actor,
|
||||
commit=False)
|
||||
version = written.version
|
||||
elif action == "add_codes":
|
||||
if row.kind != CODES_NEW or not row.column_name:
|
||||
raise ChangeInvalid("只有「出现了新码值」能加入码值")
|
||||
wanted = dict(codes) if codes else {str(c): "" for c in ((row.after or {}).get("codes") or [])}
|
||||
if not wanted or not all(isinstance(k, str) and isinstance(v, str) for k, v in wanted.items()):
|
||||
raise ChangeInvalid("要加入的码值应为 {码值: 含义} 的对照,含义可以先空着")
|
||||
entry = await catalog.read_entry(session, source.id, table)
|
||||
current = entry.version if entry else 0
|
||||
meta_tables = (getattr(source, "schema_cache", None) or {}).get("tables") or None
|
||||
changes = [{"path": f"columns.{row.column_name}.codes", "value": wanted,
|
||||
"reason": f"数据源变化 #{row.id[:6]}:取值观察测到的新码值,含义待填写"}]
|
||||
try:
|
||||
notes = catalog.apply_patch(entry.notes if entry else {}, changes, table=table, tables=meta_tables)
|
||||
except catalog.CatalogInvalid as e:
|
||||
raise ChangeInvalid(";".join(e.problems) if hasattr(e, "problems") else str(e)) from e
|
||||
written = await catalog.write_entry(session, source.id, table, notes, if_version=current, actor=actor,
|
||||
commit=False)
|
||||
version = written.version
|
||||
elif action == "reprofile":
|
||||
job_id = await _reprofile(session, source, table, actor)
|
||||
out["job"] = {"kind": "profile", "id": job_id}
|
||||
elif action == "redraft":
|
||||
job_id = await _redraft(session, source, table, actor, row.id)
|
||||
out["job"] = {"kind": "draft", "id": job_id}
|
||||
resolve(row, action=action, by=actor, note=note, catalog_version=version, job_id=job_id)
|
||||
await session.commit()
|
||||
if version is not None:
|
||||
out["catalog_version"] = version
|
||||
out["change"] = change_out(row)
|
||||
return out
|
||||
|
||||
|
||||
async def _reprofile(session: AsyncSession, source: Any, table: str, actor: str | None) -> str:
|
||||
from app.data import catalog_profile, catalog_profile_jobs, table_versions
|
||||
|
||||
try:
|
||||
settings = catalog_profile.ensure_enabled(source)
|
||||
except catalog_profile.ProfileDisabled as e:
|
||||
raise ChangeInvalid(str(e)) from e
|
||||
try:
|
||||
view = await table_versions.resolve_source(session, source)
|
||||
except table_versions.SnapshotMissing as e:
|
||||
raise ChangeInvalid(str(e)) from e
|
||||
try:
|
||||
job, _tasks = await catalog_profile_jobs.create_job(session, source, view, tables=[table], scope="selected",
|
||||
settings=settings, actor=actor)
|
||||
except (catalog_profile_jobs.ProfileBusy, catalog_profile_jobs.ProfileNothing) as e:
|
||||
raise ChangeInvalid(str(e)) from e
|
||||
catalog_profile_jobs.manager.start(job.id)
|
||||
return job.id
|
||||
|
||||
|
||||
async def _redraft(session: AsyncSession, source: Any, table: str, actor: str | None, change_id: str) -> str:
|
||||
"""建一个只含这张表的起草任务。起草模型用不了时只按表结构起草(数据库注释、外键、命名推断),不挡这个动作。"""
|
||||
from app.data import catalog, catalog_jobs, table_versions
|
||||
|
||||
try:
|
||||
view = await table_versions.resolve_source(session, source)
|
||||
except table_versions.SnapshotMissing as e:
|
||||
raise ChangeInvalid(str(e)) from e
|
||||
if not ((getattr(view, "schema_cache", None) or {}).get("tables") or {}):
|
||||
raise ChangeInvalid("这个数据源还没有表结构,不能起草")
|
||||
use_model = True
|
||||
try:
|
||||
await catalog_jobs.check_model(session)
|
||||
except catalog.CatalogModelUnavailable:
|
||||
use_model = False
|
||||
try:
|
||||
job, _tasks = await catalog_jobs.create_job(session, source, view, tables=[table], use_model=use_model,
|
||||
note=f"数据源变化 #{change_id[:6]}", actor=actor)
|
||||
except catalog_jobs.DraftBusy as e:
|
||||
raise ChangeInvalid(str(e)) from e
|
||||
catalog_jobs.manager.start(job.id)
|
||||
return job.id
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS", "ACTION_STATUS", "CODES_NEW", "CODES_SHIFT", "COVERAGE_DROP", "ChangeFinished", "ChangeInvalid",
|
||||
"ChangeNotFound", "DATE_GAP", "DATE_STALLED", "Detected", "KINDS", "KIND_LABEL", "NewChange", "PENDING", "ROWS_DROP",
|
||||
"ROWS_JUMP", "SCHEMA", "SEVERITIES", "SEVERITY_RANK", "STATUSES", "VALUE_KINDS", "VOLUME_DROP", "affecting",
|
||||
"change_out", "counts", "fingerprint", "handle", "list_changes", "list_for_source", "load", "record", "record_all",
|
||||
"resolve", "summary", "triggers_recall", "without_paths",
|
||||
]
|
||||
@@ -446,6 +446,11 @@ class RefreshStats:
|
||||
note: str | None = None
|
||||
#: 第一次建对象索引时从升级前的老缓存接进来几张(legacy_seed)
|
||||
adopted: int = 0
|
||||
#: 这次新出现的、不在了(真删除)的对象名(结构变化按它记 table_added / table_removed,next-round 03 B1);
|
||||
#: first_build:这个 schema 的对象索引是这一次才建起来的——全是新的,只建基线、不记「新出现的表」
|
||||
added_names: list[str] = field(default_factory=list)
|
||||
removed_names: list[str] = field(default_factory=list)
|
||||
first_build: bool = False
|
||||
|
||||
|
||||
def _chunks(items: list[Any], size: int = 500) -> Iterable[list[Any]]:
|
||||
@@ -548,14 +553,18 @@ async def refresh_index(session: AsyncSession, source_id: str, schema: str, resu
|
||||
await session.execute(update(_SO).where(_SO.id.in_(chunk)).values(removed_at=None)
|
||||
.execution_options(synchronize_session=False))
|
||||
gone: list[str] = []
|
||||
gone_names: list[str] = []
|
||||
if not result.capped:
|
||||
gone = [r.id for name, r in existing.items() if name not in seen and r.removed_at is None]
|
||||
gone_names = [name for name, r in existing.items() if name not in seen and r.removed_at is None]
|
||||
gone = [existing[name].id for name in gone_names]
|
||||
for chunk in _chunks(gone):
|
||||
# 真删除:不在范围里了,结构(meta)留着——下一波的结构差异要拿它和以前比
|
||||
# 真删除:不在范围里了,结构(meta)留着——结构差异(data/schema_diff)要拿它和以前比、算影响面
|
||||
await session.execute(update(_SO).where(_SO.id.in_(chunk)).values(removed_at=now, in_scope=False)
|
||||
.execution_options(synchronize_session=False))
|
||||
return RefreshStats(total=len(seen), added=len(new_rows), removed=len(gone), method=result.method,
|
||||
capped=result.capped, note=result.note, adopted=len(seeds))
|
||||
capped=result.capped, note=result.note, adopted=len(seeds),
|
||||
added_names=[r["name"] for r in new_rows] if existing else [], removed_names=gone_names,
|
||||
first_build=not existing)
|
||||
|
||||
|
||||
async def live_objects(session: AsyncSession, source_id: str, schema: str) -> list[tuple[str, str]]:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""SQL 里的列名对照数据源结构(sql_unknown_columns):助手自查(api/copilot.py)和表结构变化的影响面
|
||||
(data/catalog_impact.py:已发布模板写死的 SQL 换上新结构再跑一遍,说得出「这条 SQL 现在会报列不存在」)共用这一份。
|
||||
|
||||
以前它在 api/copilot.py 里,影响面要用就得从 data 包反过来导入接口层;挪到这里,copilot 那边只改 import。
|
||||
|
||||
只做有把握的判断,宁可漏判不可误判:助手自查会把图打回去让模型改,影响面会把一条变化升为「要处理」。CTE、FROM 里的
|
||||
子查询、引号括起来的标识符、结构里找不到的表、结构没探查过或者探查被截断的,一律不判。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
#: @ 开头的是变量(@region、@@session),: 开头的是参数,. 后面的是限定名
|
||||
_SQL_IDENT = re.compile(r"(?<![\w$#.:@])([A-Za-z_][A-Za-z0-9_$#]*)(?![\w$#])")
|
||||
_FROM_ITEMS = re.compile(
|
||||
r"\b(?:from|join)\s+(.+?)(?=\bwhere\b|\bgroup\b|\border\b|\bhaving\b|\blimit\b|\bfetch\b|\boffset\b"
|
||||
r"|\bunion\b|\bintersect\b|\bexcept\b|\bon\b|\busing\b|\b(?:left|right|inner|outer|full|cross|natural)\b"
|
||||
r"|\bjoin\b|\bwindow\b|\)|$)", re.I | re.S)
|
||||
_TABLE_ITEM = re.compile(r"^\s*([A-Za-z_][\w$#]*(?:\.[A-Za-z_][\w$#]*)*)(?:\s+(?:as\s+)?([A-Za-z_][\w$#]*))?\s*$",
|
||||
re.I)
|
||||
_ALIAS_AS = re.compile(r"\bas\s+([A-Za-z_][\w$#]*)", re.I)
|
||||
#: 不写 AS 的列别名:「SUM(amount) total,」「amount total FROM」
|
||||
_ALIAS_BARE = re.compile(r"(\)|\b[A-Za-z_][\w$#]*|\d)\s+([A-Za-z_][\w$#]*)\s*(?=,|\bfrom\b)", re.I)
|
||||
_QUALIFIED = re.compile(r"(?<![\w$#.])([A-Za-z_][\w$#]*)\s*\.\s*([A-Za-z_][\w$#]*|\*)(?!\s*\()")
|
||||
_SQL_WORDS = frozenset("""
|
||||
select distinct all from where and or not in is null like ilike between exists case when then else end as on using
|
||||
join inner left right outer full cross natural group by order having limit offset fetch first next rows row only
|
||||
union intersect except asc desc nulls last with over partition range preceding following unbounded current true
|
||||
false interval year month day hour minute second week quarter dow doy epoch date time timestamp datetime zone
|
||||
cast integer int bigint smallint tinyint decimal numeric real float double precision varchar char character text
|
||||
boolean bool signed unsigned binary top percent escape collate any some values current_date current_time
|
||||
current_timestamp localtime localtimestamp sysdate systimestamp rownum level dual unknown filter within
|
||||
at nocase rtrim glob regexp rlike similar to div mod xor isnull notnull minus prior connect start nocycle siblings
|
||||
grouping sets rollup cube ignore respect lateral apply qualify window exclude include ties others no groups
|
||||
for update of share nowait skip locked lock mode array sounds overlaps language query expansion separator
|
||||
charset utf8 utf8mb4 latin1 ascii unicode years months weeks days hours minutes seconds microsecond microseconds
|
||||
millisecond milliseconds year_month day_hour day_minute day_second hour_minute hour_second minute_second
|
||||
isodow isoyear century decade millennium julian timezone timezone_hour timezone_minute
|
||||
sql_calc_found_rows sql_no_cache sql_cache sql_small_result sql_big_result sql_buffer_result high_priority
|
||||
straight_join distinctrow unique recursive tablesample bernoulli sample repeatable pivot unpivot keep ordinality
|
||||
symmetric asymmetric leading trailing both placing force use index into default without local
|
||||
user current_user session_user system_user current_schema current_catalog current_role
|
||||
""".split())
|
||||
|
||||
#: 后面跟的一定是操作数、不会是别名的词:「SELECT created_at FROM」里 created_at 是列。
|
||||
#: END、NULL、TRUE、CURRENT_DATE 这类「一个值到此为止」的词不在里面——「… END flag,」的 flag 是别名
|
||||
_OPERAND_BEFORE = frozenset("""
|
||||
select distinct distinctrow all unique by as and or not xor is in like ilike glob regexp rlike similar to between
|
||||
when then else case on where having exists div mod escape interval collate using any some from join
|
||||
sql_calc_found_rows sql_no_cache sql_cache sql_small_result sql_big_result sql_buffer_result high_priority
|
||||
straight_join
|
||||
""".split())
|
||||
#: 不是列的名字:排序规则名(COLLATE x)、字符集名(USING x、CHARSET x、CHARACTER SET x)
|
||||
_NAME_AFTER = re.compile(
|
||||
r"\b(?:collate|charset|character\s+set|using(?!\s*\())\s+([A-Za-z_][\w$#]*)", re.I)
|
||||
#: DATEADD(dd, …)、DATEDIFF(wk, …) 的第一个参数是日期部分的缩写,不是列。只在这个位置放过:
|
||||
#: 放进关键字表的话,别处打错成 wk、dd 的列名就查不出来了
|
||||
_DATEPART_ARG = re.compile(
|
||||
r"\b(?:dateadd|datediff|datediff_big|datepart|datename|datetrunc|date_bucket|timestampadd|timestampdiff)"
|
||||
r"\s*\(\s*([A-Za-z_][\w$#]*)", re.I)
|
||||
#: 命名窗口:WINDOW w AS (…)、…, w2 AS (…)
|
||||
_WINDOW_NAME = re.compile(r"(?:\bwindow|,)\s*([A-Za-z_][\w$#]*)\s+as\s*\(", re.I)
|
||||
|
||||
|
||||
def _blank_unicode_identifiers(sql: str) -> str:
|
||||
"""带引号、名字里有非 ASCII 字符的标识符(中文别名 AS "月份"、AS `曝光量`)连引号一起换成空格。
|
||||
|
||||
这项核对只认 ASCII 的列名,中文名本来就不判;不抹掉的话,SQL 里一出现带引号的标识符就整条放弃核对,照「结果列
|
||||
起中文别名」写的 SQL 会把猜错的英文列名一起放过去。英文的带引号标识符("StatDate")大小写、空格都可能,照旧不判。"""
|
||||
from app.data.guard import _GENERIC, _tokens
|
||||
|
||||
out = []
|
||||
for token in _tokens(sql, _GENERIC):
|
||||
if token.kind == "ident" and not token.text.isascii():
|
||||
out.append(" " * len(token.text))
|
||||
else:
|
||||
out.append(token.text)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def sql_unknown_columns(sql: str, source: Any) -> tuple[list[str], list[str]] | None:
|
||||
"""SQL 里用到、数据源结构里查不到的列:(查不到的列, 这几张表的全部列)。判断不了、或者都查得到时返回 None。"""
|
||||
from app.data import introspect as _introspect
|
||||
from app.data.guard import _blank_quoted, first_verb, strip_comments
|
||||
|
||||
cache = getattr(source, "schema_cache", None) or {}
|
||||
if not cache.get("tables") or cache.get("failed") or first_verb(sql) != "select":
|
||||
return None
|
||||
text = _blank_unicode_identifiers(re.sub(r"\{\{.*?\}\}", " 0 ", strip_comments(sql), flags=re.S))
|
||||
if '"' in text.replace("''", "") and _blank_quoted(text) != _blank_quoted(text, identifiers=False):
|
||||
return None # 引号括起来的标识符:大小写、空格都可能,不猜
|
||||
text = _blank_quoted(text)
|
||||
if re.search(r"\bwith\b", text, re.I) or re.search(r"\b(?:from|join)\s*\(", text, re.I) or "`" in text \
|
||||
or "[" in text:
|
||||
return None # [方括号] 标识符、数组下标:同样不猜
|
||||
tables: dict[str, dict[str, Any]] = {} # 表名、别名(小写)→ 表结构
|
||||
spans: list[tuple[int, int]] = []
|
||||
for m in _FROM_ITEMS.finditer(text):
|
||||
spans.append(m.span(1))
|
||||
for item in m.group(1).split(","):
|
||||
parsed = _TABLE_ITEM.match(item)
|
||||
if not parsed:
|
||||
return None
|
||||
name, alias = parsed.group(1), parsed.group(2)
|
||||
meta = _introspect.find_table(source, name)
|
||||
if meta is None:
|
||||
return None # 结构里找不到的表(视图、同义词、截断了的结构)不判
|
||||
tables[name.lower()] = tables[name.rsplit(".", 1)[-1].lower()] = meta
|
||||
if alias and alias.lower() not in _SQL_WORDS:
|
||||
tables[alias.lower()] = meta
|
||||
if not tables:
|
||||
return None
|
||||
known = list(dict.fromkeys(c["name"] for meta in {id(m): m for m in tables.values()}.values()
|
||||
for c in meta.get("columns") or []))
|
||||
columns = {c.lower() for c in known}
|
||||
# 「SELECT created_at FROM」里 created_at 前面是关键字,它是列,不是别名
|
||||
aliases = {a.lower() for a in _ALIAS_AS.findall(text)} | {
|
||||
m.group(2).lower() for m in _ALIAS_BARE.finditer(text) if m.group(1).lower() not in _OPERAND_BEFORE} | {
|
||||
a.lower() for a in _WINDOW_NAME.findall(text)}
|
||||
names = {m.start(1) for pattern in (_NAME_AFTER, _DATEPART_ARG) for m in pattern.finditer(text)}
|
||||
unknown: list[str] = []
|
||||
for m in _QUALIFIED.finditer(text):
|
||||
if any(a <= m.start() < b for a, b in spans):
|
||||
continue
|
||||
meta = tables.get(m.group(1).lower())
|
||||
col = m.group(2)
|
||||
if meta is not None and col != "*" and col.lower() not in {c["name"].lower() for c in meta.get("columns") or []}:
|
||||
unknown.append(f"{m.group(1)}.{col}")
|
||||
for m in _SQL_IDENT.finditer(text):
|
||||
word = m.group(1)
|
||||
low = word.lower()
|
||||
if any(a <= m.start() < b for a, b in spans) or low in _SQL_WORDS or low in columns or low in aliases \
|
||||
or low in tables or m.start() in names or re.match(r"\s*[.(]", text[m.end():]) \
|
||||
or text[m.end():m.end() + 1] == "'": # N'…'、X'…'、_utf8mb4'…':字面量的前缀
|
||||
continue
|
||||
unknown.append(word)
|
||||
unknown = list(dict.fromkeys(unknown))
|
||||
return (unknown, known) if unknown else None
|
||||
|
||||
|
||||
__all__ = ["sql_unknown_columns"]
|
||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, UTCDateTime, new_id, utcnow
|
||||
@@ -134,4 +134,101 @@ class IntrospectTask(Base):
|
||||
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
|
||||
|
||||
|
||||
__all__ = ["IntrospectJob", "IntrospectTask", "SourceObject"]
|
||||
# --------------------------------------------------------------------------
|
||||
# 第 3 波:取值观察、变化记录(docs/design/next-round/03-数据接入与目录维护.md B2、B3)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TableObservation(Base):
|
||||
"""一次取值观察(剖析引擎的四类观察检查,data/catalog_profile_observe.py):码值列的取值分布(codes_observe)、最新业务
|
||||
日期(freshness)、最近 35 天按天的行数(daily_volume)、行数(rows)。
|
||||
|
||||
**为什么单独一张表。** 剖析任务的逐表一行只留最近 50 个任务,而且一张表一次剖析只有一份发现;取值漂移要拿同一口径的
|
||||
历史比(新码值、比例剧变、断档、缺天、量跌、行数跌),得按「表 × 种类 × 列」留一串。每张表每种观察(码值按列)留最近
|
||||
KEEP 条,更早的删掉。
|
||||
|
||||
**口径(basis)。** 每条都记它看的是哪一部分数据:full 整表、first_rows(按存储顺序的前 N 行)、window(业务日期最近 N 天)、
|
||||
sample(块采样 p%)、stats / count(行数的来源)。basis_key 是口径的规范写法:只和 basis_key 相同的历史比,口径不同的照实说
|
||||
不比。遮罩列不出现在任何一条观察查询里,取值也就不会进这张表。
|
||||
|
||||
挂外键、级联删除:观察是数据源的附属记录。"""
|
||||
|
||||
__tablename__ = "table_observations"
|
||||
__table_args__ = (
|
||||
Index("ix_table_observations_lookup", "source_id", "table_name", "kind", "column_name", "at"),
|
||||
)
|
||||
|
||||
#: 每张表每种观察(码值按列)留几条
|
||||
KEEP = 26
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
|
||||
table_name: Mapped[str] = mapped_column(String(255))
|
||||
#: 码值、业务日期观察的那一列;表级的(行数)是空串
|
||||
column_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
#: codes_observe | freshness | daily_volume | rows
|
||||
kind: Mapped[str] = mapped_column(String(20))
|
||||
basis: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
||||
basis_key: Mapped[str] = mapped_column(String(200), default="")
|
||||
#: 观察到的:码值 {values: [[码, 行数]…], rows, truncated};最新业务日期 {max};按日 {since, until, days: {日: 行数}};
|
||||
#: 行数 {rows, at_least, method}
|
||||
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
||||
#: 哪个剖析任务的第几张表测到的
|
||||
job_id: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
task_seq: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
|
||||
|
||||
class SourceChange(Base):
|
||||
"""数据源的一条变化(data/source_changes.py,格式 agentlab-change/1):结构变化(重新探查后的前后差异)和取值变化(取值
|
||||
观察和同口径历史的比较)。全站只有这一份记录:审阅页的「变化」、数据源卡片的角标、外部通知、口径账本的召回都读它。
|
||||
|
||||
- seq:自增,不复用(sqlite_autoincrement),召回按它当游标拉取;id 是给界面、链接用的编号;
|
||||
- kind:结构类 table_added / table_removed / table_left_scope / table_reflect_failed / column_added / column_removed /
|
||||
column_type_changed / column_nullable_changed / key_changed / fk_changed / comment_changed / scope_changed;取值类
|
||||
codes_new / codes_shift / date_stalled / date_gap / volume_drop / rows_drop / rows_jump / coverage_drop;
|
||||
- severity:info / warning / action(现在大概率已经不对了:已确认的项、写死的 SQL 指向被删的列,或者「已列全」被撤销);
|
||||
- detected_by:{job_kind, job_id, task_seq, trigger: manual|schedule|on_demand, actor};basis:{method, query_refs};
|
||||
- before / after:前后各是什么(码值只记非遮罩列的);affects:{catalog: [{table, path, status, source}], templates:
|
||||
[{workflow_id, name, version, impact, nodes}], rules_touched};struct_version_before / after:结构变化前后的结构版本;
|
||||
- fingerprint:去重——同一个变化再次测到只更新 last_seen_at 和 seen_count;
|
||||
- status:open / acknowledged / resolved / dismissed;resolution:{by, at, action, note, catalog_version, job_id}。
|
||||
|
||||
挂外键、级联删除:变化是数据源的附属记录。"""
|
||||
|
||||
__tablename__ = "source_changes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "fingerprint"),
|
||||
Index("ix_source_changes_source_status", "source_id", "status"),
|
||||
Index("ix_source_changes_source_table", "source_id", "table_name"),
|
||||
{"sqlite_autoincrement": True},
|
||||
)
|
||||
|
||||
seq: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
id: Mapped[str] = mapped_column(String(32), unique=True, default=new_id)
|
||||
source_id: Mapped[str] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"))
|
||||
#: 测到时这个源叫什么(之后改了名,这条记录说的仍是当时的名字)
|
||||
source_name: Mapped[str] = mapped_column(String(100), default="")
|
||||
#: 源级的变化(范围规则改了)是空串
|
||||
table_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
column_name: Mapped[str | None] = mapped_column(String(255), default=None)
|
||||
kind: Mapped[str] = mapped_column(String(40))
|
||||
severity: Mapped[str] = mapped_column(String(10), default="info")
|
||||
detected_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
seen_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
detected_by: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
||||
basis: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||||
before: Mapped[Any] = mapped_column(JSON, nullable=True, default=None)
|
||||
after: Mapped[Any] = mapped_column(JSON, nullable=True, default=None)
|
||||
affects: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||||
struct_version_before: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
struct_version_after: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(20), default="open")
|
||||
resolution: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||||
#: 记录的格式(agentlab-change/1):召回按它读,字段以后改要升版本
|
||||
schema_tag: Mapped[str] = mapped_column("schema", String(30), default="agentlab-change/1")
|
||||
|
||||
|
||||
__all__ = ["IntrospectJob", "IntrospectTask", "SourceChange", "SourceObject", "TableObservation"]
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import artifact_store
|
||||
from app.core.events import EventType
|
||||
from app.data.catalog_impact import catalog_drift
|
||||
from app.data.catalog_impact import catalog_drift, schema_drift
|
||||
from app.apps.versions import is_published, subgraph_pin_problems
|
||||
from app.db.models import Run, Workflow, WorkflowVersion
|
||||
from app.engine.governance import governance_note, unresolved_caliber_upgrades
|
||||
@@ -165,9 +165,13 @@ async def launch_run(
|
||||
# 数据目录和口径卡不同:目录是给人和助手看的说明,不是运行的输入。发布之后变了只提醒、不拦——
|
||||
# 拦下来等于让一次文字修订卡住正式出具
|
||||
catalog_changes = await catalog_drift(session, snapshot.catalog_versions)
|
||||
# 表结构同理(next-round 03 B1):发布之后结构版本变了只提醒、不拦——只是加了一列不该卡住正式出具,真删了列查询
|
||||
# 本身会照实报错
|
||||
schema_changes = await schema_drift(session, snapshot.schema_versions)
|
||||
else:
|
||||
upgrade_events = []
|
||||
catalog_changes = []
|
||||
schema_changes = []
|
||||
if workflow_id:
|
||||
workflow = await session.get(Workflow, workflow_id)
|
||||
if not workflow:
|
||||
@@ -197,6 +201,10 @@ async def launch_run(
|
||||
# SQL 用到的,possible Agent 可能查询的(界面分开说)
|
||||
notes.append((EventType.CATALOG_DRIFT, None,
|
||||
{"version": run_version, "count": len(catalog_changes), "tables": catalog_changes}))
|
||||
if schema_changes:
|
||||
# 回看这次正式出具,能知道当时的表结构和发布时不一样:哪几张表、发布时和现在的结构版本、还没处理的变化
|
||||
notes.append((EventType.SCHEMA_DRIFT, None,
|
||||
{"version": run_version, "count": len(schema_changes), "tables": schema_changes}))
|
||||
if capability_changes:
|
||||
# 能力的治理(读写效果、审批、遮罩……)发布之后改过:同目录漂移,只提醒、不拦,之后回看这次运行知道差在哪
|
||||
notes.append(("log", None, {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""表结构变化:前后差异进变化记录、影响面、结构版本与 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
|
||||
@@ -0,0 +1,124 @@
|
||||
"""表结构的前后差异(app/data/schema_diff.py,next-round 03 B1):纯函数。
|
||||
|
||||
守的是:加列、删列、类型、可空、注释(列和表)、主键、唯一约束、外键各出一条;疑似改名只说「可能」(类型和位置都对上、
|
||||
而且只有一对);读不到唯一约束、外键的一边不比;离开范围、读不到结构不算删除;升级后第一次读到(版本 0)只建基线。
|
||||
表、列都是虚构的通用名字。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from app.data import schema_diff as sd
|
||||
|
||||
|
||||
def _meta(**over):
|
||||
meta = {
|
||||
"qualified": "orders", "schema": None, "comment": "订单", "is_view": False,
|
||||
"columns": [
|
||||
{"name": "id", "type": "INTEGER", "nullable": False, "comment": None},
|
||||
{"name": "status", "type": "VARCHAR(10)", "nullable": True, "comment": "状态"},
|
||||
{"name": "amount", "type": "NUMERIC(12, 2)", "nullable": True, "comment": None},
|
||||
{"name": "paid_at", "type": "TIMESTAMP", "nullable": True, "comment": None},
|
||||
],
|
||||
"primary_key": ["id"],
|
||||
"unique": [],
|
||||
"foreign_keys": [{"columns": ["store_id"], "to_table": "stores", "to_columns": ["id"]}],
|
||||
}
|
||||
meta.update(over)
|
||||
return meta
|
||||
|
||||
|
||||
def _kinds(diffs):
|
||||
return [(d.kind, d.column) for d in diffs]
|
||||
|
||||
|
||||
def test_same_structure_has_no_changes():
|
||||
assert sd.diff_table(_meta(), _meta()) == []
|
||||
|
||||
|
||||
def test_added_removed_type_nullable_and_comment_each_get_one_change():
|
||||
before = _meta()
|
||||
after = copy.deepcopy(before)
|
||||
cols = after["columns"]
|
||||
cols[1]["type"] = "VARCHAR(20)" # 类型变长
|
||||
cols[2]["nullable"] = False # 改成非空
|
||||
cols[3]["comment"] = "支付时间" # 补了注释
|
||||
cols.append({"name": "channel", "type": "VARCHAR(8)", "nullable": True, "comment": None})
|
||||
after["columns"] = [c for c in cols if c["name"] != "amount"] + []
|
||||
after["columns"].insert(2, {"name": "amount", "type": "NUMERIC(12, 2)", "nullable": False, "comment": None})
|
||||
after["comment"] = "订单明细"
|
||||
diffs = sd.diff_table(before, after)
|
||||
assert _kinds(diffs) == [
|
||||
("column_added", "channel"),
|
||||
("column_type_changed", "status"), ("column_nullable_changed", "amount"),
|
||||
("comment_changed", "paid_at"), ("comment_changed", None),
|
||||
]
|
||||
by = {(d.kind, d.column): d for d in diffs}
|
||||
assert by[("column_type_changed", "status")].before == "VARCHAR(10)"
|
||||
assert by[("column_type_changed", "status")].after == "VARCHAR(20)"
|
||||
assert by[("column_nullable_changed", "amount")].before is True and by[("column_nullable_changed", "amount")].after is False
|
||||
assert by[("comment_changed", None)].after == "订单明细"
|
||||
|
||||
|
||||
def test_removed_column_is_a_deletion_and_left_scope_is_not():
|
||||
after = _meta(columns=[c for c in _meta()["columns"] if c["name"] != "amount"])
|
||||
[d] = sd.diff_table(_meta(), after)
|
||||
assert d.kind == "column_removed" and d.column == "amount" and d.before["type"] == "NUMERIC(12, 2)"
|
||||
assert d.rename is None, "没有新加的列,谈不上改名"
|
||||
assert sd.is_deletion("column_removed") and sd.is_deletion("table_removed")
|
||||
assert not sd.is_deletion("table_left_scope") and not sd.is_deletion("table_reflect_failed"), "范围外、读不到不算删除"
|
||||
assert d.severity == "warning"
|
||||
|
||||
|
||||
def test_same_type_same_position_is_only_a_possible_rename():
|
||||
after = copy.deepcopy(_meta())
|
||||
after["columns"][1] = {"name": "state", "type": "VARCHAR(10)", "nullable": True, "comment": "状态"}
|
||||
diffs = sd.diff_table(_meta(), after)
|
||||
assert _kinds(diffs) == [("column_removed", "status"), ("column_added", "state")]
|
||||
assert diffs[0].rename == {"from": "status", "to": "state"} == diffs[1].rename
|
||||
assert diffs[0].kind == "column_removed", "改名只说「可能」:照样是一条删列、一条加列"
|
||||
|
||||
|
||||
def test_rename_needs_same_type_and_same_position():
|
||||
moved = copy.deepcopy(_meta())
|
||||
moved["columns"] = [moved["columns"][0], moved["columns"][2], moved["columns"][3],
|
||||
{"name": "state", "type": "VARCHAR(10)", "nullable": True, "comment": None}]
|
||||
diffs = sd.diff_table(_meta(), moved)
|
||||
assert all(d.rename is None for d in diffs), "位置不同:不说改名"
|
||||
retyped = copy.deepcopy(_meta())
|
||||
retyped["columns"][1] = {"name": "state", "type": "INTEGER", "nullable": True, "comment": None}
|
||||
assert all(d.rename is None for d in sd.diff_table(_meta(), retyped)), "类型不同:不说改名"
|
||||
|
||||
|
||||
def test_keys_and_foreign_keys():
|
||||
after = _meta(primary_key=["id", "status"], unique=[["paid_at"]],
|
||||
foreign_keys=[{"columns": ["store_id"], "to_table": "shops", "to_columns": ["id"]}])
|
||||
diffs = sd.diff_table(_meta(), after)
|
||||
assert [(d.kind, d.key, d.column) for d in diffs] == [
|
||||
("key_changed", "primary", None), ("key_changed", "unique", "paid_at"),
|
||||
("fk_changed", None, "store_id"), ("fk_changed", None, "store_id")]
|
||||
fk_gone, fk_new = diffs[2], diffs[3]
|
||||
assert fk_gone.before["to_table"] == "stores" and fk_gone.after is None
|
||||
assert fk_new.after["to_table"] == "shops" and fk_new.before is None
|
||||
|
||||
|
||||
def test_constraints_not_read_on_one_side_are_not_compared():
|
||||
"""反射读不到唯一约束、外键时不写那两个键:不知道 ≠ 删了。"""
|
||||
after = _meta()
|
||||
del after["unique"], after["foreign_keys"]
|
||||
assert sd.diff_table(_meta(), after) == []
|
||||
|
||||
|
||||
def test_baseline_after_upgrade_reports_nothing():
|
||||
"""升级前的老缓存接进来的结构版本是 0:第一次按对象索引读到只建基线,通用写法换成方言完整写法不算类型变了。"""
|
||||
legacy = _meta()
|
||||
assert sd.is_baseline(0, legacy) and sd.is_baseline(None, legacy)
|
||||
assert sd.is_baseline(3, None) and sd.is_baseline(3, {"columns": []}), "上一次的结构不在手上:没法比"
|
||||
assert not sd.is_baseline(1, legacy)
|
||||
|
||||
|
||||
def test_to_dict_keeps_rename_and_key():
|
||||
d = sd.Diff("key_changed", None, before=["id"], after=None, key="primary")
|
||||
assert d.to_dict() == {"kind": "key_changed", "column": None, "before": ["id"], "after": None, "key": "primary"}
|
||||
assert sd.touched_columns([sd.Diff("column_removed", "a"), sd.Diff("column_type_changed", "b"),
|
||||
sd.Diff("column_added", "c")]) == {"removed": {"a"}, "changed": {"b"}}
|
||||
Reference in New Issue
Block a user