Files
AgentLab/backend/app/engine/direct_select.py
ThearchyHelios e4f8ee5d08 Excel 导入改造期 4:证据下钻——报告里引用上传表格的数,点开能看到它来自原表格的哪一格;裁判核对时看得到来源、口径、接受理由和区域外文字
推断的来源(新 engine/direct_select、data/provenance、data/provenance_db;api/evidence 新增推断来源接口)
- 只对直接引用一列的查询下钻:受限文法用和执行时相同的 SQLite 分词读法,别名互换、自连接、表达式、聚合、子查询、集合运算(含藏在注释、字符串、方括号、转义、BOM 之后的)一律只给表级来历;再用 EXPLAIN 字节码和授权回调兜底
- 结果带齐主键、值非空,按主键参数化回查只读快照、类型和值严格一致才给格子;不一致时整份重算库哈希,哈希不符标红
- 从导入清单和溯源找回格子:宽表、交叉表逆透视的长表(日期取自表头格、年份取自统计期、行标签、分段标题)、列表、合计表、按期累积的并集(换算到所属那一期);原文与规范写法不同时一并列出
- 清单读不到、登记哈希与清单不一致、快照已回收、遮罩列,都只给表级来历或不给,写明原因;原件已清除、接受已作废照实显示;未封存的运行照常推断并标明
- 相关核对:这一行、这一期的核对结论与接受理由

裁判摘录(engine/judge)
- 带期 4 标记的报告,上传表格的查询旁加来源行、口径行、接受理由、可见单元格里的区域外文字和人工录入的统计期;限定语写明这些数未经系统核对,长度有上限、截断时写明
- 隐藏行列里的区域外文字不送;设了遮罩的源只写条数

前端证据面板(run/EvidenceProvenance;ManifestBody 从版本页抽出复用)
- 「推断的来源」一节与已封存的证据分开标注:格子坐标、取自哪些格、年份来源、规范写法、回查、相关核对、数据版本与各期;标红与拒绝原因按状态显示

不变的部分
- 文档顶层加 provenance 标记,只有新报告走新路径;期 4 之前的运行、非上传源、手工源的面板、复核、裁判摘录逐字不变,由金样测试钉住
- 执行器给区域外文字记录是否在隐藏行列;不建「导入校验」系统表(D6)
2026-10-02 14:38:10 +08:00

534 lines
28 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.
"""期 4「推断的来源」的 SQL 判据:认出「从一张表里直接选取若干列」这一种形状(P4-SPEC 2.4、7.2)。
下钻要回答「查询结果的第 i 列是哪张表的哪一列」。只有一张表、每个结果列都是这张表某列的直接引用时,这个问题才有
唯一的答案;改名、表达式、多表、子查询、集合运算之后,结果列装的可能是另一列的值,值恰好相等时就会下钻到错误的
格子。所以这里只认一种形状,形状之外一律拒绝:**宁可不下钻,也不能下钻错**。
**读法和执行这条 SQL 的 SQLite 相同**:分词用守卫的 `guard._tokens(sql, guard._SQLITE)`。查询快照里存的 SQL 是
`guard.check(…, dialect="sqlite")` 的返回值,执行的也正是它(engine.run_query)。`_SQLITE` 读法:单引号字符串里
反斜杠不是转义;`[…]` 是标识符,里面没有转义,遇到第一个 `]` 就结束;`"…"`、`` `…` `` 是标识符,两个引号表示
一个引号。识别器和数据库读的是同一串记号,中间没有第二种读法。
**SQLite 分词的两处细节**(tokenize.c,实测 3.53):
- U+FEFF(字节 EF BB BF,CC_BOM)出现在记号开头时是空白,出现在名字中间时是名字的一部分。不能照「码位 ≥ 0x80
一律算名字」读:那样紧跟在 U+FEFF 后面的 UNION 成了一个普通的词,藏在它后面的 UNION ALL、GROUP BY 就看不见了,
SQLite 却照样执行,结果列里混着另一列的值;
- 竖制表符出现在记号开头时是非法字符,紧跟在其他空白后面时是空白(CC_SPACE 的循环用 sqlite3Isspace,含 \\v)。
这里一律当空白:SQLite 不当空白的位置整条 SQL 执行不了,不会留下查询快照,判据碰不到;能执行的位置读法相同。
**为什么不用 `evidence._sql_scan`**:它按多方言「宁可少认一张表」设计,单引号里认反斜杠转义、方括号里含引号时不当
标识符。藏在 `ESCAPE '\\'` 或 `AS [t']` 后面的 `UNION ALL` 在它眼里不存在,SQLite 却照样执行(P4-SPEC 附录 B7)。
对 `sql_tables` 那只是少认一张表,对下钻就是错格子。`_sql_scan`、`sql_tables` 本身不动(H7)。
**执行时与存下的字面不同的一处**:engine 经 SQLAlchemy 的 `text()` 执行,它把 `\\:` 改成 `:`、把 `:名字` 当成绑定
参数。后者没有给值,执行会失败,不会留下查询快照;前者只删掉冒号前的反斜杠,SQLite 读法里反斜杠不影响任何记号的
边界,只改字符串和带引号名字的内容,而配方导入的表名、列名不含反斜杠和冒号(names.name_problem),对不上任何表列。
**分组**(界面显示分组的原文,细分给测试和日志用,取值见 provenance_types.DETAILS):
- expression:表达式、聚合、字符串或字面量关键字、窗口、COLLATE、DISTINCT;
- alias:给列改名(含省略 AS 的改名);
- multi_table:JOIN、逗号连接、子查询、公用表表达式、集合运算;
- unparsed:不是 SELECT、多条语句、表的写法不认识、限定名不是本表、列或表找不到、重名、`*` 对不上、尾部写法不认识。
判据绑在文档标记 `DOC_PROVENANCE = 1` 上(P4-SPEC 1.4):以后放宽文法要升版本号、给 recognize 加 version 参数,
已有 `provenance: 1` 的文档仍按这一版判,结论不跟着变。
只许导入守卫、names 和 provenance_types(P4-SPEC 7.2):判据是纯函数,不连库,不依赖证据层和接口层。
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from app.data.guard import _SQLITE, _tokens
from app.data.names import name_key
from app.data.provenance_types import DirectSelect, SelectRefusal
# ==========================================================================
# 分词:守卫的 SQLite 读法之上,再按 SQLite 的规则把代码段切成词和标点
# ==========================================================================
#: SQLite 在记号之间认的空白:CC_SPACE 的五个,加竖制表符。不能用 str.isspace:全角空格、不换行空格在 SQLite 里是
#: 名字的一部分(除记号开头的 U+FEFF 之外,码位 ≥ 0x80 的字符一律算名字)。竖制表符出现在记号开头时 SQLite 报非法
#: 记号,紧跟其他空白时当空白;一律当空白是安全的:前一种位置执行不了,后一种读法相同(模块说明)
_SPACE = frozenset(" \t\n\v\f\r")
#: U+FEFF 只在记号开头是空白(CC_BOM),名字中间是名字的一部分(「日\ufeff期」是一个名字),所以不放进 _SPACE、
#: 只在 _split_code 判断记号开头的地方单独认
_BOM = "\ufeff"
_ASCII_LETTERS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
_ASCII_DIGITS = frozenset("0123456789")
def _name_start(ch: str) -> bool:
"""不加引号的名字的开头:ASCII 字母、下划线、码位 ≥ 0x80 的字符(同 SQLite,也同 2 版实体语法)。
U+FEFF 例外:出现在记号开头时 SQLite 当空白,调用处(_split_code)先把它判掉,轮不到这里。名字中间的 U+FEFF
仍算名字:_name_char 借这个函数认后续字符,所以这里不排除它。"""
return ch in _ASCII_LETTERS or ch == "_" or ord(ch) >= 0x80
def _name_char(ch: str) -> bool:
"""名字的后续字符:开头字符之外再加 ASCII 数字和 $(SQLite 的 IdChar)。不用 isalnum:「٣」这类非 ASCII
数字本来就 ≥ 0x80,算名字;ASCII 以外的 isdigit 不能决定切词。"""
return _name_start(ch) or ch in _ASCII_DIGITS or ch == "$"
@dataclass(frozen=True)
class _Lex:
#: word:不加引号的词;name:带引号的名字("…" `…` […]);str:字符串;blob:x'…';num:数字;punct:标点;
#: sep:分号
kind: str
#: word、num、punct 是原文;name、str 是去掉定界符、还原了成对引号的内容
text: str
#: 在原 SQL 里的结束位置:认 x'…' 时要知道 x 和引号之间有没有空白
end: int
def _split_code(text: str, base: int, out: list[_Lex]) -> None:
"""把一段代码(守卫已经把注释、字符串、带引号的名字、分号切出去了)按 SQLite 的规则切成词、数字和标点。
数字只吃数字和小数点:`1e5`、`0x1F` 会被切成「数字 + 词」,`1union` 也会切出 UNION。SQLite 遇到数字后紧跟
名字字符会报非法记号、根本不执行,所以这样切只会让判据多看见词、多拒,不会少看见。
循环顶层的每个位置都是一个记号的开头(代码段的开头紧跟在注释、字符串、带引号的名字或分号后面,也是):
U+FEFF 在这里当空白,同 SQLite 的 CC_BOM;进了名字以后由 _name_char 当名字字符吃掉。
"""
i, n = 0, len(text)
while i < n:
ch = text[i]
if ch in _SPACE or ch == _BOM:
i += 1
elif _name_start(ch):
j = i + 1
while j < n and _name_char(text[j]):
j += 1
out.append(_Lex("word", text[i:j], base + j))
i = j
elif ch in _ASCII_DIGITS or (ch == "." and i + 1 < n and text[i + 1] in _ASCII_DIGITS):
j = i + 1
while j < n and (text[j] in _ASCII_DIGITS or text[j] == "."):
j += 1
out.append(_Lex("num", text[i:j], base + j))
i = j
else:
out.append(_Lex("punct", ch, base + i + 1))
i += 1
def _lex(sql: str, *, strict: bool = True) -> list[_Lex] | None:
"""整条 SQL → 词序列。引号没闭合时 strict 返回 None(SQLite 会报非法记号,存下的 SQL 不该这样);
不 strict 时照样切,没闭合的那一段内容取到末尾(tables_in 宁可多认)。
注释当空白跳过:SQLite 也这样读,注释两边的代码各自成词(`SEL/**/ECT` 是两个词)。
"""
out: list[_Lex] = []
pos = 0
for tok in _tokens(sql, _SQLITE):
start, pos = pos, pos + len(tok.text)
if tok.kind == "comment":
continue
if tok.kind == "sep":
out.append(_Lex("sep", ";", pos))
elif tok.kind in ("string", "ident"):
if tok.close == 0 and strict:
return None
inner = tok.text[tok.open:len(tok.text) - tok.close]
quote = tok.text[0]
if tok.kind == "string":
inner = inner.replace("''", "'")
prev = out[-1] if out else None
# x'00':紧挨在单独一个 x 后面的单引号串是 BLOB 字面量,不是「名字 x 加字符串别名」
if prev is not None and prev.kind == "word" and prev.text in ("x", "X") and prev.end == start:
out[-1] = _Lex("blob", inner, pos)
else:
out.append(_Lex("str", inner, pos))
else:
# 方括号里没有转义;双引号、反引号里两个引号表示一个
if quote in "\"`":
inner = inner.replace(quote * 2, quote)
out.append(_Lex("name", inner, pos))
else:
_split_code(tok.text, start, out)
return out
def _kw(lex: _Lex | None) -> str | None:
"""不加引号的词按关键字比较时用的键(只对 ASCII 不区分大小写,同 SQLite);不是词返回 None。
带引号的名字永远不是关键字:`"union"` 是一个叫 union 的列。
"""
return name_key(lex.text) if lex is not None and lex.kind == "word" else None
def _is_name(lex: _Lex | None) -> bool:
return lex is not None and lex.kind in ("word", "name")
# ==========================================================================
# 关键字表
# ==========================================================================
#: P4-SPEC 2.4:尾部(以及整条语句里)不得出现的词,按细分归组。七组的并集就是规格的那张表,测试钉住
_COMPOUND = frozenset({"union", "intersect", "except"})
_CTE = frozenset({"with", "recursive"})
_SUBQUERY = frozenset({"select", "values"})
_JOIN = frozenset({"join", "natural", "cross", "inner", "left", "right", "full", "outer"})
_WINDOW = frozenset({"over", "window"})
#: GROUP BY / HAVING:结果是聚合出来的
_AGGREGATE = frozenset({"group", "having"})
#: 尾部的 FROM、DISTINCT 只会出现在 `IS [NOT] DISTINCT FROM` 这类写法里:不影响结果列,但超出这一版文法
_TAIL_ODD = frozenset({"from", "distinct"})
FORBIDDEN = _COMPOUND | _CTE | _SUBQUERY | _JOIN | _WINDOW | _AGGREGATE | _TAIL_ODD
#: 不加引号时是字面量的关键字(P4-SPEC 2.4)。CURRENT_* 永远是关键字;TRUE、FALSE 只在表里有同名列时才指那一列,
#: 读法取决于表结构,一律拒最保守
_LITERAL_KEYWORDS = frozenset({"null", "true", "false", "current_date", "current_time", "current_timestamp"})
#: SQLite 的保留字里不能当不加引号的名字用的那些:出现在选取项、表名的位置,这一项就是表达式(CASE …、NOT x、
#: EXISTS (…))或者写法不对。CAST、RAISE、MATCH、GLOB、END、FILTER 之类不在里面:SQLite 允许它们当名字(fallback),
#: 后面紧跟「(」时由下一步按函数调用拒,单独出现、又真有这一列时它就是那一列
_EXPR_WORDS = frozenset({
"case", "not", "exists", "when", "then", "else", "select", "distinct", "all",
"from", "where", "group", "having", "order", "limit", "as", "on", "using",
"and", "or", "is", "in", "between", "escape", "collate", "isnull", "notnull",
})
#: 选取项后面紧跟这些词:这一项是运算的一部分(`"日期" IS NULL`、`"a" BETWEEN …`),不是省略 AS 的改名
_OPERATOR_WORDS = frozenset({
"is", "isnull", "notnull", "not", "and", "or", "in", "like", "glob", "regexp", "match", "between",
"escape", "filter",
})
#: 表名后面紧跟这些词时它们不是表别名:子句开头、连接、`INDEXED BY` / `NOT INDEXED`、ON / USING
_AFTER_TABLE = frozenset({
"where", "order", "limit", "group", "having", "window", "indexed", "not", "on", "using", "as",
*_JOIN, *_COMPOUND,
})
#: 尾部允许的开头(P4-SPEC 2.4 文法):只过滤、排序、截取行,不改变结果列的来历
_TAIL_START = frozenset({"where", "order", "limit"})
def _refuse(code: str, detail: str) -> SelectRefusal:
return SelectRefusal(code, detail) # type: ignore[arg-type]
# ==========================================================================
# recognize
# ==========================================================================
def _statement(lexes: list[_Lex]) -> list[_Lex] | SelectRefusal:
"""去掉尾部的分号;分号后面还有东西就是多条语句。
存下的 SQL 是 guard.check 的返回值,本来就没有分号和注释。P4-SPEC 7.2 写的是「出现就按 multi_statement 拒」,
但 2.4 的文法写了 `[;]`,原型的 24 例(期望不变)和 6.3 要求注释里的 JOIN、尾分号照样认可,两处矛盾。这里取
后者,按 SQLite 的读法接受尾分号、把注释当空白:两者都不改变这条语句选了什么,读法又和执行时相同,不会因此
认错;真正的第二条语句照样拒。
"""
end = len(lexes)
while end and lexes[end - 1].kind == "sep":
end -= 1
body = lexes[:end]
if any(lx.kind == "sep" for lx in body):
return _refuse("unparsed", "multi_statement")
return body
def _global_shape(body: list[_Lex]) -> SelectRefusal | None:
"""整条语句里只要出现就不是「一张表的直接选取」的关键字:集合运算、公用表表达式、子查询、连接、窗口。
先于逐项的形状判断:B7 那类写法(`… ESCAPE '\\' UNION ALL …`)不管前面哪一项长什么样,都要报成集合运算。
词只取代码段里的:字符串、带引号的名字、注释里出现的不算,和 SQLite 的读法一样。
"""
words = {_kw(lx) for lx in body[1:] if lx.kind == "word"}
for group, detail, code in ((_COMPOUND, "compound", "multi_table"), (_CTE, "cte", "multi_table"),
(_SUBQUERY, "subquery", "multi_table"), (_JOIN, "join", "multi_table"),
(_WINDOW, "window", "expression")):
if words & group:
return _refuse(code, detail)
return None
@dataclass
class _Item:
qualifier: str | None
column: str
def _item_start(lex: _Lex | None) -> SelectRefusal | None:
"""选取项的第一个记号不是名字时,这一项是什么。是名字返回 None。"""
if lex is None:
return _refuse("unparsed", "not_select")
if lex.kind == "str":
return _refuse("expression", "string_literal")
kw = _kw(lex)
if kw in _LITERAL_KEYWORDS:
return _refuse("expression", "literal_keyword")
if kw in _EXPR_WORDS or not _is_name(lex):
# 数字、blob、括号、正负号、CASE / CAST / NOT …
return _refuse("expression", "expression")
return None
def _after_item(lex: _Lex | None) -> SelectRefusal:
"""选取项后面只能紧跟「,」或 FROM(P4-SPEC 2.4)。其余的分别是什么。"""
kw = _kw(lex)
if lex is None:
# 没有 FROM:不是从表里选
return _refuse("unparsed", "table_shape")
if kw == "as":
return _refuse("alias", "alias")
if kw == "collate":
return _refuse("expression", "collate")
if kw in _OPERATOR_WORDS:
return _refuse("expression", "expression")
if lex.kind in ("name", "str") or (lex.kind == "word" and kw not in _EXPR_WORDS):
# `"日期" "分区乙"`、`"日期" '分区乙'`、`"日期" 别名`:SQLite 都当省略 AS 的改名
return _refuse("alias", "alias")
# 「(」是函数调用,其余标点、数字是运算
return _refuse("expression", "expression")
def _select_list(body: list[_Lex], k: int) -> tuple[list[_Item] | None, int] | SelectRefusal:
"""解析选取项,返回 (项列表,FROM 的位置)。`*` 单独出现时项列表为 None。"""
if k < len(body) and body[k].kind == "punct" and body[k].text == "*":
nxt = body[k + 1] if k + 1 < len(body) else None
if _kw(nxt) != "from":
if nxt is not None and nxt.kind == "punct" and nxt.text == ",":
# `*, "日期"`:* 只许单独出现,否则结果列和表的列对不上位置
return _refuse("unparsed", "star_mismatch")
return _after_item(nxt)
return None, k + 1
items: list[_Item] = []
while True:
lex = body[k] if k < len(body) else None
if lex is not None and lex.kind == "punct" and lex.text == "*":
# `"日期", *`
return _refuse("unparsed", "star_mismatch")
refusal = _item_start(lex)
if refusal is not None:
return refusal
assert lex is not None
k += 1
qualifier: str | None = None
column = lex.text
if k < len(body) and body[k].kind == "punct" and body[k].text == ".":
target = body[k + 1] if k + 1 < len(body) else None
if target is not None and target.kind == "punct" and target.text == "*":
# `t.*`:不在这一版文法里
return _refuse("unparsed", "star_mismatch")
if not _is_name(target):
return _refuse("expression", "expression")
assert target is not None
qualifier, column = lex.text, target.text
k += 2
if k < len(body) and body[k].kind == "punct" and body[k].text == ".":
# `main.日客流.日期`:带库名的限定名
return _refuse("unparsed", "qualifier")
items.append(_Item(qualifier, column))
nxt = body[k] if k < len(body) else None
if nxt is not None and nxt.kind == "punct" and nxt.text == ",":
k += 1
continue
if _kw(nxt) == "from":
return items, k
return _after_item(nxt)
def _from_clause(body: list[_Lex], k: int) -> tuple[str, str | None, int] | SelectRefusal:
"""FROM 后面:表名、可选的表别名。返回 (表名,别名,尾部的起点)。"""
lex = body[k] if k < len(body) else None
if lex is not None and lex.kind == "punct" and lex.text == "(":
# FROM (SELECT …);`FROM (日客流)` 在 SQLite 里也能执行,一律不认
return _refuse("multi_table", "subquery")
if not _is_name(lex) or _kw(lex) in _AFTER_TABLE | _EXPR_WORDS:
# 字符串当表名(SQLite 的历史兼容)、数字、缺表名
return _refuse("unparsed", "table_shape")
assert lex is not None
table = lex.text
k += 1
nxt = body[k] if k < len(body) else None
if nxt is not None and nxt.kind == "punct" and nxt.text == ".":
return _refuse("unparsed", "qualified_table")
if nxt is not None and nxt.kind == "punct" and nxt.text == "(":
# 表函数(json_each(…))
return _refuse("unparsed", "table_shape")
alias: str | None = None
if _kw(nxt) == "as":
target = body[k + 1] if k + 1 < len(body) else None
if not _is_name(target) or _kw(target) in _AFTER_TABLE | _EXPR_WORDS:
# `AS '别名'`:SQLite 认字符串当别名,这一版文法不认
return _refuse("unparsed", "table_shape")
assert target is not None
alias = target.text
k += 2
elif nxt is not None and (nxt.kind == "name"
or (nxt.kind == "word" and _kw(nxt) not in _AFTER_TABLE | _EXPR_WORDS)):
alias = nxt.text
k += 1
return table, alias, k
def _tail(body: list[_Lex], k: int) -> SelectRefusal | None:
"""尾部只能以 WHERE / ORDER / LIMIT 开头,里面不得有 P4-SPEC 2.4 那张表里的词。
整条语句里的集合运算、子查询、连接、窗口已经在 _global_shape 拦了,这里只剩 GROUP / HAVING(聚合)和尾部的
FROM、DISTINCT。
"""
if k >= len(body):
return None
head = body[k]
kw = _kw(head)
if kw not in _TAIL_START:
if head.kind == "punct" and head.text == ",":
return _refuse("multi_table", "comma_join")
if kw in _AGGREGATE:
return _refuse("expression", "expression")
# INDEXED BY、NOT INDEXED、第二个别名、括号……
return _refuse("unparsed", "tail_shape")
words = {_kw(lx) for lx in body[k:] if lx.kind == "word"}
if words & _AGGREGATE:
return _refuse("expression", "expression")
if words & _TAIL_ODD:
return _refuse("unparsed", "tail_shape")
return None
def _index(names: Iterable[str]) -> dict[str, str | None]:
"""name_key → 原名。两个名字撞了键(冻结表结构里不该出现)时记 None:说不清是哪一个,按找不到处理。"""
out: dict[str, str | None] = {}
for name in names:
key = name_key(name)
out[key] = None if key in out else name
return out
def recognize(sql: str, *, result_columns: list[str], tables: dict[str, list[str]]
) -> DirectSelect | SelectRefusal:
"""tables:冻结表结构里的 {表名: [列名…]}(按定义顺序)。
认可(P4-SPEC 2.4 的文法)→ DirectSelect;否则 → SelectRefusal(code, detail)。纯函数,不连库,不抛异常。
文法对应 DOC_PROVENANCE = 1(1.4);以后放宽文法时加 version 参数,不改这一版的结论。
判断顺序:分词和语句个数 → 是不是 SELECT(WITH 开头是公用表表达式)→ 整条语句里的集合运算、公用表表达式、
子查询、连接、窗口 → 选取项 → FROM → 尾部 → 名字解析(表、限定名、列、重名)→ 结果列与选取项逐个对应。
"""
lexes = _lex(sql)
if lexes is None:
return _refuse("unparsed", "not_select")
body = _statement(lexes)
if isinstance(body, SelectRefusal):
return body
first = _kw(body[0]) if body else None
if first == "with":
return _refuse("multi_table", "cte")
if first != "select":
return _refuse("unparsed", "not_select")
refusal = _global_shape(body)
if refusal is not None:
return refusal
k = 1
if _kw(body[k] if k < len(body) else None) == "all":
k += 1
if _kw(body[k] if k < len(body) else None) == "distinct":
return _refuse("expression", "distinct")
parsed = _select_list(body, k)
if isinstance(parsed, SelectRefusal):
return parsed
items, k = parsed
shape = _from_clause(body, k + 1)
if isinstance(shape, SelectRefusal):
return shape
table_written, alias, k = shape
refusal = _tail(body, k)
if refusal is not None:
return refusal
# ---- 名字解析:一律按 name_key(只对 ASCII 不区分大小写,同 SQLite) ----
table = _index(tables).get(name_key(table_written))
if table is None:
return _refuse("unparsed", "unknown_table")
defined = list(tables[table])
if items is None:
# `*`:结果列必须和表的列按 name_key 逐个相等、顺序一致,才能按位置对应
if [name_key(c) for c in result_columns] != [name_key(c) for c in defined]:
return _refuse("unparsed", "star_mismatch")
return DirectSelect(table, alias, defined)
# 有别名时只认别名:SQLite 里表起了别名之后,原表名不能再当限定名(`SELECT 日客流.日期 FROM 日客流 AS t`
# 报 no such column),所以这一条比「表名或别名」严,严出来的只是执行不了的写法
owner = name_key(alias if alias is not None else table_written)
if any(it.qualifier is not None and name_key(it.qualifier) != owner for it in items):
return _refuse("unparsed", "qualifier")
columns_index = _index(defined)
columns: list[str] = []
for it in items:
column = columns_index.get(name_key(it.column))
if column is None:
return _refuse("unparsed", "unknown_column")
if column in columns:
# `SELECT "日期", "日期"`、`SELECT Key, key`:locate_cell 按第一次出现取,说不清是哪一个
return _refuse("unparsed", "ambiguous_column")
columns.append(column)
# 结果列按位置对应第 i 个选取项,不按名字认(SQLite 返回的列名有时是原写法、有时是定义名,P4-SPEC 2.4)。
# 个数必须相等;名字只作一致性核对:直接引用的列,SQLite 返回的名字按 name_key 一定等于那一列,
# 不等说明这份结果不是这条 SQL 的,不下钻
if len(result_columns) != len(columns) or any(
name_key(r) != name_key(c) for r, c in zip(result_columns, columns)):
return _refuse("unparsed", "unknown_column")
return DirectSelect(table, alias, columns)
# ==========================================================================
# tables_in
# ==========================================================================
#: 这些词后面紧跟的单引号串,SQLite 当表名读(`FROM '日客流'` 能执行,历史兼容)。IN:SQLite 文法 `expr IN nm`
#: 的 nm 可以是字符串,`WHERE 日期 IN '单列表'` 读的是那张表(列数和左边对得上就能执行,行值也行)
_TABLE_BEFORE_STRING = frozenset({"from", "join", "in"})
#: 逗号后面的单引号串当表名读的子句:FROM 列表里(含 ON / USING 之后接着逗号连接)
_TABLE_LIST_CLAUSES = frozenset({"from", "join", "on", "using"})
#: 切换子句的词:tables_in 据此判断逗号后面是不是还在 FROM 列表里
#: 不含 AS:`FROM a AS x, '时段客流'` 的逗号仍在 FROM 列表里
_CLAUSE_WORDS = frozenset({
"select", "from", "join", "where", "group", "having", "order", "limit", "on", "using", "window", "values",
"union", "intersect", "except", "with",
})
def tables_in(sql: str, tables: dict[str, list[str]]) -> list[str]:
"""SQL 里出现的冻结表结构的表名(原样),按第一次出现的顺序去重。读法同 recognize,宁可多认(P4-SPEC 2.4)。
每个带引号的名字、代码里的每个词都按 name_key 对表名:列名或别名恰好和某张表同名时会多出这张表,后果只是
摘录里多一行说明;漏认的后果是裁判拿不到「原表写明的合计,不要相加」。不用 2 版的 `sql_tables`:它的读法和
SQLite 不同,藏在 `ESCAPE '\\'` 后面的合计表会漏掉。
字符串一般不算(`WHERE 分区 = '日客流'` 不是读表),只有 SQLite 会把它当表名读的位置才算:紧跟 FROM / JOIN /
IN、紧跟「.」(`main.'日客流'`),以及 FROM 列表里的逗号、括号之后。「紧跟」按 SQLite 的分词算:中间隔着的空白
(含记号开头的 U+FEFF、空白后面的竖制表符)不算记号。注释里的不算:SQLite 不读注释。
"""
index = _index(tables)
found: dict[str, None] = {}
lexes = _lex(sql, strict=False) or []
clause: str | None = None
prev: _Lex | None = None
for lx in lexes:
candidate: str | None = None
if lx.kind in ("word", "name"):
candidate = lx.text
elif lx.kind == "str" and prev is not None:
pkw = _kw(prev)
if (pkw in _TABLE_BEFORE_STRING or (prev.kind == "punct" and prev.text == ".")
or (prev.kind == "punct" and prev.text in ",(" and clause in _TABLE_LIST_CLAUSES)):
candidate = lx.text
if candidate is not None:
table = index.get(name_key(candidate))
if table is not None:
found.setdefault(table, None)
kw = _kw(lx)
if kw in _CLAUSE_WORDS:
clause = kw
prev = lx
return list(found)