mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 23:01:59 +00:00
a137182145
为什么:每月一份的明细导出是最常见的场景,期 4 之前列表只能每期替换;夹在中间的小计会让规则起草失败;
表格对象这个现成的边界信号没用上;CSV 不能走配方。四项都复用现有的执行器、核对、累积、溯源,不另起一套。
- 列表按期累积:ListBlock.period 二选一——{column, check: within_context} 按日期列分期,试运行多一条结构类核对
D(每行的日期都在本期统计期内,空着也不行);{synthesize: 统计期} 每行加「统计期起」「统计期止」两列,列表在
统计期定下来之前就抽完了,这两格先放占位、写库时换成真值。accumulate_blockers 放行绑定了统计期的列表;合计行、
小计行另存的表各期主键分不开,照旧不能累积;同一订单两期都出现由并集的主键核对拒收。U3 按列表的分期方式核对。
说明里「按哪一列筛选」用分期列,加的两列有列说明;空行之后文字行的说法补了并集版本(列表能累积以后要用到)。
- 穿插的小计:ListRows.subtotals {label_column, pick, scope: since_previous | group_column, group_column, keep_as}。
小计行不进数据,每个数字格一条合计格(分段键是块 id + ·小计),核对模块按它所管那组明细的 rowid 区间用 SQL
求和比对(新 T 编号,标题「小计行按各组明细求和」),整行记进排除的行(reason=subtotal),可以另存成
reported_total 表(小计项、分组取值、各数字列);数字格全空报 subtotal_blank(结构类);合计行的公式引用了小计格
时按它管的明细换算。静态校验:标签列、分组列要在列里,按组小计要给分组列,小计和合计的开头词不能互为前缀。
规则起草把第一列同一个合计字样、下面还有明细的行提议成小计(以前记 stray_totals 失败),框选同一条规则。
- 表格对象起草:工作表有表格对象时按它出列表块:表头行、列范围、数据行、汇总行(映射成 total_row,标签取汇总行
第一列的文字)都由表格对象给,锚点仍按表头文字、不记坐标;表名优先用表格对象的名字(含数字的不用)。不调用 AI。
- CSV 走配方:新增 csv_grid,按内容认出 CSV(不是 zip、不是 OLE、开头没有 NUL),编码和分隔符复用 tabular 读 CSV
的那一段,适配成一张名为「CSV」的工作表(SheetScan.path=csv:,WorkbookScan.format=csv);只有写法毫无歧义的
整数、小数转成数,千分位、前导零、日期写法一律留作文字交给配方的规则。坐标沿用「工作表!A1」(偏离细案的
R{行}C{列},理由见 csv_grid 的模块说明)。CSV 不做稀疏判定。导入向导和简单上传的结构提示都放行 CSV。
- 每个列表一道题「每期怎么更新」(q_list_period):每期替换 / 按期累积并按某个日期列核对 / 按期累积并加统计期
起止两列,后两种同时把导入模式改成按期累积、缺统计期的工作表一并配上(问题集合按草稿生成、回答时不变,草稿里
的列表还不符合累积资格,q_mode 不会出现,所以并成一道题)。确认项新增 subtotals:<块>、list_period:<块>。
- 前端配方面板:列表块多「小计行」(标签列、开头词、管哪些明细、分组列、另存)和「按期累积时每一行属于哪一期」;
回执「排除的行」认 subtotal。
验证:新增 tests/test_csv_grid.py、test_recipe_p5.py(执行器、核对、校验、资格、并集物化、起草)、
test_recipe_p5_e2e.py(经接口:三份月度 CSV 累积、部分重叠拒收、同一期替换要确认;带穿插小计的 Excel 起草、
核对、提交)。去掉小计识别、统计期回填、D 核对分别回跑,新测试 10 条失败。旧测试里「夹在中间的小计一律失败」
「列表只能每期替换」的断言按新行为改写。
948 lines
53 KiB
Python
948 lines
53 KiB
Python
"""按期累积(期 3,WP-4):配方演进的分档、累积计划、并集的物化(P3-SPEC 2.4–2.7)。
|
||
|
||
**为什么物化,不用视图。** 对外的表是各期最新分区的并集。视图(final.md R1 的否定结论)没法以 immutable 只读打开、
|
||
没法按文件哈希核对,运行固定的版本也就说不清查的是哪一份数据。所以并集是一个真正的 STRICT + PRIMARY KEY 的
|
||
SQLite 文件,和期 1、期 2 的快照库一样内容寻址、只读、按哈希核对;它也登记成一个构建(table_versions)。
|
||
|
||
**为什么在试运行里物化。** 用户启用前看到的行数、结构核对和说明,就是启用后模型看到的那一份(H2);提交在版本
|
||
存储锁里只做文件 link 和写记录,不在锁里花几秒物化。所以这里的函数都是同步的,调用方放进线程池并占 parse_slot。
|
||
|
||
**确定性。** 写入顺序固定(期按统计期排序,表按列序,行按 rowid),同样的输入物化两次库文件哈希相同。publish_snapshot
|
||
复活已回收的快照时靠这一条:重新物化出的文件哈希必须等于登记值。
|
||
|
||
这里不碰数据库记录、不碰数据源:PartInfo 由 WP-5 组装(统计期取自各期导入清单),计划和报告原样交回给它。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as _dt
|
||
import os
|
||
import secrets
|
||
import sqlite3
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
from urllib.parse import quote
|
||
|
||
from app.data import raw_store
|
||
from app.data.engine import open_checked_sqlite
|
||
from app.data.names import collide_key
|
||
from app.data.recipe_confirm import table_changes
|
||
from app.data.recipe_parsers import hour_range, hour_range_total, match_key, text_label
|
||
from app.data.recipe_types import (
|
||
UNION_VER, AccumulatePlan, ChangeClass, CheckResult, ColumnOut, CrosstabBlock, DerivedSegment,
|
||
DimensionSegment, PartInfo, Recipe, UnionPart, UnionReport, accumulate_blockers, derive_tables,
|
||
list_period_columns,
|
||
)
|
||
from app.data.table_versions import settle_journal, sha_json
|
||
|
||
#: table_changes 报出这些 kind 就是不兼容:单位、类型、常量取值只要和任何一期不同,同一列里就混了两种口径
|
||
#: (P3-SPEC 2.5)。table_removed / column_removed 不在其中:按「当前版本的表结构」另判退役;
|
||
#: placeholder_meaning 属于兼容(占位符本来就存空值,只是说明的写法变了)
|
||
INCOMPATIBLE_KINDS = ("type", "unit", "grain", "table_kind", "const_value", "source", "store")
|
||
|
||
|
||
class UnionError(ValueError):
|
||
"""物化失败。code:part_missing(某一期的构建库不在了)、part_tampered(某一期的库哈希对不上,物化第 0 步)、
|
||
union_failed(目标表结构推不出来、某一期的 rowid 不连续、某一期有并集里没有位置的表或列;U1–U3 不通过时
|
||
materialize_union 不抛它、只返回 ok=False 的报告,要异常的调用方用 require_union_ok)。
|
||
part 是出问题的那一期的序号(从 1 起,按统计期排序),与哪一期无关时为 None。message 给人看。"""
|
||
|
||
def __init__(self, message: str, *, code: str, part: int | None = None) -> None:
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.part = part
|
||
|
||
|
||
# ==========================================================================
|
||
# 小工具
|
||
# ==========================================================================
|
||
|
||
|
||
def _q(name: str) -> str:
|
||
return '"' + str(name).replace('"', '""') + '"'
|
||
|
||
|
||
def _period_text(start: str | None, end: str | None) -> str:
|
||
return f"{start} 至 {end}" if start and end else "统计期未记录"
|
||
|
||
|
||
def _period_key(start: str | None, end: str | None) -> str:
|
||
"""快照清单、报告里一期的写法:「2026-08-01~2026-08-31」。"""
|
||
return f"{start}~{end}"
|
||
|
||
|
||
def _day(text: str) -> _dt.date:
|
||
return _dt.date.fromisoformat(str(text))
|
||
|
||
|
||
def _intersects(a: tuple[str | None, str | None], b: tuple[str | None, str | None]) -> bool:
|
||
"""两个统计期有没有交集(ISO 日期按字符串比较即可)。统计期不全的不算相交:替换模式下的旧一期可能没有统计期。"""
|
||
if not (a[0] and a[1] and b[0] and b[1]):
|
||
return False
|
||
return not (a[1] < b[0] or b[1] < a[0])
|
||
|
||
|
||
def _gaps(periods: list[tuple[str, str]]) -> list[dict[str, str]]:
|
||
"""相邻两期之间的空缺:前一期 end 加一天 < 后一期 start。periods 已按统计期排序。"""
|
||
out: list[dict[str, str]] = []
|
||
for (_s1, e1), (s2, _e2) in zip(periods, periods[1:]):
|
||
first, last = _day(e1) + _dt.timedelta(days=1), _day(s2) - _dt.timedelta(days=1)
|
||
if first <= last:
|
||
out.append({"start": first.isoformat(), "end": last.isoformat()})
|
||
return out
|
||
|
||
|
||
def _dump(recipe: Recipe) -> dict[str, Any]:
|
||
return recipe.model_dump(mode="json")
|
||
|
||
|
||
def overlap_message(period: dict[str, Any], overlaps: Iterable[dict[str, Any]]) -> str:
|
||
"""period_overlap 问题的消息(P3-SPEC 2.3)。WP-5 组装试运行问题时用,免得两边各写一套文字。"""
|
||
others = "、".join(_period_text(o.get("start"), o.get("end")) for o in overlaps)
|
||
return (f"本期统计期 {_period_text(period.get('start'), period.get('end'))} 与已有的 {others} 部分重叠:按期累积要求"
|
||
"各期互不重叠。请检查文件的统计期;如需重新开始累积,可以在数据源卡片的「版本」中启用更早的版本,"
|
||
"或在配方中改为每期替换")
|
||
|
||
|
||
# ==========================================================================
|
||
# 配方演进:兼容、退役、不兼容(P3-SPEC 2.5)
|
||
# ==========================================================================
|
||
|
||
|
||
def _tables_of_parts(parts: list[PartInfo]) -> dict[str, list[ColumnOut]]:
|
||
"""没有当前版本的实际表结构时,退回各期配方推出的表结构的并集(按首次出现的顺序)。"""
|
||
out: dict[str, list[ColumnOut]] = {}
|
||
for part in parts:
|
||
if part.recipe is None:
|
||
continue
|
||
tables, _ = derive_tables(part.recipe)
|
||
for name, cols in tables.items():
|
||
have = out.setdefault(name, [])
|
||
known = {c.name for c in have}
|
||
have.extend(c for c in cols if c.name not in known)
|
||
return out
|
||
|
||
|
||
def _label_canon(seg: Any, label: str) -> str:
|
||
"""维度、派生分段的期望标签 → 解析器的规范写法(执行器存进库里的就是它)。解析不了的退回 match_key。"""
|
||
if isinstance(seg, DerivedSegment):
|
||
parsed = hour_range_total(label)
|
||
return parsed.canonical if parsed is not None else match_key(label)
|
||
if seg.dim.parser == "hour_range":
|
||
parsed = hour_range(label)
|
||
return parsed.canonical if parsed is not None else match_key(label)
|
||
return text_label(label) or match_key(label)
|
||
|
||
|
||
def _dim_segments(recipe: Recipe) -> list[tuple[Any, str, str]]:
|
||
"""配方里写进表的维度、派生分段:(分段, 表, 维度列)。只核对不另存的派生分段不进表,不算。"""
|
||
out: list[tuple[Any, str, str]] = []
|
||
for sheet in recipe.sheets:
|
||
for block in sheet.blocks:
|
||
if not isinstance(block, CrosstabBlock):
|
||
continue
|
||
for seg in block.segments:
|
||
if isinstance(seg, DimensionSegment):
|
||
out.append((seg, seg.table, seg.dim.name))
|
||
elif isinstance(seg, DerivedSegment) and seg.keep_as is not None:
|
||
out.append((seg, seg.keep_as.table, seg.keep_as.dim))
|
||
return out
|
||
|
||
|
||
def _label_sets(parts: list[PartInfo]) -> list[dict[str, Any]]:
|
||
"""各期维度取值的差异(评审一-M2):按分段 id 比较各期配方里 dimension、derived 段的期望标签(规范写法,
|
||
按 match_key 比)。只在含这个分段的各期之间比;集合都相同就不列。
|
||
|
||
missing 是相对全部各期并集少了哪些,extra 是比全部各期都有的(交集)多了哪些:D04 去掉「7-8」以后,
|
||
9 月的 missing 是「7-8」,8 月的 extra 是「7-8」。集合不同不算不兼容(table_changes 为空),但跨期比较会
|
||
悄悄变得不可比,所以记进计划和快照清单,说明加 dim_values_vary。
|
||
"""
|
||
seen: dict[str, dict[str, Any]] = {}
|
||
for part in parts:
|
||
if part.recipe is None:
|
||
continue
|
||
for seg, table, column in _dim_segments(part.recipe):
|
||
entry = seen.setdefault(seg.id, {"table": table, "column": column, "periods": []})
|
||
labels: dict[str, str] = {}
|
||
for label in seg.labels.expect:
|
||
canon = _label_canon(seg, label)
|
||
labels.setdefault(match_key(canon), canon)
|
||
entry["periods"].append((part, labels))
|
||
out: list[dict[str, Any]] = []
|
||
for sid, entry in seen.items():
|
||
periods = entry["periods"]
|
||
if len(periods) < 2:
|
||
continue
|
||
order: dict[str, str] = {}
|
||
for _part, labels in periods:
|
||
for key, canon in labels.items():
|
||
order.setdefault(key, canon)
|
||
common = set.intersection(*(set(labels) for _part, labels in periods))
|
||
if all(set(labels) == set(order) for _part, labels in periods):
|
||
continue
|
||
out.append({
|
||
"table": entry["table"], "column": entry["column"], "segment": sid,
|
||
"periods": [{
|
||
"start": part.start, "end": part.end,
|
||
"missing": [canon for key, canon in order.items() if key not in labels],
|
||
"extra": [canon for key, canon in order.items() if key in labels and key not in common],
|
||
} for part, labels in periods],
|
||
})
|
||
return out
|
||
|
||
|
||
def classify_changes(parts: list[PartInfo], target: Recipe, *,
|
||
current_tables: dict[str, list[ColumnOut]] | None,
|
||
retired_status: dict[str, dict[str, dict]] | None = None) -> ChangeClass:
|
||
"""目标配方相对结果快照各期、当前版本的变化分三档(P3-SPEC 2.5,评审二-M3 后改)。
|
||
|
||
1. **不兼容**:拿目标配方和 parts 里**每一期的配方**分别调 table_changes,出现 INCOMPATIBLE_KINDS 就是不兼容。
|
||
逐期比是因为单位、类型、常量取值只要和任何一期不同,同一列里就混了两种口径。
|
||
2. **新增和退役**:拿目标配方的表结构和**当前版本实际的表结构**(current_tables,含早先已退役、仍保留在并集
|
||
里的列;为 None 时退回各期配方推出的表结构的并集)比。当前有、目标没有的是退役,其中 retired_status
|
||
(当前快照清单的 columns)里早已标为 retired 的记进 retired_existing、不再出确认项,这次才退役的记进
|
||
retired_new。不这样分的话,8 月的配方永远含「分区乙」,以后每个月都会再报一次退役。整张表退役:这张表在
|
||
retired_status 里的每一列都已标 retired,才算早已退役。
|
||
新增的表名、列名和当前版本里退役的名字 collide_key 相同、写法不同(「PM2_5」退役、「pm2_5」新增)时按
|
||
不兼容处理:SQLite 的名字不分大小写,并集建表会报重名。
|
||
|
||
label_sets 只在 parts 之间比:要把本期算进去,调用方把本期也作为一期传进来(plan_accumulate 就这么做)。
|
||
change:有不兼容为 semantic;否则有这次才退役的为 retire;否则配方与各期都相同、也没有新增为 same;其余 compatible。
|
||
"""
|
||
semantic: list[str] = []
|
||
|
||
def _say(text: str) -> None:
|
||
if text not in semantic:
|
||
semantic.append(text)
|
||
|
||
for k, part in enumerate(parts, 1):
|
||
if part.recipe is None:
|
||
_say(f"第 {k} 期({_period_text(part.start, part.end)})没有配方记录,无法比较表结构")
|
||
continue
|
||
for ch in table_changes(part.recipe, target):
|
||
if ch.kind in INCOMPATIBLE_KINDS:
|
||
_say(f"表「{ch.table}」:{ch.message}")
|
||
target_tables, problems = derive_tables(target)
|
||
for p in problems:
|
||
_say(p.message)
|
||
current = current_tables if current_tables is not None else _tables_of_parts(parts)
|
||
status = retired_status or {}
|
||
|
||
added: list[dict[str, Any]] = []
|
||
retired_new: list[dict[str, Any]] = []
|
||
retired_existing: list[dict[str, Any]] = []
|
||
gone_tables: dict[str, str] = {}
|
||
for name, cols in current.items():
|
||
info = status.get(name) or {}
|
||
tgt = target_tables.get(name)
|
||
if tgt is None:
|
||
gone_tables[collide_key(name)] = name
|
||
names = [c.name for c in cols]
|
||
done = bool(names) and all((info.get(c) or {}).get("status") == "retired" for c in names)
|
||
(retired_existing if done else retired_new).append({"table": name, "column": None})
|
||
continue
|
||
keep = {c.name for c in tgt}
|
||
for col in cols:
|
||
if col.name not in keep:
|
||
entry = {"table": name, "column": col.name}
|
||
done = (info.get(col.name) or {}).get("status") == "retired"
|
||
(retired_existing if done else retired_new).append(entry)
|
||
for name, cols in target_tables.items():
|
||
have = current.get(name)
|
||
if have is None:
|
||
added.append({"table": name, "column": None})
|
||
other = gone_tables.get(collide_key(name))
|
||
if other is not None and other != name:
|
||
_say(f"新增的表「{name}」与当前版本里退役的表「{other}」只差大小写或全半角:数据库中的表名不分大小写,"
|
||
"两张表不能并存")
|
||
continue
|
||
names = {c.name for c in have}
|
||
gone = {collide_key(c.name): c.name for c in have if c.name not in {x.name for x in cols}}
|
||
for col in cols:
|
||
if col.name in names:
|
||
continue
|
||
added.append({"table": name, "column": col.name})
|
||
other = gone.get(collide_key(col.name))
|
||
if other is not None and other != col.name:
|
||
_say(f"表「{name}」新增的列「{col.name}」与退役的列「{other}」只差大小写或全半角:数据库中的列名不分"
|
||
"大小写,两列不能并存")
|
||
|
||
label_sets = _label_sets(parts)
|
||
target_dump = _dump(target)
|
||
same = all(p.recipe is not None and _dump(p.recipe) == target_dump for p in parts)
|
||
if semantic:
|
||
change = "semantic"
|
||
elif retired_new:
|
||
change = "retire"
|
||
elif same and not added and not label_sets:
|
||
change = "same"
|
||
else:
|
||
change = "compatible"
|
||
return ChangeClass(change=change, added=added, retired_new=retired_new, retired_existing=retired_existing,
|
||
semantic=semantic, label_sets=label_sets)
|
||
|
||
|
||
# ==========================================================================
|
||
# 累积计划(P3-SPEC 2.4)
|
||
# ==========================================================================
|
||
|
||
|
||
def _part_dict(part: PartInfo, blockers: list[str]) -> dict[str, Any]:
|
||
return {"import_id": part.import_id, "seq": part.seq, "start": part.start, "end": part.end,
|
||
"file_name": part.file_name, "new": False, "rows": dict(part.rows), "blockers": list(blockers)}
|
||
|
||
|
||
def plan_accumulate(*, new_recipe: Recipe, new_period: tuple[str, str], period_source: str,
|
||
staging_kind: str, current_mode: str | None, current_snapshot_kind: str | None,
|
||
parts: list[PartInfo], current_tables: dict[str, list[ColumnOut]] | None,
|
||
retired_status: dict[str, dict[str, dict]] | None = None) -> AccumulatePlan:
|
||
"""这次导入启用后,当前版本由哪几期组成(P3-SPEC 2.4 的判定表)。不物化:union 留给调用方物化后填。
|
||
|
||
- 新配方是每期替换:replace,结果只有本期;当前是累积快照时 mode_switch=accumulate->replace,各期列进 dropped。
|
||
- 新配方是按期累积:
|
||
- 没有当前快照、当前是简单导入、首次导入或从简单导入切换:first;
|
||
- 现行也是累积:先判不兼容(restart 优先于 rejected,评审一-m12)——目标配方对结果快照里留下的任何一期
|
||
不兼容,或者留下的某一期的配方不满足累积资格(parts[].blockers),restart,结果只有本期,此前各期列进
|
||
dropped,部分重叠的各期写进 overlaps 只作提示。否则按统计期判:部分重叠 rejected;起止都相同
|
||
replace_period;不相交 append(早于已有的某一期时 backfill)。修改配方、不换文件(staging_kind=redraft)
|
||
用的就是最晚一期的原件,替换最晚一期(replace_period);
|
||
- 现行是替换(模式切换 replace->accumulate):当前那一期有统计期、满足资格、配方兼容时作为第一期保留
|
||
(append / replace_period);没有统计期或不满足资格时 first(reason 写明),不兼容时 restart。
|
||
|
||
period 除了 start、end 还带 source(period_source:cells / human):补传早期而本期统计期是人工录入时,差异卡的
|
||
period_backfill 要改为需确认(2.3,评审一-m12),WP-3 只拿得到计划,从这里读。
|
||
parts 里本期标 new: true;本期的 file_name、rows 这里不知道(签名里没有),留空,调用方按试运行回执补上。
|
||
结果只剩本期时不比新增、退役(没有早期各期可保留,列就跟着本期走,破坏性变化由期 2 的 breaking 确认项兜住),
|
||
label_sets 也为空(restart 的结果只有本期)。rejected 时 parts 是原样不动的当前各期,label_sets 为空。
|
||
current_tables 为 None 时按当前快照各期的配方推出表结构(含要被替换的那一期),新增、退役照样比得出来;
|
||
早已退役的列只在真实的表结构里有,所以有当前快照时调用方应当给 current_tables。
|
||
"""
|
||
start, end = (new_period or (None, None))[0], (new_period or (None, None))[1]
|
||
period = {"start": start, "end": end, "source": period_source}
|
||
ordered = sorted(parts, key=lambda p: (p.start or "", p.end or "", p.seq or 0))
|
||
blockers: dict[int, list[str]] = {}
|
||
for p in ordered:
|
||
blockers[id(p)] = ([b.message for b in accumulate_blockers(p.recipe)] if p.recipe is not None
|
||
else ["这一期没有配方记录"])
|
||
new_entry = {"import_id": None, "seq": None, "start": start, "end": end, "file_name": "", "new": True,
|
||
"rows": {}, "blockers": []}
|
||
plan = AccumulatePlan(
|
||
mode=new_recipe.mode, action="replace", period=period, parts=[new_entry], replaces=None, dropped=[],
|
||
overlaps=[], gaps=[], backfill=False, change="same", added=[], retired_new=[], retired_existing=[],
|
||
semantic=[], label_sets=[], mode_switch=None, reason=None, union=None,
|
||
)
|
||
|
||
def entry(p: PartInfo) -> dict[str, Any]:
|
||
return _part_dict(p, blockers[id(p)])
|
||
|
||
if new_recipe.mode != "accumulate":
|
||
if current_mode == "accumulate" and current_snapshot_kind == "recipe" and ordered:
|
||
plan.mode_switch = "accumulate->replace"
|
||
plan.dropped = [entry(p) for p in ordered]
|
||
return plan
|
||
|
||
if not start or not end:
|
||
raise ValueError("按期累积需要本期的统计期:统计期解析不出来时先由人录入,再做累积计划")
|
||
new_blockers = [b.message for b in accumulate_blockers(new_recipe)]
|
||
if new_blockers:
|
||
# 静态校验已经拦过(mode=accumulate 时报 accumulate_blockers),走到这里说明配方没过校验就试运行了
|
||
plan.action, plan.reason, plan.parts = "rejected", new_blockers[0], [entry(p) for p in ordered]
|
||
return plan
|
||
if (staging_kind in ("first", "switch") or current_snapshot_kind in (None, "simple") or current_mode is None
|
||
or not ordered):
|
||
plan.action = "first"
|
||
return plan
|
||
|
||
new_part = PartInfo(import_id="", seq=0, start=start, end=end, build_id="", db_path="", db_sha256="",
|
||
manifest_db_sha256=None, recipe_id=None, recipe=new_recipe, recipe_sha256=None,
|
||
raw_state="kept", file_name="", manifest_artifact=None)
|
||
switching = current_mode != "accumulate"
|
||
if switching:
|
||
plan.mode_switch = "replace->accumulate"
|
||
cur = ordered[-1]
|
||
if not (cur.start and cur.end) or blockers[id(cur)]:
|
||
plan.action = "first"
|
||
plan.dropped = [entry(p) for p in ordered]
|
||
if not (cur.start and cur.end):
|
||
plan.reason = "当前版本没有统计期,不能作为第一期:启用后当前版本只含本期"
|
||
else:
|
||
plan.reason = (f"当前版本的配方不满足按期累积的要求({blockers[id(cur)][0]}),不能作为第一期:"
|
||
"启用后当前版本只含本期")
|
||
return plan
|
||
|
||
if staging_kind == "redraft":
|
||
replaced: PartInfo | None = ordered[-1]
|
||
kept = ordered[:-1]
|
||
clash = [p for p in kept if _intersects((p.start, p.end), (start, end))]
|
||
else:
|
||
replaced = next((p for p in ordered if (p.start, p.end) == (start, end)), None)
|
||
kept = [p for p in ordered if p is not replaced]
|
||
clash = [p for p in kept if _intersects((p.start, p.end), (start, end))]
|
||
|
||
# 没给当前版本的实际表结构时,按当前快照各期(含要被替换的那一期,它此刻仍在当前版本里)的配方推。不能让
|
||
# classify_changes 自己退回它的 parts:那里面有本期,本期的列也算成「当前已有」,新增列就永远报不出来
|
||
current = current_tables if current_tables is not None else _tables_of_parts(ordered)
|
||
cc = classify_changes([*kept, new_part], new_recipe, current_tables=current, retired_status=retired_status)
|
||
plan.change, plan.semantic, plan.label_sets = cc.change, cc.semantic, cc.label_sets
|
||
if kept:
|
||
plan.added, plan.retired_new, plan.retired_existing = cc.added, cc.retired_new, cc.retired_existing
|
||
elif cc.change == "retire":
|
||
plan.change = "compatible"
|
||
blocked = [(k, p) for k, p in enumerate(ordered, 1) if p in kept and blockers[id(p)]]
|
||
if cc.semantic or blocked:
|
||
plan.action = "restart"
|
||
plan.dropped = [entry(p) for p in ordered]
|
||
plan.overlaps = [entry(p) for p in clash]
|
||
if cc.semantic:
|
||
plan.reason = "表结构有不兼容的变化:" + ";".join(cc.semantic)
|
||
else:
|
||
k, p = blocked[0]
|
||
plan.reason = (f"第 {k} 期({_period_text(p.start, p.end)})的配方不满足按期累积的要求:"
|
||
f"{blockers[id(p)][0]}")
|
||
# 结果只有本期:没有早期各期可保留,新增、退役、各期维度取值的差异都无从谈起。label_sets 不清的话,
|
||
# 差异卡会说「本期没有「7-8」,此前各期有」,可那些期并不在新版本里
|
||
plan.added, plan.retired_new, plan.retired_existing, plan.label_sets = [], [], [], []
|
||
if plan.change == "retire":
|
||
plan.change = "semantic"
|
||
return plan
|
||
if clash:
|
||
plan.action = "rejected"
|
||
plan.overlaps = [entry(p) for p in clash]
|
||
plan.parts = [entry(p) for p in ordered]
|
||
# 拒收时没有结果版本,本期与各期的取值差异没有落点
|
||
plan.label_sets = []
|
||
return plan
|
||
|
||
result = sorted([*kept, new_part], key=lambda p: (p.start or "", p.end or ""))
|
||
plan.parts = [new_entry if p is new_part else entry(p) for p in result]
|
||
if replaced is not None:
|
||
plan.action, plan.replaces = "replace_period", entry(replaced)
|
||
else:
|
||
plan.action = "append"
|
||
plan.backfill = any((p.start or "") > start for p in kept)
|
||
plan.gaps = _gaps([(p.start, p.end) for p in result if p.start and p.end])
|
||
return plan
|
||
|
||
|
||
# ==========================================================================
|
||
# 并集 id 与物化(P3-SPEC 2.1、2.6)
|
||
# ==========================================================================
|
||
|
||
|
||
def retired_columns(change: Any, current_tables: dict[str, list[ColumnOut]] | None) -> dict[str, list[ColumnOut]]:
|
||
"""把计划(AccumulatePlan)或 classify_changes 的结果里的退役项,换成 materialize_union 的 retired 参数。
|
||
|
||
顺序有讲究(P3-SPEC 2.6「退役列按退役先后追加」):**先 retired_existing,再 retired_new**,各自按
|
||
current_tables 里的列序。当前版本的表结构里,早先退役的列本来就排在目标配方的列之后、按退役先后排着;这次才
|
||
退役的列原是目标配方里的列,排在前面。照 current_tables 的顺序直接拼,新退役的就跑到早先退役的前面去了。
|
||
退役列的顺序决定并集的建表语句,也就决定库文件的哈希:复活时重新物化要得到同一个哈希,同一组输入就必须
|
||
拼出同一个顺序,所以由这里统一拼,调用方不要自己拼。整张表退役(column 为 None)给这张表的全部列。
|
||
"""
|
||
tables = current_tables or {}
|
||
out: dict[str, list[ColumnOut]] = {}
|
||
for entry in [*(getattr(change, "retired_existing", None) or []), *(getattr(change, "retired_new", None) or [])]:
|
||
table, column = entry.get("table"), entry.get("column")
|
||
cols = tables.get(table) or []
|
||
picks = list(cols) if column is None else [c for c in cols if c.name == column]
|
||
have = out.setdefault(table, [])
|
||
known = {c.name for c in have}
|
||
for col in picks:
|
||
if col.name not in known:
|
||
have.append(_copy_col(col))
|
||
known.add(col.name)
|
||
if not have:
|
||
del out[table]
|
||
return out
|
||
|
||
|
||
def retired_layout(retired: dict[str, list[ColumnOut]] | None) -> list[list[Any]]:
|
||
"""退役列的规范写法 [[表, [[列, 类型], …]], …],**保留给定的顺序**(表的先后、列的先后都进库文件的哈希),
|
||
同一张表里重复的列只记第一次,没有列的表不记。并集 id、并集构建的 options 都用它。"""
|
||
out: list[list[Any]] = []
|
||
for table, cols in (retired or {}).items():
|
||
seen: set[str] = set()
|
||
listed: list[list[str]] = []
|
||
for col in cols:
|
||
if col.name in seen:
|
||
continue
|
||
seen.add(col.name)
|
||
listed.append([str(col.name), str(col.type)])
|
||
if listed:
|
||
out.append([str(table), listed])
|
||
return out
|
||
|
||
|
||
def _ordered_parts(parts: list[tuple[str, str, str]]) -> list[list[str]]:
|
||
return sorted(([str(s), str(e), str(b)] for s, e, b in parts), key=lambda x: (x[0], x[1]))
|
||
|
||
|
||
def union_id(source_id: str, parts: list[tuple[str, str, str]], target_recipe_sha: str, *,
|
||
retired: dict[str, list[ColumnOut]] | None = None) -> str:
|
||
"""并集 id = sha_json({source_id, parts: [[起, 止, 构建 id]…] 按统计期排序, 目标配方哈希, union_ver}),
|
||
有退役列时再加 "retired": retired_layout(retired)。
|
||
|
||
只取决于各期构建和并集的表结构,**不含导入记录 id**:同一个 9 月文件换个文件名再传,导入记录是新的,
|
||
并集文件照样复用。
|
||
|
||
**retired 必须和传给 materialize_union 的是同一个**(用 retired_columns 拼)。退役列是有历史的:同样的各期、
|
||
同样的目标配方,在含「分区乙」的历史上移除某一期,并集里留着全为空值的「分区乙」;回滚之后重新导入同样两个
|
||
文件,就没有这一列。id 不含退役列的话,两份结构不同的文件同一个 id,发布时目标已存在、哈希等于登记值就直接
|
||
复用旧文件,用户在试运行里核对的和真正发布的不是同一份(H2)。没有退役列时 id 与不带这个键时相同。
|
||
"""
|
||
payload: dict[str, Any] = {"source_id": source_id, "parts": _ordered_parts(parts),
|
||
"target_recipe_sha256": target_recipe_sha, "union_ver": UNION_VER}
|
||
layout = retired_layout(retired)
|
||
if layout:
|
||
payload["retired"] = layout
|
||
return sha_json(payload)
|
||
|
||
|
||
def union_options(parts: list[tuple[str, str, str]], target_recipe_sha: str, *,
|
||
retired: dict[str, list[ColumnOut]] | None = None) -> dict[str, Any]:
|
||
"""并集构建登记的 options(TableBuild.options,P3-SPEC 2.1):{"kind": "union", "parts", "target_recipe_sha256"},
|
||
有退役列时加 "retired"。与 union_id 的输入一一对应,管理员按 options 就能复核 id 是怎么来的。"""
|
||
options: dict[str, Any] = {"kind": "union", "parts": _ordered_parts(parts),
|
||
"target_recipe_sha256": target_recipe_sha}
|
||
layout = retired_layout(retired)
|
||
if layout:
|
||
options["retired"] = layout
|
||
return options
|
||
|
||
|
||
class _Table:
|
||
"""并集里的一张表:列(目标配方的在前、退役的按给定顺序追加在后)、主键、日期轴列。期 5:列表写入的表按 period
|
||
分期(list_period_columns):按日期列的,U3 核对那一列落在该期统计期内;加统计期起止两列的,核对两列就是该期。"""
|
||
|
||
def __init__(self, name: str, cols: list[ColumnOut], grain: list[str],
|
||
period: tuple[str, ...] | None = None) -> None:
|
||
self.name = name
|
||
self.cols = cols
|
||
self.grain = grain
|
||
self.axis = next((c.name for c in cols if c.role == "axis"), None)
|
||
self.names = [c.name for c in cols]
|
||
self.period = period if period is None or all(c in self.names for c in period[1:]) else None
|
||
if self.axis is None and self.period is not None and self.period[0] == "column":
|
||
self.axis = self.period[1]
|
||
|
||
|
||
def _copy_col(col: ColumnOut) -> ColumnOut:
|
||
return ColumnOut(name=col.name, type=col.type, header=col.header, unit=col.unit, role=col.role)
|
||
|
||
|
||
def _layout(target: Recipe, parts: list[UnionPart],
|
||
retired: dict[str, list[ColumnOut]] | None) -> dict[str, _Table]:
|
||
"""并集的表结构:derive_tables(target) 的列序,加上退役列。退役列、整张退役的表,类型、单位、表头、主键
|
||
取最后一个还含它的那一期的配方(「类型取最后一个含它的那一期」,说明的单位也要用它)。
|
||
|
||
退役列按 retired 给定的顺序追加,整张退役的表按 retired 的顺序排在目标配方的表之后。**这个顺序进建表语句,
|
||
也就进库文件的哈希**:retired 要用 retired_columns 拼(先早先退役的、再这次才退役的),union_id 要带上同一个
|
||
retired,否则同一个并集 id 会对应结构不同的文件,复活时重新物化也对不上登记的哈希(build_conflict)。"""
|
||
tables, problems = derive_tables(target)
|
||
if problems:
|
||
raise UnionError("目标配方推不出表结构:" + ";".join(p.message for p in problems[:3]), code="union_failed")
|
||
specs = {t.name: t for t in target.tables}
|
||
derived = [(derive_tables(p.recipe)[0], {t.name: t for t in p.recipe.tables}) for p in parts]
|
||
|
||
def last_with(table: str, column: str | None) -> tuple[ColumnOut | None, list[str] | None]:
|
||
for cols, pspecs in reversed(derived):
|
||
have = cols.get(table)
|
||
if have is None:
|
||
continue
|
||
grain = list(pspecs[table].grain) if table in pspecs else []
|
||
if column is None:
|
||
return None, grain
|
||
found = next((c for c in have if c.name == column), None)
|
||
if found is not None:
|
||
return found, grain
|
||
return None, None
|
||
|
||
out: dict[str, _Table] = {}
|
||
retired = retired or {}
|
||
periods = list_period_columns(target)
|
||
for name, cols in tables.items():
|
||
mine = [_copy_col(c) for c in cols]
|
||
known = {c.name for c in mine}
|
||
for col in retired.get(name, []):
|
||
if col.name in known:
|
||
continue
|
||
found, _ = last_with(name, col.name)
|
||
mine.append(_copy_col(found) if found is not None else _copy_col(col))
|
||
known.add(col.name)
|
||
spec = specs.get(name)
|
||
out[name] = _Table(name, mine, list(spec.grain) if spec is not None else [], periods.get(name))
|
||
for name, cols in retired.items():
|
||
if name in out:
|
||
continue
|
||
base: list[ColumnOut] = []
|
||
for cols_k, _pspecs in reversed(derived):
|
||
if name in cols_k:
|
||
base = [_copy_col(c) for c in cols_k[name]]
|
||
break
|
||
known = {c.name for c in base}
|
||
for col in cols:
|
||
if col.name not in known:
|
||
found, _ = last_with(name, col.name)
|
||
base.append(_copy_col(found) if found is not None else _copy_col(col))
|
||
known.add(col.name)
|
||
_, grain = last_with(name, None)
|
||
out[name] = _Table(name, base, [g for g in grain or [] if g in known])
|
||
return out
|
||
|
||
|
||
def _create(conn: sqlite3.Connection, table: _Table) -> None:
|
||
"""建表,写法与执行器相同(recipe_engine._create_tables):STRICT;主键列 NOT NULL;单列 INTEGER 主键写成
|
||
列约束的 PRIMARY KEY DESC,不让它成为 rowid 的别名(rowid 必须是插入序号,期 4 按 rowid 区间溯源)。"""
|
||
types = {c.name: c.type for c in table.cols}
|
||
alias = len(table.grain) == 1 and types.get(table.grain[0]) == "INTEGER"
|
||
defs = []
|
||
for col in table.cols:
|
||
d = f"{_q(col.name)} {col.type}"
|
||
if alias and col.name == table.grain[0]:
|
||
d += " PRIMARY KEY DESC NOT NULL"
|
||
elif col.name in table.grain:
|
||
d += " NOT NULL"
|
||
defs.append(d)
|
||
if table.grain and not alias:
|
||
defs.append(f"PRIMARY KEY ({', '.join(_q(g) for g in table.grain)})")
|
||
conn.execute(f"CREATE TABLE {_q(table.name)} ({', '.join(defs)}) STRICT")
|
||
|
||
|
||
def _check(cid: str, kind: str, title: str, *, checked: int, failed: list[str], sql: str | None,
|
||
params: list[Any] | None = None) -> CheckResult:
|
||
return CheckResult(id=cid, kind=kind, title=title, status="mismatch" if failed else "passed", # type: ignore[arg-type]
|
||
category="structure", checked=checked, failed=len(failed), sql=sql,
|
||
params=list(params or []), details=failed[:20])
|
||
|
||
|
||
def materialize_union(out: Path, *, target: Recipe, parts: list[UnionPart],
|
||
retired: dict[str, list[ColumnOut]] | None = None) -> UnionReport:
|
||
"""按目标配方的表结构,把各期构建库物化成一个并集库(P3-SPEC 2.6)。同步,调用方放进线程池并占 parse_slot。
|
||
|
||
parts 已按统计期排序,可以只有一期(移除、撤销之后剩一期而那一期的配方不是目标配方,7.4)。retired 是退役列:
|
||
表 → 列,整张退役的表给全部的列,**用 retired_columns(计划, current_tables) 拼**(先 retired_existing、再
|
||
retired_new,顺序进库哈希),并且原样传给 union_id、union_options。
|
||
|
||
0. 逐期算构建库文件的 sha256,和 UnionPart.db_sha256(TableBuild 登记的)、manifest_db_sha256(导入清单里的)
|
||
都比对,不一致抛 UnionError(part_tampered);文件不在抛 part_missing。各期以 immutable 方式附加,SQLite 不会
|
||
再校验,被改过的库附加进来就被「洗白」成新版本了,所以必须在附加之前算(评审一-M4)。
|
||
1. 先写 <out>.tmp-<随机>(open_checked_sqlite,关 DQS,回滚日志、不开 WAL),成功后 os.replace 到 out。
|
||
2. 建表:目标配方的列在前、退役列追加在后;grain 非空时加 PRIMARY KEY,主键列 NOT NULL。
|
||
3. 逐期附加(mode=ro&immutable=1)、断言该期每张表 rowid 从 1 连续(不成立抛 union_failed:期 4 按偏移把并集
|
||
rowid 换算回该期构建库,依赖这一条)、INSERT … SELECT … ORDER BY rowid(只选这一期有的列)、每期一个事务、
|
||
提交后 DETACH(SQLite 不允许在事务里 DETACH;逐期附加也绕开了最多附加 10 个库的上限)。
|
||
4. 记下每期每张表的 rowid 区间(并集里的、该期构建库里的)。
|
||
5. 结构核对 U1 行数、U2 主键(写入时撞上主键约束也记成 U2 不通过)、U3 日期落在该期统计期内。
|
||
6. table_hashes 按 union/1 的组合定义(目标表结构加各期登记的表哈希),不逐行重算;空值数按表、按期分开记
|
||
(按期的只记该期构建库里有的列:不含这一列的期是结构性空值,说明不能说成「原表为空格」)。
|
||
7. 关连接、settle_journal、算 db_sha256、os.replace。
|
||
|
||
**U1–U3 有不通过的不抛异常**:不产出文件(临时文件删掉,out 上旧的同名文件也删掉,免得被当成这次的结果发布),
|
||
返回 ok=False、db_sha256 为空的报告。试运行要把核对结果(哪一期哪张表几行)给人看,所以看 report.ok 判
|
||
rejected;版本页的写操作(移除、撤销)没有地方展示核对结果,调 require_union_ok(report) 换成
|
||
UnionError(union_failed),接口层映射成 409 union_failed(7.4)。漏检也不会静默发布:没有文件,提交时是
|
||
trial_missing。其余失败(part_missing、part_tampered、rowid 不连续等)抛 UnionError。
|
||
"""
|
||
out = Path(out)
|
||
if not parts:
|
||
raise ValueError("物化至少要有一期")
|
||
keys = [(p.start, p.end) for p in parts]
|
||
if keys != sorted(keys):
|
||
raise ValueError("parts 必须按统计期排序")
|
||
for k, part in enumerate(parts, 1):
|
||
path = Path(part.db_path)
|
||
label = f"第 {k} 期({_period_text(part.start, part.end)})"
|
||
if not path.is_file():
|
||
# 出路照 7.4 写:丢了文件的那一期移出去,或回到更早的版本。接口层直接转发这句
|
||
raise UnionError(f"{label}的数据文件已丢失,无法重建当前版本。可以先移除这一期,"
|
||
"或在数据源卡片的「版本」中启用更早的版本", code="part_missing", part=k)
|
||
actual = raw_store.sha256_file(path)
|
||
expected = {str(part.db_sha256 or "").strip()}
|
||
if part.manifest_db_sha256 is not None:
|
||
expected.add(str(part.manifest_db_sha256).strip())
|
||
if expected != {actual}:
|
||
raise UnionError(f"{label}的数据文件与登记的不一致,可能被修改过", code="part_tampered", part=k)
|
||
layout = _layout(target, parts, retired)
|
||
targets = {name: {c.name for c in cols} for name, cols in derive_tables(target)[0].items()}
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = out.parent / f"{out.name}{raw_store.TMP_MARK}{secrets.token_hex(6)}"
|
||
try:
|
||
report = _materialize(tmp, layout, parts, targets)
|
||
if not report.ok:
|
||
_remove_db_files(tmp)
|
||
_remove_db_files(out)
|
||
return report
|
||
settle_journal(tmp)
|
||
report.db_sha256 = raw_store.sha256_file(tmp)
|
||
os.replace(tmp, out)
|
||
return report
|
||
except BaseException:
|
||
_remove_db_files(tmp)
|
||
raise
|
||
|
||
|
||
def require_union_ok(report: UnionReport) -> None:
|
||
"""U1–U3 有不通过的就抛 UnionError(union_failed),消息写出不通过的细节(前三条)。
|
||
|
||
materialize_union 遇到 U1–U3 不通过只返回 ok=False 的报告(试运行要展示核对结果);版本页的移除、撤销没有地方
|
||
展示,用它把报告换成异常,接口层照 7.4 回 409 union_failed。"""
|
||
if report.ok:
|
||
return
|
||
details = [d for c in report.checks if c.status != "passed" for d in (c.details or [c.title])]
|
||
raise UnionError("累积后的数据没有通过结构核对,当前版本未受影响:" + ";".join(details[:3]), code="union_failed")
|
||
|
||
|
||
def _remove_db_files(path: Path) -> None:
|
||
for suffix in ("", "-journal", "-wal", "-shm"):
|
||
Path(f"{path}{suffix}").unlink(missing_ok=True)
|
||
|
||
|
||
def _materialize(tmp: Path, layout: dict[str, _Table], parts: list[UnionPart],
|
||
targets: dict[str, set[str]]) -> UnionReport:
|
||
conn = open_checked_sqlite(str(tmp), readonly=False)
|
||
conn.isolation_level = None
|
||
part_rows: list[dict[str, dict[str, list[int]]]] = []
|
||
part_cols: list[dict[str, list[str]]] = []
|
||
part_counts: list[dict[str, int]] = []
|
||
pk_failures: list[str] = []
|
||
#: 撞上主键约束、整期回滚的那几期(下标从 0 起)
|
||
rolled_back: set[int] = set()
|
||
try:
|
||
conn.execute("BEGIN")
|
||
for table in layout.values():
|
||
_create(conn, table)
|
||
conn.execute("COMMIT")
|
||
for k, part in enumerate(parts, 1):
|
||
label = f"第 {k} 期({_period_text(part.start, part.end)})"
|
||
conn.execute("ATTACH DATABASE ? AS part", (f"file:{quote(os.fspath(part.db_path))}?mode=ro&immutable=1",))
|
||
ranges: dict[str, dict[str, list[int]]] = {}
|
||
cols_k: dict[str, list[str]] = {}
|
||
counts: dict[str, int] = {}
|
||
try:
|
||
present = [r[0] for r in conn.execute(
|
||
"SELECT name FROM part.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
|
||
stray = [t for t in present if t not in layout]
|
||
if stray:
|
||
raise UnionError(f"{label}有表" + "、".join(f"「{t}」" for t in stray)
|
||
+ "在累积后的表结构里没有位置", code="union_failed", part=k)
|
||
conn.execute("BEGIN")
|
||
try:
|
||
for name, table in layout.items():
|
||
if name not in present:
|
||
continue
|
||
have = [r[1] for r in conn.execute(f"PRAGMA part.table_info({_q(name)})")]
|
||
extra = [c for c in have if c not in table.names]
|
||
if extra:
|
||
raise UnionError(f"{label}的「{name}」有列" + "、".join(f"「{c}」" for c in extra)
|
||
+ "在累积后的表结构里没有位置", code="union_failed", part=k)
|
||
lo, hi, n = conn.execute(f"SELECT MIN(rowid), MAX(rowid), COUNT(*) FROM part.{_q(name)}").fetchone()
|
||
if n and (lo != 1 or hi != n):
|
||
raise UnionError(f"{label}的「{name}」行号不连续({lo} 至 {hi},共 {n} 行),无法按行号溯源",
|
||
code="union_failed", part=k)
|
||
cols = [c for c in table.names if c in have]
|
||
before = conn.execute(f"SELECT COALESCE(MAX(rowid), 0) FROM main.{_q(name)}").fetchone()[0]
|
||
listed = ", ".join(_q(c) for c in cols)
|
||
conn.execute(f"INSERT INTO main.{_q(name)} ({listed}) SELECT {listed} FROM part.{_q(name)} "
|
||
"ORDER BY rowid")
|
||
after = conn.execute(f"SELECT COALESCE(MAX(rowid), 0) FROM main.{_q(name)}").fetchone()[0]
|
||
if after - before != n:
|
||
raise UnionError(f"{label}的「{name}」写入了 {after - before} 行,应为 {n} 行",
|
||
code="union_failed", part=k)
|
||
ranges[name] = {"union": [before + 1, after], "part": [1, n]}
|
||
cols_k[name] = cols
|
||
counts[name] = n
|
||
conn.execute("COMMIT")
|
||
except sqlite3.IntegrityError as e:
|
||
conn.execute("ROLLBACK")
|
||
pk_failures.append(_pk_conflict(conn, layout, parts, part_rows, k, e))
|
||
ranges, cols_k, counts = {}, {}, {}
|
||
rolled_back.add(k - 1)
|
||
except BaseException:
|
||
if conn.in_transaction:
|
||
conn.execute("ROLLBACK")
|
||
raise
|
||
finally:
|
||
conn.execute("DETACH DATABASE part")
|
||
part_rows.append(ranges)
|
||
part_cols.append(cols_k)
|
||
part_counts.append(counts)
|
||
return _finish(conn, layout, parts, part_rows, part_cols, part_counts, pk_failures, rolled_back, targets)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _pk_conflict(conn: sqlite3.Connection, layout: dict[str, _Table], parts: list[UnionPart],
|
||
part_rows: list[dict[str, dict[str, list[int]]]], k: int, error: Exception) -> str:
|
||
"""第 k 期写入时撞上了主键约束:找出和哪一期、在哪个主键上重复(库已回滚到第 k 期之前,part 还附加着)。"""
|
||
label = f"第 {k} 期({_period_text(parts[k - 1].start, parts[k - 1].end)})"
|
||
for name, table in layout.items():
|
||
if not table.grain:
|
||
continue
|
||
try:
|
||
listed = ", ".join(_q(g) for g in table.grain)
|
||
nulls = conn.execute(f"SELECT COUNT(*) FROM part.{_q(name)} WHERE "
|
||
+ " OR ".join(f"{_q(g)} IS NULL" for g in table.grain)).fetchone()[0]
|
||
if nulls:
|
||
return f"{label}的「{name}」有 {nulls} 行主键列为空值"
|
||
dup = conn.execute(f"SELECT {listed} FROM part.{_q(name)} GROUP BY {listed} HAVING COUNT(*) > 1 LIMIT 1"
|
||
).fetchone()
|
||
if dup is not None:
|
||
key = ",".join(f"{g}={v}" for g, v in zip(table.grain, dup))
|
||
return f"{label}的「{name}」自身在主键({key})上重复"
|
||
hit = conn.execute(f"SELECT m.rowid, {', '.join('m.' + _q(g) for g in table.grain)} FROM main.{_q(name)} m "
|
||
f"JOIN part.{_q(name)} p USING ({listed}) LIMIT 1").fetchone()
|
||
except sqlite3.Error:
|
||
continue
|
||
if hit is None:
|
||
continue
|
||
rowid, values = hit[0], hit[1:]
|
||
other = next((j for j, ranges in enumerate(part_rows, 1)
|
||
if name in ranges and ranges[name]["union"][0] <= rowid <= ranges[name]["union"][1]), None)
|
||
key = ",".join(f"{g}={v}" for g, v in zip(table.grain, values))
|
||
who = f"第 {other} 期" if other is not None else "此前的某一期"
|
||
return f"{who}与第 {k} 期在「{name}」的主键({key})上重复"
|
||
return f"{label}写入时违反了主键约束({error})"
|
||
|
||
|
||
def _finish(conn: sqlite3.Connection, layout: dict[str, _Table], parts: list[UnionPart],
|
||
part_rows: list[dict[str, dict[str, list[int]]]], part_cols: list[dict[str, list[str]]],
|
||
part_counts: list[dict[str, int]], pk_failures: list[str], rolled_back: set[int],
|
||
targets: dict[str, set[str]]) -> UnionReport:
|
||
rows: dict[str, int] = {}
|
||
null_counts: dict[str, dict[str, int]] = {}
|
||
for name, table in layout.items():
|
||
exprs = ", ".join(f"COUNT({_q(c)})" for c in table.names)
|
||
got = conn.execute(f"SELECT COUNT(*), {exprs} FROM {_q(name)}").fetchone()
|
||
rows[name] = got[0]
|
||
null_counts[name] = {c: got[0] - n for c, n in zip(table.names, got[1:])}
|
||
|
||
# U1 行数:每张表的并集行数 = 各期该表行数之和
|
||
u1: list[str] = []
|
||
for name in layout:
|
||
want = sum(c.get(name, 0) for c in part_counts)
|
||
# 撞了主键而整期回滚的那几期没有计入 part_counts:按它们在构建库里的行数补上,U1 才会如实报出少了的行
|
||
want += sum(_part_count(parts[k], name) for k in sorted(rolled_back))
|
||
if rows[name] != want:
|
||
u1.append(f"表「{name}」累积后 {rows[name]} 行,各期之和 {want} 行")
|
||
first = next(iter(layout), None)
|
||
checks = [_check("U1", "union_rows", "累积后每张表的行数等于各期行数之和", checked=len(layout), failed=u1,
|
||
sql=f"SELECT COUNT(*) FROM {_q(first)}" if first else None)]
|
||
|
||
# U2 主键:按目标 grain 分组没有重复;写入时撞上主键约束的也记在这里
|
||
u2 = list(pk_failures)
|
||
keyed = [t for t in layout.values() if t.grain]
|
||
sql2 = None
|
||
for table in keyed:
|
||
listed = ", ".join(_q(g) for g in table.grain)
|
||
sql2 = f"SELECT COUNT(*) FROM (SELECT 1 FROM {_q(table.name)} GROUP BY {listed} HAVING COUNT(*) > 1)"
|
||
dup = conn.execute(sql2).fetchone()[0]
|
||
if dup:
|
||
u2.append(f"表「{table.name}」有 {dup} 组主键重复")
|
||
checks.append(_check("U2", "union_pk", "累积后主键不重复", checked=len(keyed), failed=u2, sql=sql2))
|
||
|
||
# U3 统计期:第 k 期的 rowid 区间里,日期都在 [start_k, end_k] 之内。期 5:按日期列分期的列表,日期为空也算不在;
|
||
# 加统计期起止两列的列表,两列必须正好是这一期
|
||
u3: list[str] = []
|
||
sql3, params3, checked3 = None, [], 0
|
||
for k, (part, ranges) in enumerate(zip(parts, part_rows), 1):
|
||
for name, span in ranges.items():
|
||
table = layout[name]
|
||
have = part_cols[k - 1].get(name, [])
|
||
a, b = span["union"]
|
||
if table.period is not None and table.period[0] == "synthesize":
|
||
start_col, end_col = table.period[1], table.period[2]
|
||
if start_col not in have or end_col not in have or b < a:
|
||
continue
|
||
checked3 += 1
|
||
sql3 = (f"SELECT COUNT(*) FROM {_q(name)} WHERE rowid BETWEEN ? AND ? "
|
||
f"AND ({_q(start_col)} IS NOT ? OR {_q(end_col)} IS NOT ?)")
|
||
params3 = [a, b, part.start, part.end]
|
||
bad = conn.execute(sql3, params3).fetchone()[0]
|
||
if bad:
|
||
u3.append(f"第 {k} 期({_period_text(part.start, part.end)})的「{name}」有 {bad} 行的统计期不是这一期")
|
||
continue
|
||
axis = table.axis
|
||
if axis is None or axis not in have:
|
||
continue
|
||
if b < a:
|
||
continue
|
||
checked3 += 1
|
||
nulls = f"{_q(axis)} IS NULL OR " if table.period is not None else ""
|
||
sql3 = (f"SELECT COUNT(*) FROM {_q(name)} WHERE rowid BETWEEN ? AND ? "
|
||
f"AND ({nulls}{_q(axis)} < ? OR {_q(axis)} > ?)")
|
||
params3 = [a, b, part.start, part.end]
|
||
bad = conn.execute(sql3, params3).fetchone()[0]
|
||
if bad:
|
||
u3.append(f"第 {k} 期({_period_text(part.start, part.end)})的「{name}」有 {bad} 行日期不在该期统计期内")
|
||
checks.append(_check("U3", "union_period", "各期的日期都在该期的统计期内", checked=checked3, failed=u3,
|
||
sql=sql3, params=params3))
|
||
|
||
part_nulls: list[dict[str, dict[str, int]]] = []
|
||
for ranges, cols_k in zip(part_rows, part_cols):
|
||
mine: dict[str, dict[str, int]] = {}
|
||
for name, span in ranges.items():
|
||
cols = cols_k.get(name, [])
|
||
a, b = span["union"]
|
||
if not cols:
|
||
continue
|
||
if b < a:
|
||
mine[name] = {c: 0 for c in cols}
|
||
continue
|
||
got = conn.execute(f"SELECT {', '.join(f'COUNT(*) - COUNT({_q(c)})' for c in cols)} FROM {_q(name)} "
|
||
"WHERE rowid BETWEEN ? AND ?", (a, b)).fetchone()
|
||
mine[name] = dict(zip(cols, got))
|
||
part_nulls.append(mine)
|
||
|
||
table_hashes: dict[str, str] = {}
|
||
for name, table in layout.items():
|
||
hashes: list[str | None] = []
|
||
for k, (part, ranges) in enumerate(zip(parts, part_rows), 1):
|
||
if name not in ranges:
|
||
hashes.append(None)
|
||
continue
|
||
got = part.table_hashes.get(name)
|
||
if not got:
|
||
raise UnionError(f"第 {k} 期({_period_text(part.start, part.end)})缺少表「{name}」的表哈希登记",
|
||
code="union_failed", part=k)
|
||
hashes.append(got)
|
||
table_hashes[name] = sha_json({"union_ver": UNION_VER, "columns": [[c.name, c.type] for c in table.cols],
|
||
"grain": table.grain, "parts": hashes})
|
||
|
||
added: list[dict[str, Any]] = []
|
||
retired: list[dict[str, Any]] = []
|
||
for name, table in layout.items():
|
||
with_table = [_period_key(p.start, p.end) for p, ranges in zip(parts, part_rows) if name in ranges]
|
||
if name not in targets:
|
||
retired.append({"table": name, "column": None, "periods": with_table})
|
||
continue
|
||
if len(with_table) < len(parts):
|
||
added.append({"table": name, "column": None, "periods": with_table})
|
||
for col in table.cols:
|
||
having = [_period_key(p.start, p.end) for p, cols_k in zip(parts, part_cols)
|
||
if col.name in cols_k.get(name, [])]
|
||
if col.name not in targets[name]:
|
||
retired.append({"table": name, "column": col.name, "periods": having})
|
||
elif len(having) < len(with_table):
|
||
added.append({"table": name, "column": col.name, "periods": having})
|
||
|
||
ok = not (u1 or u2 or u3)
|
||
return UnionReport(
|
||
tables={name: table.cols for name, table in layout.items()},
|
||
grains={name: table.grain for name, table in layout.items()},
|
||
rows=rows, part_rows=part_rows, table_hashes=table_hashes, null_counts=null_counts,
|
||
part_null_counts=part_nulls, added=added, retired=retired, checks=checks, ok=ok, db_sha256="",
|
||
)
|
||
|
||
|
||
def _part_count(part: UnionPart, name: str) -> int:
|
||
"""某一期构建库里一张表的行数(只读打开)。表不在时为 0。"""
|
||
try:
|
||
conn = open_checked_sqlite(part.db_path, readonly=True)
|
||
except sqlite3.Error:
|
||
return 0
|
||
try:
|
||
return int(conn.execute(f"SELECT COUNT(*) FROM {_q(name)}").fetchone()[0])
|
||
except sqlite3.Error:
|
||
return 0
|
||
finally:
|
||
conn.close()
|
||
|