mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
8059cd5147
连接层 - 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检 - 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询 - 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因 解析(新 xlsx_scan + tabular) - 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个 - 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收 - 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖 - 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执 - 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完 版本底座(新 table_versions、raw_store) - 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查 - 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断) - 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥 - 上传接口的解析移到线程池 运行固定数据版本 - 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询 - 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version 证据层 - 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变 - 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出 前端 - 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存 - 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
115 lines
4.8 KiB
Python
115 lines
4.8 KiB
Python
"""SQLite 连接关了双引号字符串兼容以后,老库里一个写坏的视图不能拖垮整次结构探查。
|
|
|
|
老库里常有 `SELECT "yes" AS flag` 这种视图:当年 SQLite 把找不到的双引号名字当字符串,
|
|
它一直能用。连接层关掉这项兼容后,这个视图本身查询会报 no such column——这是对的;
|
|
但批量反射也因它报错,以前探查吞掉异常、返回「成功、0 张表」,好好的表跟着一起消失,
|
|
还被盖上「已同步」。现在批量失败时逐个对象重试,只跳过坏的那个,并说明原因。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import uuid
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from sqlalchemy.engine.reflection import Inspector
|
|
|
|
from app.data import introspect as introspect_mod
|
|
from app.data.engine import engines, run_query
|
|
from app.data.introspect import introspect
|
|
|
|
|
|
def _make(path, *, table: bool = True, views: int = 1) -> str:
|
|
"""建库用的是 Python 自带的 sqlite3 连接(DQS 开着),和当年建出这种老库的情形一样。"""
|
|
conn = sqlite3.connect(path)
|
|
if table:
|
|
conn.execute("CREATE TABLE 时段客流 (id INTEGER PRIMARY KEY, 分区 TEXT, 客流 INTEGER)")
|
|
conn.executemany("INSERT INTO 时段客流 (分区, 客流) VALUES (?, ?)",
|
|
[("分区甲", 120), ("分区乙", 80)])
|
|
for i in range(views):
|
|
conn.execute(f'CREATE VIEW 旧视图{i} AS SELECT 分区, "是" AS 标记 FROM 时段客流')
|
|
else:
|
|
conn.execute('CREATE VIEW 旧视图 AS SELECT "是" AS 标记') # 库里只有这一个坏视图
|
|
conn.commit()
|
|
conn.close()
|
|
return str(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_source():
|
|
made: list[SimpleNamespace] = []
|
|
|
|
def make(path: str, *, readonly: bool = True) -> SimpleNamespace:
|
|
src = SimpleNamespace(
|
|
id=f"bv-{uuid.uuid4().hex[:8]}", name="旧库", kind="sqlite", database=path,
|
|
host=None, port=None, username=None, password=None, options={},
|
|
readonly=readonly, description=None, schema_cache={},
|
|
)
|
|
made.append(src)
|
|
return src
|
|
|
|
yield make
|
|
for src in made:
|
|
engines._engines.pop(f"{src.id}:", None)
|
|
|
|
|
|
@pytest.mark.parametrize("readonly", [True, False], ids=["只读", "可写"])
|
|
async def test_broken_view_is_skipped_tables_still_listed(tmp_path, make_source, readonly):
|
|
src = make_source(_make(tmp_path / "旧库.db"), readonly=readonly)
|
|
payload = await introspect(src)
|
|
|
|
assert list(payload["tables"]) == ["时段客流"]
|
|
table = payload["tables"]["时段客流"]
|
|
assert [c["name"] for c in table["columns"]] == ["id", "分区", "客流"]
|
|
assert table["primary_key"] == ["id"]
|
|
assert not payload.get("failed")
|
|
assert list(payload["skipped"]) == ["旧视图0"]
|
|
assert "no such column" in payload["skipped"]["旧视图0"]
|
|
|
|
# 表照常可查;坏视图本身查询报错(连接层关 DQS 的本意)
|
|
assert (await run_query(src, "SELECT SUM(客流) FROM 时段客流")).rows == [[200]]
|
|
with pytest.raises(Exception, match="no such column"):
|
|
await run_query(src, "SELECT * FROM 旧视图0")
|
|
|
|
|
|
async def test_only_broken_objects_is_a_failure_not_an_empty_success(tmp_path, make_source):
|
|
"""一个对象也取不到:记成失败(不会被盖「已同步」),而不是「成功、0 张表」。"""
|
|
src = make_source(_make(tmp_path / "只剩视图.db", table=False))
|
|
payload = await introspect(src)
|
|
assert payload["tables"] == {}
|
|
assert payload["failed"] is True
|
|
assert "no such column" in payload["error"]
|
|
|
|
|
|
async def test_healthy_database_payload_unchanged(tmp_path, make_source):
|
|
src = make_source(_make(tmp_path / "好库.db", views=0))
|
|
payload = await introspect(src)
|
|
assert list(payload["tables"]) == ["时段客流"]
|
|
assert "skipped" not in payload and not payload.get("failed")
|
|
|
|
|
|
async def test_per_object_retry_gives_up_when_nothing_is_readable(tmp_path, make_source, monkeypatch):
|
|
"""批量和逐个都失败(比如连接本身出了问题):开头几个都取不到就停,不挨个耗时间。"""
|
|
path = tmp_path / "多表.db"
|
|
conn = sqlite3.connect(path)
|
|
for i in range(12):
|
|
conn.execute(f"CREATE TABLE t{i} (a INTEGER)")
|
|
conn.commit()
|
|
conn.close()
|
|
src = make_source(str(path))
|
|
|
|
calls: list[str] = []
|
|
|
|
def batch_fails(self, *a, **kw):
|
|
raise RuntimeError("模拟:连接中断")
|
|
|
|
def single_fails(self, name, *a, **kw):
|
|
calls.append(name)
|
|
raise RuntimeError("模拟:连接中断")
|
|
|
|
monkeypatch.setattr(Inspector, "get_multi_columns", batch_fails)
|
|
monkeypatch.setattr(Inspector, "get_columns", single_fails)
|
|
payload = await introspect(src)
|
|
assert len(calls) == introspect_mod._GIVE_UP_AFTER
|
|
assert payload["failed"] is True and "连接中断" in payload["error"]
|