mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:01: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
6.5 KiB
Python
115 lines
6.5 KiB
Python
"""表名、列名、维度值的规范化和比较。
|
||
|
||
导入(data/tabular.py、data/table_versions.py)和证据层(engine/evidence.py)共用这一份。
|
||
两边各写一套的话,「导入时认为没撞名、核对时认为是同一个名字」这种口径分歧迟早会出现。
|
||
|
||
三个用途,三个函数,不能混用:
|
||
- canon:比较和存储维度值、主键用的规范写法(全角、各类连字符、格式字符、空白都抹平);
|
||
- name_key:按名字找表、找列。只对 ASCII 不区分大小写、不做 NFKC,和 SQLite 的标识符比较完全一致
|
||
(导入的名字都满足 name_problem、等于自身的 NFKC 形式,对它们做不做 NFKC 没有区别);
|
||
- collide_key:判断两个名字算不算撞名。用 casefold,比 SQLite 严:「Дата」和「дата」
|
||
在 SQLite 里是两张表,但并存只会让人和模型都分不清,所以也不许。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import unicodedata
|
||
|
||
#: NFKC 之后仍然不是 ASCII 连字符的各种横线:连字符、不换行连字符、数字线、en dash、
|
||
#: em dash、水平线、减号。全角「-」NFKC 会归一,不在这里
|
||
_DASHES = frozenset("‐‑‒–—―−")
|
||
#: 看起来是空白、却不算 Zs 也不算 Cf 的填充字符(韩文填充符一类),混进名字里肉眼看不出来。
|
||
#: 证据层核对 [[t:]] / [[c:]] 的写法时也用它(engine/evidence.py)
|
||
FILLERS = frozenset("ᅟᅠㅤᅠ")
|
||
_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
||
#: 名字上限 48 字:工具描述、表结构里要列出来,太长的名字模型和人都读不全
|
||
NAME_MAX = 48
|
||
_NAME = re.compile(rf"^[^\W\d]\w{{0,{NAME_MAX - 1}}}$")
|
||
#: 作名字会让不加引号的 SQL 读错的关键字。不求全,收常见的、表头里可能出现的
|
||
RESERVED = frozenset({
|
||
"abort", "add", "all", "alter", "and", "as", "asc", "between", "by", "case", "check",
|
||
"collate", "column", "commit", "constraint", "create", "cross", "default", "delete",
|
||
"desc", "distinct", "drop", "else", "end", "escape", "except", "exists", "foreign",
|
||
"from", "full", "group", "having", "in", "index", "inner", "insert", "intersect",
|
||
"into", "is", "join", "key", "left", "like", "limit", "natural", "not", "null",
|
||
"offset", "on", "or", "order", "outer", "primary", "references", "right", "rowid",
|
||
"select", "set", "table", "then", "to", "transaction", "trigger", "union", "unique",
|
||
"update", "using", "values", "view", "when", "where", "with",
|
||
})
|
||
|
||
|
||
def canon(text: str) -> str:
|
||
"""规范写法:NFKC,各类横线统一成「-」,去掉格式字符(Cf),空白折叠成一个空格、去首尾。
|
||
|
||
九月的表把「8-9」写成「8–9」(en dash)或「8-9」(全角),按原文存的话,
|
||
`WHERE 时段 = '8-9'` 会静默漏掉那个月——所以维度值和主键都按这个存,原文另记。
|
||
"""
|
||
s = unicodedata.normalize("NFKC", str(text))
|
||
s = "".join("-" if ch in _DASHES else ch for ch in s if unicodedata.category(ch) != "Cf")
|
||
return re.sub(r"\s+", " ", s).strip()
|
||
|
||
|
||
def name_problem(name: str) -> str | None:
|
||
"""表名或列名能不能用。能用返回 None,不能用返回一句给人看的原因。
|
||
|
||
规则:等于自身的 NFKC 形式(挡住全角 ASCII「ABC」、带圈数字「①」);不含控制字符、
|
||
格式字符、空格类和组合附加符号,也不含填充字符;字母或汉字开头,只含字母、数字、汉字和
|
||
下划线,最长 NAME_MAX 字;不以 sqlite_ 开头(SQLite 保留);不是 SQL 关键字。
|
||
"""
|
||
if not name:
|
||
return "名称不能为空"
|
||
if name != unicodedata.normalize("NFKC", name):
|
||
return f"名称「{name}」含全角或兼容字符,请改用常规写法"
|
||
if any(unicodedata.category(ch) in ("Cc", "Cf", "Zs", "Mn") or ch in FILLERS for ch in name):
|
||
return f"名称「{name}」含不可见字符或空格"
|
||
if not _NAME.match(name):
|
||
return (f"名称「{name}」需以字母或汉字开头,只能包含字母、数字、汉字和下划线,"
|
||
f"最长 {NAME_MAX} 字")
|
||
if name.lower().startswith("sqlite_"):
|
||
return f"名称「{name}」以 sqlite_ 开头,这是 SQLite 的保留前缀"
|
||
if name.lower() in RESERVED:
|
||
return f"名称「{name}」是 SQL 关键字"
|
||
return None
|
||
|
||
|
||
def name_key(name: str) -> str:
|
||
"""按名字查找时用的键:只把 ASCII 字母转小写,其余逐字比,不做 NFKC。
|
||
|
||
SQLite 比较标识符时只对 ASCII 不区分大小写(`ABC` 和 `abc` 是同一张表,`Дата` 和
|
||
`дата` 不是),也不做任何归一(全角的 `ABC` 和 `abc` 是两张表,库里可以并存)。证据层用这个键
|
||
找表找列、给 SQL 里的表名去重,和真正执行 SQL 的数据库口径一致。
|
||
|
||
不能先做 NFKC:手工接入的库里全角「ORDERS」和「orders」并存时,[[t:orders]] 会被静默绑到
|
||
全角那张,SQL 台账也会把真正 JOIN 的两张表当成一张。导入产生的名字都满足 name_problem(等于自身的
|
||
NFKC 形式),对它们这两种写法得出的键一样;证据层的 [[t:]] / [[c:]] 引用也要求 NFKC 稳定。
|
||
"""
|
||
return str(name).translate(_ASCII_LOWER)
|
||
|
||
|
||
def collide_key(name: str) -> str:
|
||
"""撞名检查用的键:NFKC 加 casefold,比 SQLite 严。"""
|
||
return unicodedata.normalize("NFKC", str(name)).casefold()
|
||
|
||
|
||
def to_sql_name(header: object, *, fallback: str) -> str:
|
||
"""原表头 → 一个满足 name_problem 的名字。原表头另存作显示名,这里只管能用。
|
||
|
||
canon 之后把不是字母、数字、汉字、下划线的字符(括号、斜杠、空格、换行、方括号、竖线)
|
||
换成「_」并合并,去掉首尾的「_」;数字开头加「c_」,关键字后面加「_」,超长截断。
|
||
什么都不剩时用 fallback(一般是 col_<位置>)。「金额(元)」→「金额_元」。
|
||
"""
|
||
s = canon("" if header is None else str(header))
|
||
s = re.sub(r"[^\w]+", "_", s)
|
||
s = "".join(ch for ch in s if unicodedata.category(ch) not in ("Mn", "Cf") and ch not in FILLERS)
|
||
s = re.sub(r"_+", "_", s).strip("_")
|
||
if not s:
|
||
return fallback
|
||
if s[0].isdigit():
|
||
s = f"c_{s}"
|
||
if s.lower().startswith("sqlite_"):
|
||
s = f"t_{s}"
|
||
s = s[:NAME_MAX].rstrip("_") or fallback
|
||
if s.lower() in RESERVED:
|
||
s = f"{s}_"
|
||
return s if name_problem(s) is None else fallback
|