Files
AgentLab/backend/tests/test_table_versions.py
ThearchyHelios 2cdbf9a733 Excel 导入改造期 3:网格交互、自助修复、按期累积——每月的报表可以累积成一份跨月的表,漂移了点修复按钮就能接着导入,网格上框一块就能写进配方,版本页可以回滚、移除一期、清除原件
按期累积(新 recipe_accumulate;table_versions 扩展)
- 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图
- 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对
- 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」
- 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用
- 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查

修复按钮(新 recipe_fixes;执行器给问题带结构化参数)
- 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名
- 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单
- 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对

框选转锚点(新 recipe_select)
- 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因
- 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理

确认与对照(recipe_confirm、recipe_diff、新 recipe_compare)
- 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方
- 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答
- 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行

版本页(新 source_versions、api/source_versions;前端 VersionsDialog)
- 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期
- 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认

期 1、期 2 遗留
- 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500
- 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id
- 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
2026-10-02 07:38:44 +08:00

1012 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""上传表格的版本底座:构建、导入记录、快照,发布、迁移、回收、原件存档。
要守住的几件事:
- 发布失败(解析拒收、文件对不上、写记录出错)时,指针不动、旧版本完整可查、不留新文件;
- 同一个源重传同一个文件是同一个构建、新的导入记录;两个源传同一个文件是两个构建;
- 运行钉住的快照不被回收,钉住的快照没了就明确报错,绝不退回当前版本;
- 原件按哈希只存一份、只读、没有下载口子,清除后记录还在。
夹具一律现造(openpyxl),标签用假名。解析器(tabular.load_into)的新接口由 WP-B 实现:
它还没合进来时,用这里的最小替身(只认简单的一行表头 + 数据);合进来以后自动换成真的。
"""
from __future__ import annotations
import hashlib
import inspect
import io
import os
import sqlite3
import stat
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import func, select
from app.core.config import settings
from app.data import raw_store, table_versions, tabular
from app.data.engine import engines, run_query
from app.data.table_versions import SnapshotMissing
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, SourceSnapshot, TableBuild, TableImport
# --------------------------------------------------------------------------
# 解析器替身(WP-B 合并前)
# --------------------------------------------------------------------------
REAL_PARSER = "raw_mode" in inspect.signature(tabular.load_into).parameters
@dataclass
class StubTable:
name: str
sheet: str
columns: list[dict[str, Any]]
rows: int
region: str
blank_rows_skipped: int = 0
columns_trimmed: list[str] = field(default_factory=list)
unshaped: bool = False
@dataclass
class StubReport:
tables: list[StubTable] = field(default_factory=list)
skipped_sheets: list[dict[str, Any]] = field(default_factory=list)
conversions: list[dict[str, Any]] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
class StubNeedsDecision(tabular.UnsupportedTable):
"""tabular.NeedsDecision 的形状:UnsupportedTable 的子类,带 kind 和 details。"""
def __init__(self, message: str, *, kind: str, details: dict[str, Any]) -> None:
super().__init__(message)
self.kind = kind
self.details = details
def stub_load_into(db_path: str, raw: bytes, filename: str, *, header_row: int = 1,
mixed: str = "reject", raw_mode: bool = False, scan: Any = None) -> StubReport:
"""一行表头 + 数据的最小解析:整列是整数就 INTEGER,否则 TEXT;STRICT 表。"""
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
if not filename.lower().endswith((".xlsx", ".xlsm")):
raise tabular.UnsupportedTable(f"无法读取 {filename}:仅支持 Excel(.xlsx)")
try:
book = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
except Exception as e: # noqa: BLE001
raise tabular.UnsupportedTable("无法打开该 Excel 文件") from e
report = StubReport()
conn = sqlite3.connect(db_path)
try:
for ws in book.worksheets:
rows = [list(r) for r in ws.iter_rows(min_row=header_row, values_only=True)]
if not rows:
continue
headers = [str(h) for h in rows[0]]
body = rows[1:]
types = []
for i in range(len(headers)):
vals = [r[i] for r in body if r[i] is not None]
types.append("INTEGER" if vals and all(isinstance(v, int) for v in vals) else "TEXT")
cols = ", ".join(f'"{h}" {t}' for h, t in zip(headers, types))
conn.execute(f'CREATE TABLE "{ws.title}" ({cols}) STRICT')
conn.executemany(
f'INSERT INTO "{ws.title}" VALUES ({", ".join("?" * len(headers))})',
[[None if v is None else (v if t == "INTEGER" else str(v)) for v, t in zip(r, types)]
for r in body],
)
report.tables.append(StubTable(
name=ws.title, sheet=ws.title,
columns=[{"name": h, "type": t, "header": h} for h, t in zip(headers, types)],
rows=len(body),
region=f"A{header_row}:{get_column_letter(len(headers))}{header_row + len(body)}",
unshaped=raw_mode,
))
conn.commit()
finally:
conn.close()
book.close()
return report
@pytest.fixture(autouse=True)
def parser(monkeypatch):
if not REAL_PARSER:
monkeypatch.setattr(tabular, "load_into", stub_load_into)
@pytest.fixture(autouse=True)
def store(monkeypatch, tmp_path):
"""每个测试一个独立的数据目录:上传目录、原件存档都在里面,回收和启动清理只看得到这里。"""
data = tmp_path / "data"
(data / "uploads").mkdir(parents=True)
monkeypatch.setattr(settings, "data_dir", data)
yield data
# 测试目录随后会被删,权限 0444 的文件挡不住删除(目录可写即可)
# --------------------------------------------------------------------------
# 工具
# --------------------------------------------------------------------------
def xlsx(sheets: dict[str, list[list]]) -> bytes:
from openpyxl import Workbook
book = Workbook()
book.remove(book.active)
for title, rows in sheets.items():
sheet = book.create_sheet(title)
for row in rows:
sheet.append(row)
buf = io.BytesIO()
book.save(buf)
return buf.getvalue()
V1 = {"甲表": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]],
"乙表": [["时段", "客流"], ["早", 10], ["晚", 20]]}
V2 = {"甲表": [["区域", "数量"], ["分区甲", 100]],
"乙表": [["时段", "客流"], ["早", 1000]]}
def unique(prefix: str = "wpc") -> str:
return f"{prefix}_{uuid.uuid4().hex[:10]}"
def fresh(sheets: dict) -> dict:
"""内容独一份的工作簿。原件按内容共用:别的测试传过同样字节的文件,原件就不会随回收删除。"""
return {**sheets, "丙表": [["标记"], [uuid.uuid4().hex]]}
async def publish(name: str, sheets: dict | None = None, *, raw: bytes | None = None,
filename: str = "客流.xlsx", **kw: Any):
"""建(或更新)一个上传源并发布。返回 (源 id, ImportResult)。"""
raw = raw if raw is not None else xlsx(sheets or V1)
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
result = await table_versions.publish_upload(session, row, raw, filename, **kw)
return row.id, result
async def load(source_id: str) -> DataSource:
async with SessionLocal() as session:
return await session.get(DataSource, source_id)
async def resolve(source_id: str, pinned: str | None = None):
async with SessionLocal() as session:
row = await session.get(DataSource, source_id)
return await table_versions.resolve_source(session, row, pinned)
async def scalar(source, sql: str):
try:
return (await run_query(source, sql)).rows[0][0]
finally:
await engines.invalidate(source.id)
async def imports_of(source_id: str) -> list[TableImport]:
async with SessionLocal() as session:
return list((await session.execute(
select(TableImport).where(TableImport.source_id == source_id).order_by(TableImport.seq)
)).scalars())
async def get(model, key):
async with SessionLocal() as session:
return await session.get(model, key)
def files_under(root: Path) -> list[Path]:
return sorted(p for p in root.rglob("*") if p.is_file()) if root.exists() else []
# --------------------------------------------------------------------------
# id
# --------------------------------------------------------------------------
def test_build_id_covers_source_raw_options_and_engine():
opts = table_versions.parse_options("a.xlsx", header_row=1, mixed="reject", raw_mode=False)
base = table_versions.build_id("s1", "a" * 64, opts)
assert len(base) == 64
assert table_versions.build_id("s2", "a" * 64, opts) != base
assert table_versions.build_id("s1", "b" * 64, opts) != base
other = table_versions.parse_options("a.xlsx", header_row=2, mixed="reject", raw_mode=False)
assert table_versions.build_id("s1", "a" * 64, other) != base
# Excel 换个文件名还是同一个构建;CSV 的表名取自文件名,所以文件名算进去
renamed = table_versions.parse_options("b.xlsx", header_row=1, mixed="reject", raw_mode=False)
assert table_versions.build_id("s1", "a" * 64, renamed) == base
csv_a = table_versions.parse_options("a.csv", header_row=1, mixed="reject", raw_mode=False)
csv_b = table_versions.parse_options("b.csv", header_row=1, mixed="reject", raw_mode=False)
assert table_versions.build_id("s1", "a" * 64, csv_a) != table_versions.build_id("s1", "a" * 64, csv_b)
def test_build_path_refuses_odd_source_ids():
with pytest.raises(ValueError):
table_versions.build_path("../etc", "x")
# --------------------------------------------------------------------------
# 原件存档
# --------------------------------------------------------------------------
def test_put_raw_stores_once_by_hash_and_read_only(store):
sha, path = raw_store.put_raw(b"hello")
assert sha == hashlib.sha256(b"hello").hexdigest()
assert path == store / "uploads" / "raw" / sha[:2] / sha
assert path.read_bytes() == b"hello"
assert stat.S_IMODE(path.stat().st_mode) == 0o444
again, path2 = raw_store.put_raw(b"hello")
assert (again, path2) == (sha, path)
assert raw_store.raw_exists(sha)
# 没有临时文件残留
assert [p.name for p in path.parent.iterdir()] == [sha]
def test_put_raw_replaces_a_corrupted_copy(store):
sha, path = raw_store.put_raw(b"payload")
os.chmod(path, 0o644)
path.write_bytes(b"")
raw_store.put_raw(b"payload")
assert path.read_bytes() == b"payload"
def test_purge_raw_and_bad_hashes(store):
sha, path = raw_store.put_raw(b"to be purged")
assert raw_store.purge_raw(sha) is True
assert not path.exists() and not raw_store.raw_exists(sha)
assert raw_store.purge_raw(sha) is False
assert raw_store.raw_exists("../../etc/passwd") is False
with pytest.raises(ValueError):
raw_store.raw_path("../../x")
def test_raw_store_has_no_reader():
"""原件不提供下载:模块里没有读回内容的函数,接口层也没有对应的路由。"""
public = {n for n in dir(raw_store) if not n.startswith("_")}
assert not {n for n in public if n.startswith(("get", "read", "open", "load"))}
from app.main import app
paths = [getattr(r, "path", "") for r in app.routes]
assert not [p for p in paths if "raw" in p and "purge" not in p]
# --------------------------------------------------------------------------
# 发布
# --------------------------------------------------------------------------
async def test_publish_creates_build_import_snapshot_and_switches_pointer(store):
name = unique()
sid, result = await publish(name)
row = await load(sid)
assert row.origin == "upload" and row.readonly is True
assert row.current_snapshot_id == result.snapshot_id
build = await get(TableBuild, result.build_id)
snap = await get(SourceSnapshot, result.snapshot_id)
[imp] = await imports_of(sid)
assert imp.id == result.import_id and imp.seq == 1 and imp.status == "active"
assert imp.raw_state == "kept" and raw_store.raw_exists(imp.raw_sha256)
assert snap.imports == [imp.id] and snap.db_path == build.db_path == row.database
assert Path(build.db_path).parent == store / "uploads" / "tables" / sid / "builds"
assert build.db_sha256 == raw_store.sha256_file(Path(build.db_path)) == snap.db_sha256
assert {t["name"] for t in result.report["tables"]} == {"甲表", "乙表"}
# 库文件只读:任何代码路径都写不进去
assert stat.S_IMODE(Path(build.db_path).stat().st_mode) == 0o444
with pytest.raises(sqlite3.OperationalError):
conn = sqlite3.connect(build.db_path)
try:
conn.execute('INSERT INTO "甲表" VALUES (\'x\', 1)')
conn.commit()
finally:
conn.close()
# 结构冻结在快照里,和数据源上的一致
assert set(snap.schema_cache["tables"]) == {"甲表", "乙表"}
assert row.schema_cache == snap.schema_cache
# 没有临时文件
assert not [p for p in files_under(store / "uploads") if raw_store.TMP_MARK in p.name]
view = await resolve(sid)
assert view.snapshot_id == snap.id and view.immutable is True and view.readonly is True
assert view.expected_sha256 == build.db_sha256 and view.database == snap.db_path
assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_same_file_twice_is_one_build_and_two_imports(store):
name = unique()
raw = xlsx(V1)
sid, first = await publish(name, raw=raw)
_, second = await publish(name, raw=raw, filename="改了名字.xlsx")
assert second.build_id == first.build_id and second.build_reused is True
assert first.build_reused is False
assert second.import_id != first.import_id and second.snapshot_id != first.snapshot_id
imps = await imports_of(sid)
assert [i.seq for i in imps] == [1, 2]
assert imps[1].status == "active" and imps[0].status != "active"
assert imps[1].file_name == "改了名字.xlsx"
# 库文件只有一个,仍在、仍是当前版本
builds = files_under(store / "uploads" / "tables" / sid)
assert [p.stem for p in builds] == [first.build_id]
assert (await load(sid)).current_snapshot_id == second.snapshot_id
async def test_two_sources_same_file_get_separate_builds(store):
raw = xlsx(V1)
a, ra = await publish(unique(), raw=raw)
b, rb = await publish(unique(), raw=raw)
assert ra.build_id != rb.build_id
ba, bb = await get(TableBuild, ra.build_id), await get(TableBuild, rb.build_id)
assert ba.db_path != bb.db_path and ba.source_id == a and bb.source_id == b
# 删掉 a 并回收:b 的库和共用的原件都还在
async with SessionLocal() as session:
await table_versions.retire_source(session, a)
await session.delete(await session.get(DataSource, a))
await session.commit()
await table_versions.gc(session)
assert not Path(ba.db_path).exists()
assert Path(bb.db_path).exists()
assert raw_store.raw_exists(ba.raw_sha256)
assert {i.status for i in await imports_of(a)} == {"retired"}
assert await scalar(await resolve(b), 'SELECT COUNT(*) FROM "乙表"') == 2
async def test_concurrent_reuploads_are_serialized(store):
"""同一个源同时来两次重传:序号不重、最后只有一条 active,指针指向其中一次。"""
import asyncio
name = unique()
sid, _ = await publish(name)
results = await asyncio.gather(publish(name, fresh(V2)), publish(name, fresh(V1)))
imps = await imports_of(sid)
assert [i.seq for i in imps] == [1, 2, 3]
assert [i.status for i in imps].count("active") == 1
current = (await load(sid)).current_snapshot_id
assert current in {r.snapshot_id for _, r in results}
active = next(i for i in imps if i.status == "active")
assert (await get(SourceSnapshot, current)).imports == [active.id]
async def test_parses_run_at_most_two_at_a_time(store, monkeypatch):
"""解析在线程里做、内存和上传数成正比:同时来五个上传,最多两个在解析,其余排队。"""
import asyncio
import threading
import time
lock = threading.Lock()
state = {"now": 0, "peak": 0}
def slow(final, raw, filename, header_row, mixed, raw_mode):
with lock:
state["now"] += 1
state["peak"] = max(state["peak"], state["now"])
time.sleep(0.05)
with lock:
state["now"] -= 1
raise tabular.UnsupportedTable("测试用的拒收")
monkeypatch.setattr(table_versions, "_parse_to_tmp", slow)
got = await asyncio.gather(*(publish(unique()) for _ in range(5)), return_exceptions=True)
assert all(isinstance(e, tabular.UnsupportedTable) for e in got)
assert state["peak"] == table_versions.MAX_PARALLEL_PARSES == 2
async def test_rejected_parse_keeps_old_version_and_leaves_nothing(store, monkeypatch):
name = unique()
sid, first = await publish(name)
before = files_under(store / "uploads")
def refuse(*_a, **_k):
raise StubNeedsDecision("需要先确认", kind="shape", details={"reasons": ["示例"]})
monkeypatch.setattr(tabular, "load_into", refuse)
with pytest.raises(tabular.UnsupportedTable) as info:
await publish(name, V2)
assert getattr(info.value, "kind", None) == "shape"
assert files_under(store / "uploads") == before # 没留原件、没留临时库
assert (await load(sid)).current_snapshot_id == first.snapshot_id
assert len(await imports_of(sid)) == 1
view = await resolve(sid)
assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
assert await scalar(view, 'SELECT SUM("客流") FROM "乙表"') == 30
async def test_unexpected_parse_error_is_wrapped_for_the_user(store, monkeypatch):
def boom(*_a, **_k):
raise KeyError("x")
monkeypatch.setattr(tabular, "load_into", boom)
with pytest.raises(table_versions.ParseFailed, match="请确认文件是未加密的 Excel 或 CSV"):
await publish(unique())
assert not files_under(store / "uploads")
async def test_failure_after_link_rolls_back_and_removes_new_files(store, monkeypatch):
"""写记录这一步失败:指针不动、旧版完整可查,这次新建的库文件和原件都删掉。"""
name = unique()
sid, first = await publish(name)
before = files_under(store / "uploads")
async def broken(view, report):
raise RuntimeError("模拟探查失败")
monkeypatch.setattr(table_versions, "_introspect_view", broken)
with pytest.raises(RuntimeError, match="模拟探查失败"):
await publish(name, V2)
assert files_under(store / "uploads") == before
row = await load(sid)
assert row.current_snapshot_id == first.snapshot_id
assert [i.status for i in await imports_of(sid)] == ["active"]
view = await resolve(sid)
assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_new_source_is_not_created_when_publishing_fails(store, monkeypatch):
async def broken(view, report):
raise RuntimeError("模拟失败")
monkeypatch.setattr(table_versions, "_introspect_view", broken)
name = unique()
with pytest.raises(RuntimeError):
await publish(name)
async with SessionLocal() as session:
assert (await session.execute(select(DataSource).where(DataSource.name == name))).first() is None
assert not files_under(store / "uploads" / "raw")
def quarantined(store: Path, source_id: str) -> list[Path]:
return files_under(store / "uploads" / "quarantine" / source_id)
async def test_existing_target_with_wrong_hash_is_restored_and_quarantined(store):
"""同一个构建的目标文件已存在、哈希和登记的不一样(0 字节残骸、被改过):这次的临时库哈希等于登记值,
就把已有文件挪进隔离区、用临时库恢复(期 3 遗留项 1;期 1 这里一律失败,同一个文件永远传不上去)。"""
name = unique()
raw = xlsx(V1)
sid, first = await publish(name, raw=raw)
build = await get(TableBuild, first.build_id)
path = Path(build.db_path)
os.chmod(path, 0o644)
path.write_bytes(b"")
_, second = await publish(name, raw=raw)
assert second.build_reused is True and second.build_restored is True
assert second.build_id == first.build_id
assert (await load(sid)).current_snapshot_id == second.snapshot_id
# 恢复的是登记的那一份:旧快照、新快照指着同一个文件,哈希都对得上
assert raw_store.sha256_file(path) == build.db_sha256 == (await get(TableBuild, first.build_id)).db_sha256
assert stat.S_IMODE(path.stat().st_mode) == 0o444
[moved] = quarantined(store, sid)
assert moved.name.startswith(f"{first.build_id}-") and moved.stat().st_size == 0
assert stat.S_IMODE(moved.stat().st_mode) == 0o444
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_existing_target_and_record_both_wrong_is_a_build_conflict(store):
"""文件和构建记录的哈希都被改了,本次上传也恢复不了:build_conflict,指针不动,什么都不挪。"""
name = unique()
raw = xlsx(V1)
sid, first = await publish(name, raw=raw)
async with SessionLocal() as session:
row = await session.get(TableBuild, first.build_id)
row.db_sha256 = "f" * 64
await session.commit()
path = Path((await get(TableBuild, first.build_id)).db_path)
os.chmod(path, 0o644)
path.write_bytes(b"")
with pytest.raises(table_versions.PublishError, match="无法用本次上传恢复") as err:
await publish(name, raw=raw)
assert err.value.code == "build_conflict"
assert (await load(sid)).current_snapshot_id == first.snapshot_id
assert len(await imports_of(sid)) == 1
assert path.exists() and path.stat().st_size == 0 # 不是我们建的,不替它删
assert not quarantined(store, sid)
assert raw_store.raw_exists((await get(TableBuild, first.build_id)).raw_sha256)
async def test_unregistered_file_at_target_is_quarantined_and_publish_succeeds(store):
"""目标位置有一个没有记录、内容不同的文件(没人登记的孤儿):挪进隔离区再 link,发布成功。"""
sid = uuid.uuid4().hex
raw = xlsx(V1)
opts = table_versions.parse_options("客流.xlsx", header_row=1, mixed="reject", raw_mode=False)
target = table_versions.build_path(sid, table_versions.build_id(sid, hashlib.sha256(raw).hexdigest(), opts))
target.parent.mkdir(parents=True)
target.write_bytes(b"")
name = unique()
async with SessionLocal() as session:
row = DataSource(id=sid, name=name, kind="sqlite", readonly=True, origin="upload")
result = await table_versions.publish_upload(session, row, raw, "客流.xlsx")
assert result.build_reused is False and result.build_restored is False
assert (await load(sid)).current_snapshot_id == result.snapshot_id
assert raw_store.sha256_file(target) == (await get(TableBuild, result.build_id)).db_sha256
[moved] = quarantined(store, sid)
assert moved.stat().st_size == 0
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_reupload_probes_the_new_file_even_when_the_old_engine_is_cached(store):
"""旧版本的引擎还在缓存里时重传:冻结进新快照的必须是新库的结构,不是缓存引擎指着的旧库。
引擎缓存的键若只有源 id(WP-A 之前),探查拿到的就是旧库的引擎。探查改用一次性的键,
发布时再核对探查到的表和解析回执是否一致。
"""
name = unique()
sid, _ = await publish(name, {"旧表": [["区域", "数量"], ["分区甲", 1]]})
# 查一次当前版本,不清缓存:引擎留在缓存里,指着旧库
assert (await run_query(await resolve(sid), 'SELECT COUNT(*) FROM "旧表"')).rows[0][0] == 1
try:
_, second = await publish(name, {"新表": [["时段", "客流"], ["早", 10]]})
snap = await get(SourceSnapshot, second.snapshot_id)
assert list(snap.schema_cache["tables"]) == ["新表"]
assert [t["name"] for t in second.report["tables"]] == ["新表"]
assert list((await load(sid)).schema_cache["tables"]) == ["新表"]
assert await scalar(await resolve(sid), 'SELECT SUM("客流") FROM "新表"') == 10
finally:
await engines.invalidate(sid)
async def test_publish_refuses_a_probe_that_does_not_match_the_report(store, monkeypatch):
"""探查到的表和解析回执对不上、或者探查本身失败:不冻结进快照,指针不动,新文件删掉。"""
from app.data import introspect as introspect_mod
name = unique()
sid, first = await publish(name)
before = files_under(store / "uploads")
real = introspect_mod.introspect
async def stale(source, **kw):
cache = await real(source, **kw)
cache["tables"] = {"别的表": {"qualified": "别的表", "columns": []}}
return cache
async def failed(source, **kw):
return {"tables": {}, "truncated": False, "total": 0, "failed": True, "error": "模拟探查失败"}
for fake, message in ((stale, "对不上"), (failed, "模拟探查失败")):
monkeypatch.setattr(introspect_mod, "introspect", fake)
with pytest.raises(table_versions.PublishError, match=message):
await publish(name, fresh(V2))
assert files_under(store / "uploads") == before
assert (await load(sid)).current_snapshot_id == first.snapshot_id
assert [i.status for i in await imports_of(sid)] == ["active"]
monkeypatch.setattr(introspect_mod, "introspect", real)
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_a_schema_option_on_an_upload_source_does_not_break_publishing(store):
"""上传库只有 main:数据源 options 里留着的 schema(迁移来的老源、早先手工改过的)不参与探查。"""
name = unique()
sid, _ = await publish(name)
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
row.options = {"schema": "nope", "mask_columns": "区域"}
await session.commit()
_, second = await publish(name, fresh(V2))
snap = await get(SourceSnapshot, second.snapshot_id)
assert {"甲表", "乙表"} <= set(snap.schema_cache["tables"])
assert not snap.schema_cache.get("failed")
row = await load(sid)
# 发布顺手把用不上的 schema 摘掉,其余配置保留
assert row.options == {"mask_columns": "区域"}
view = await resolve(sid)
assert "schema" not in view.options and view.options["mask_columns"] == "区域"
assert await scalar(view, 'SELECT SUM("数量") FROM "甲表"') == 100
def _wal_parser(keep_open: list):
"""替身解析器:把临时库切成 WAL 再写。keep_open 非 None 时留一个连接开着,-wal 合并不掉。"""
def parse(db_path, raw, filename, **kw):
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.close()
report = stub_load_into(db_path, raw, filename, **kw)
if keep_open is not None:
holder = sqlite3.connect(db_path, check_same_thread=False)
holder.execute("SELECT COUNT(*) FROM sqlite_master").fetchall()
keep_open.append(holder)
return report
return parse
async def test_published_files_are_in_rollback_journal_mode(store, monkeypatch):
"""解析器用了 WAL 也要切回回滚日志:库以 immutable 打开时 SQLite 不看日志,留在 WAL 里会少数据。"""
monkeypatch.setattr(tabular, "load_into", _wal_parser(None))
sid, result = await publish(unique(), fresh(V1))
path = Path((await get(TableBuild, result.build_id)).db_path)
header = path.read_bytes()[:100]
assert header[18] == 1 and header[19] == 1 # 1 = 回滚日志,2 = WAL
assert not Path(f"{path}-wal").exists() and not Path(f"{path}-shm").exists()
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
async def test_publish_stops_when_the_wal_cannot_be_merged(store, monkeypatch):
name = unique()
sid, first = await publish(name)
before = files_under(store / "uploads")
holders: list = []
monkeypatch.setattr(tabular, "load_into", _wal_parser(holders))
try:
with pytest.raises(table_versions.PublishError, match="日志"):
await publish(name, fresh(V2))
finally:
for conn in holders:
conn.close()
assert files_under(store / "uploads") == before
assert (await load(sid)).current_snapshot_id == first.snapshot_id
async def test_unshaped_tables_get_a_note_without_numbers(store, monkeypatch):
def unshaped(db_path, raw, filename, **kw):
report = stub_load_into(db_path, raw, filename, **kw)
report.tables[0].unshaped = True
report.tables[1].unshaped = False
return report
monkeypatch.setattr(tabular, "load_into", unshaped)
sid, result = await publish(unique(), raw_mode=True)
snap = await get(SourceSnapshot, result.snapshot_id)
tables = snap.schema_cache["tables"]
assert tables["甲表"]["comment"] == table_versions.UNSHAPED_NOTE
assert not tables["乙表"].get("comment")
assert not any(ch.isdigit() for ch in table_versions.UNSHAPED_NOTE)
from app.data import introspect
assert "未经规整" in introspect.describe_table(await resolve(sid), "甲表")
# --------------------------------------------------------------------------
# 快照解析
# --------------------------------------------------------------------------
async def test_manual_sources_resolve_to_themselves(tmp_path):
async with SessionLocal() as session:
row = DataSource(name=unique(), kind="sqlite", database=str(tmp_path / "m.db"))
session.add(row)
await session.commit()
assert await table_versions.resolve_source(session, row) is row
async def test_pinned_snapshot_must_belong_to_the_source(store):
a, ra = await publish(unique())
b, _ = await publish(unique())
with pytest.raises(SnapshotMissing, match="不会改用当前版本"):
await resolve(b, ra.snapshot_id)
with pytest.raises(SnapshotMissing):
await resolve(b, "f" * 64)
async def test_bind_snapshot_uses_the_snapshot_not_the_source(store):
sid, first = await publish(unique())
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
snap = await session.get(SourceSnapshot, first.snapshot_id)
row.schema_cache = {"tables": {}} # 数据源上的缓存被改了,视图不受影响
row.database = "/elsewhere.db"
view = table_versions.bind_snapshot(row, snap)
name, snap_path = row.name, snap.db_path
await session.rollback()
assert set(view.schema_cache["tables"]) == {"甲表", "乙表"}
assert view.database == snap_path and view.readonly and view.immutable
assert view.id == sid and view.name == name and view.kind == "sqlite"
# 视图里的结构是一份拷贝:改它不会改到快照
view.schema_cache["tables"].clear()
assert set((await get(SourceSnapshot, first.snapshot_id)).schema_cache["tables"]) == {"甲表", "乙表"}
def _rewrite_snapshot(path: str, sql: str) -> None:
"""改快照库里的数据(模拟被改过的文件),改完切回回滚日志模式,不留 -wal / -journal。"""
os.chmod(path, 0o644)
conn = sqlite3.connect(path)
conn.execute(sql)
conn.commit()
conn.execute("PRAGMA journal_mode=DELETE")
conn.close()
async def test_snapshot_with_an_empty_hash_is_refused(store):
"""快照登记的哈希是空的(记录损坏、被手工改过):不能退回「不核对照样打开」,读到被改过的数据。"""
sid, first = await publish(unique())
snap = await get(SourceSnapshot, first.snapshot_id)
_rewrite_snapshot(snap.db_path, 'UPDATE "甲表" SET "数量" = 1000 WHERE "区域" = \'分区甲\'')
with pytest.raises(Exception, match="可能被修改过"): # 对照:哈希还在时,被改过的文件被拒
await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"')
for blank in ("", " "):
async with SessionLocal() as session:
(await session.get(SourceSnapshot, first.snapshot_id)).db_sha256 = blank
await session.commit()
with pytest.raises(SnapshotMissing, match="缺少数据文件的哈希登记"):
await resolve(sid)
with pytest.raises(SnapshotMissing, match="缺少数据文件的哈希登记"):
await resolve(sid, first.snapshot_id)
async def test_reusing_a_build_with_an_empty_hash_fills_it_in(store):
"""构建记录的哈希是空的,重传同一个文件复用了它:新快照不能继承空哈希,构建记录也补上。"""
name, raw = unique(), xlsx(fresh(V1))
sid, first = await publish(name, raw=raw)
async with SessionLocal() as session:
(await session.get(TableBuild, first.build_id)).db_sha256 = ""
await session.commit()
_, second = await publish(name, raw=raw)
assert second.build_reused and second.build_id == first.build_id
build = await get(TableBuild, first.build_id)
snap = await get(SourceSnapshot, second.snapshot_id)
assert build.db_sha256 and snap.db_sha256 == build.db_sha256 == raw_store.sha256_file(Path(build.db_path))
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 3
# --------------------------------------------------------------------------
# 回收
# --------------------------------------------------------------------------
async def test_old_versions_are_reclaimed_when_nothing_references_them(store, monkeypatch):
# 期 3 起回收在当前快照、运行引用的之外另外保留每个源最近 SNAPSHOT_KEEP 个快照;调成 0(期 1 的规则)
# 才测得出「没人引用就回收」
monkeypatch.setattr(table_versions, "SNAPSHOT_KEEP", 0)
name = unique()
sid, first = await publish(name, fresh(V1))
old = await get(TableBuild, first.build_id)
_, second = await publish(name, fresh(V2))
assert not Path(old.db_path).exists()
assert (await get(TableBuild, first.build_id)).retired_at is not None
assert (await get(SourceSnapshot, first.snapshot_id)).retired_at is not None
imps = await imports_of(sid)
assert imps[0].status == "retired" and imps[0].raw_state == "purged"
assert imps[0].purged and imps[0].purged.get("auto") is True
assert not raw_store.raw_exists(imps[0].raw_sha256)
assert raw_store.raw_exists(imps[1].raw_sha256)
with pytest.raises(SnapshotMissing, match="不会改用当前版本"):
await resolve(sid, first.snapshot_id)
async def test_gc_keeps_snapshots_referenced_by_interrupted_runs(store, monkeypatch):
# 同上:SNAPSHOT_KEEP 调成 0,保护只来自运行引用,运行记录删掉以后才会被回收
monkeypatch.setattr(table_versions, "SNAPSHOT_KEEP", 0)
name = unique()
sid, first = await publish(name, fresh(V1))
async with SessionLocal() as session:
run = Run(workflow_name="wpc-gc", status="interrupted",
data_versions={sid: {"snapshot": first.snapshot_id, "name": name}})
session.add(run)
await session.commit()
run_id = run.id
try:
_, second = await publish(name, fresh(V2))
old = await get(TableBuild, first.build_id)
assert Path(old.db_path).exists() and old.retired_at is None
assert (await get(SourceSnapshot, first.snapshot_id)).retired_at is None
imps = await imports_of(sid)
assert imps[0].status == "superseded" and raw_store.raw_exists(imps[0].raw_sha256)
# 钉住的旧版仍可查,当前版本是新的
assert await scalar(await resolve(sid, first.snapshot_id), 'SELECT SUM("数量") FROM "甲表"') == 3
assert await scalar(await resolve(sid), 'SELECT SUM("数量") FROM "甲表"') == 100
finally:
async with SessionLocal() as session:
await session.delete(await session.get(Run, run_id))
await session.commit()
# 运行没了,再回收就删掉
async with SessionLocal() as session:
await table_versions.gc(session)
assert not Path((await get(TableBuild, first.build_id)).db_path).exists()
assert not raw_store.raw_exists((await imports_of(sid))[0].raw_sha256)
with pytest.raises(SnapshotMissing):
await resolve(sid, first.snapshot_id)
async def test_gc_never_deletes_outside_the_uploads_dir(store, tmp_path):
outside = tmp_path / "outside.db"
outside.write_bytes(b"keep me")
async with SessionLocal() as session:
session.add(TableBuild(id=hashlib.sha256(os.urandom(8)).hexdigest(), source_id="nosuchsource",
db_path=str(outside)))
await session.commit()
await table_versions.gc(session)
assert outside.read_bytes() == b"keep me"
# --------------------------------------------------------------------------
# 启动:孤儿清理、迁移
# --------------------------------------------------------------------------
async def test_startup_removes_orphans_left_by_a_crash(store):
"""模拟「link 之后、提交之前」进程没了:库文件和原件都在,却没有记录。启动时清掉。"""
sid, kept = await publish(unique())
kept_build = await get(TableBuild, kept.build_id)
orphan_src = uuid.uuid4().hex
orphan = table_versions.build_path(orphan_src, "0" * 64)
orphan.parent.mkdir(parents=True)
tmp = orphan.parent / f"{orphan.name}{raw_store.TMP_MARK}abc"
conn = sqlite3.connect(tmp)
conn.execute("CREATE TABLE t (a INTEGER)")
conn.commit()
conn.close()
table_versions._publish_file(tmp, orphan, None)
assert orphan.exists() and not tmp.exists()
half = orphan.parent / f"{'1' * 64}.db{raw_store.TMP_MARK}zzz"
half.write_bytes(b"half written")
orphan_sha, orphan_raw = raw_store.put_raw(b"raw nobody registered")
async with SessionLocal() as session:
await table_versions.startup(session)
assert not orphan.exists() and not half.exists() and not orphan_raw.exists()
assert Path(kept_build.db_path).exists() and raw_store.raw_exists(kept_build.raw_sha256)
assert await scalar(await resolve(sid), 'SELECT COUNT(*) FROM "甲表"') == 2
def _legacy_db(path: Path) -> None:
conn = sqlite3.connect(path)
conn.execute('CREATE TABLE "明细" ("金额" INTEGER)')
conn.executemany('INSERT INTO "明细" VALUES (?)', [(5,), (7,)])
conn.commit()
conn.close()
async def test_startup_migrates_legacy_uploads_idempotently(store):
tables = table_versions.tables_root()
tables.mkdir(parents=True)
a_path, b_path = tables / "old_a.db", tables / "old_b.db"
_legacy_db(a_path)
_legacy_db(b_path)
assert a_path.read_bytes() == b_path.read_bytes() # 内容相同,v0 的 id 也不能撞
async with SessionLocal() as session:
a = DataSource(name=unique("old"), kind="sqlite", database=str(a_path), readonly=True)
b = DataSource(name=unique("old"), kind="sqlite", database=str(b_path), readonly=True,
schema_cache={"tables": {"明细": {"qualified": "明细", "columns": []}}})
session.add_all([a, b])
await session.commit()
ids = (a.id, b.id)
async with SessionLocal() as session:
await table_versions.startup(session)
rows = [await load(i) for i in ids]
assert [r.origin for r in rows] == ["upload", "upload"]
snaps = [await get(SourceSnapshot, r.current_snapshot_id) for r in rows]
builds = [await get(TableBuild, (await imports_of(r.id))[0].build_id) for r in rows]
assert snaps[0].id != snaps[1].id and builds[0].id != builds[1].id
sha = raw_store.sha256_file(a_path)
assert builds[0].id == hashlib.sha256(f"legacy{ids[0]}{sha}".encode()).hexdigest()
assert builds[0].engine_ver == table_versions.LEGACY_ENGINE_VER
for r, snap, path in zip(rows, snaps, (a_path, b_path)):
[imp] = await imports_of(r.id)
assert imp.raw_state == "absent" and imp.status == "active"
assert snap.db_path == os.path.realpath(path) and snap.db_sha256 == sha
assert stat.S_IMODE(path.stat().st_mode) == 0o444
# 没有结构缓存的补探一次;有的原样冻结
assert "明细" in snaps[0].schema_cache["tables"]
assert snaps[1].schema_cache == {"tables": {"明细": {"qualified": "明细", "columns": []}}}
assert await scalar(await resolve(ids[0]), 'SELECT SUM("金额") FROM "明细"') == 12
async with SessionLocal() as session:
await table_versions.startup(session)
for r in rows:
assert len(await imports_of(r.id)) == 1
assert (await load(r.id)).current_snapshot_id == r.current_snapshot_id
async with SessionLocal() as session:
count = (await session.execute(
select(func.count()).select_from(TableBuild).where(TableBuild.source_id.in_(ids))
)).scalar()
assert count == 2
async def test_one_failing_legacy_source_does_not_block_the_rest_of_startup(store, monkeypatch):
"""一个老上传迁不过去(文件属主不对、chmod 报 EPERM 之类):别的源照常迁,孤儿清理和回收照常做。"""
tables = table_versions.tables_root()
tables.mkdir(parents=True)
paths = [tables / "aaa_fail.db", tables / "bbb_ok.db"]
for path in paths:
_legacy_db(path)
async with SessionLocal() as session:
rows = [DataSource(name=unique("old"), kind="sqlite", database=str(p), readonly=True) for p in paths]
session.add_all(rows)
await session.commit()
ids = [r.id for r in rows]
# 一个没人登记的孤儿库、一个没人引用的旧构建:startup 走完时都该被收拾掉
orphan = table_versions.build_path(uuid.uuid4().hex, "0" * 64)
orphan.parent.mkdir(parents=True)
orphan.write_bytes(b"orphan")
stale_sid, stale = await publish(unique(), fresh(V1))
stale_build = await get(TableBuild, stale.build_id)
async with SessionLocal() as session:
# 删了源、还没来得及回收(比如回收那一步失败了)
await table_versions.retire_source(session, stale_sid)
await session.delete(await session.get(DataSource, stale_sid))
await session.commit()
real = table_versions._prepare_legacy
calls: list[str] = []
def flaky(path):
calls.append(path)
if len(calls) == 1:
raise PermissionError(1, "Operation not permitted", path)
return real(path)
monkeypatch.setattr(table_versions, "_prepare_legacy", flaky)
async with SessionLocal() as session:
await table_versions.startup(session) # 不抛
assert len(calls) == 2
failed_path = calls[0]
by_path = {str(os.path.realpath(p)): i for p, i in zip(paths, ids)}
failed_id = by_path[failed_path]
ok_id = next(i for i in ids if i != failed_id)
assert (await load(failed_id)).origin == "manual"
assert (await load(failed_id)).current_snapshot_id is None
migrated = await load(ok_id)
assert migrated.origin == "upload" and migrated.current_snapshot_id
assert await scalar(await resolve(ok_id), 'SELECT SUM("金额") FROM "明细"') == 12
assert not orphan.exists()
assert (await get(TableBuild, stale.build_id)).retired_at is not None
assert not Path(stale_build.db_path).exists()
# 下次启动把上次失败的那个补上
monkeypatch.setattr(table_versions, "_prepare_legacy", real)
async with SessionLocal() as session:
await table_versions.startup(session)
assert (await load(failed_id)).origin == "upload"
async def test_startup_leaves_other_sqlite_sources_alone(store, tmp_path):
"""不是老上传的 SQLite 源不迁移;但指向上传目录的(迁移认不出来的更深一层、带连接参数的别名)
强制改成只读:那里的文件归版本底座管,升级前登记的可写手工源不能接着往里写(方案 3.7)。"""
elsewhere = tmp_path / "manual.db"
_legacy_db(elsewhere)
nested = table_versions.tables_root() / "deeper"
nested.mkdir(parents=True)
_legacy_db(nested / "x.db")
_legacy_db(nested / "y.db")
async with SessionLocal() as session:
m = DataSource(name=unique("man"), kind="sqlite", database=str(elsewhere), readonly=False)
n = DataSource(name=unique("man"), kind="sqlite", database=str(nested / "x.db"), readonly=False)
q = DataSource(name=unique("man"), kind="sqlite", database=f"{nested / 'y.db'}?timeout=5",
readonly=False)
session.add_all([m, n, q])
await session.commit()
ids = (m.id, n.id, q.id)
await table_versions.startup(session)
for i in ids:
row = await load(i)
assert row.origin == "manual" and row.current_snapshot_id is None
assert stat.S_IMODE(elsewhere.stat().st_mode) != 0o444
assert (await load(m.id)).readonly is False # 上传目录外的手工库照旧可写
assert (await load(n.id)).readonly is True and (await load(q.id)).readonly is True
async with SessionLocal() as session:
with pytest.raises(Exception, match="readonly|只读|拒绝"):
await run_query(await session.get(DataSource, n.id), 'INSERT INTO "明细" VALUES (9)')
assert sqlite3.connect(nested / "x.db").execute('SELECT COUNT(*) FROM "明细"').fetchone()[0] == 2
async def test_legacy_upload_can_be_updated_by_a_new_upload(store, monkeypatch):
# 同上:SNAPSHOT_KEEP 调成 0,迁移出的 v0 版本没人引用,随回收删除
monkeypatch.setattr(table_versions, "SNAPSHOT_KEEP", 0)
tables = table_versions.tables_root()
tables.mkdir(parents=True)
legacy = tables / "old_c.db"
_legacy_db(legacy)
name = unique("old")
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(legacy), readonly=True)
session.add(row)
await session.commit()
await table_versions.startup(session)
sid, result = await publish(name, V1)
assert (await load(sid)).current_snapshot_id == result.snapshot_id
assert not legacy.exists() # 老库没有运行引用,随回收删除
assert [i.status for i in await imports_of(sid)] == ["retired", "active"]