上一篇讲了越权评测。评测里反复出现一个名字:sqlglot。B1 也提过:正则土但快,AST 干净但有坑。
这篇把「为什么必须用 AST」讲透。不是 sqlglot 教程,而是回答一个问题:
在企业级 NL2SQL 里,为什么 SQL 安全校验和改写不能靠字符串碰运气?
一句话结论放最前面:
模型吐出来的是字符串,但你必须把它当结构来审查。 字符串判断挡得住 80% 的脏 SQL,挡不住剩下那 20% 出事的。
仓库在这: github.com/yanqiuping1...
一、先说为什么正则不够(S:情境)
很多团队第一次做 Chat-to-SQL,会先写一层正则:判断是不是 SELECT、有没有 LIMIT、有没有敏感表。Demo 阶段大多能跑,接到真实业务库就开始露馅。
举几个真实会碰到的:
WITH x AS (SELECT ...) SELECT * FROM x------正则看开头是SELECT?不,开头是WITH,直接误杀;SELECT * FROM update_log------表名里含UPDATE,\bUPDATE\b虽然不会误伤,但换个写法呢;SELECT a, (SELECT MAX(b) FROM t) AS m FROM t------子查询里嵌着别的表,字符串切割切不动;- Oracle 的分页是
FETCH FIRST 100 ROWS ONLY,SQL Server 是TOP 100------正则根本不知道这些是一个意思。
问题本质: 字符串判断只能看"样子",看不到"结构"。而 SQL 安全关心的恰恰是结构------最外层是不是查询、FROM 里引用了哪些物理表、有没有写库动作。
二、把 SQL 当结构处理:四件事(T:任务)
我们把 sqlglot 当执行前的结构化理解层,主要做四件事,没有一件是"把 SQL 打印得更漂亮":
| 用途 | 不用 AST 会怎样 |
|---|---|
判断最外层是不是 SELECT |
WITH ... SELECT、嵌套查询很容易骗过正则 |
| 提取物理表名做白名单 | CTE、别名、子查询一多,字符串切割马上变脆 |
强制追加 / 压回 LIMIT |
字符串补尾巴遇到子查询和不同方言很容易出错 |
| 校验列是否真实存在 / 是否命中敏感列 | 别名、表达式列、中文别名很容易被误杀 |
还有一件是行级范围注入(C 系列会讲):要在 WHERE 上补条件、处理括号和 AND 优先级。字符串拼接也能做,但做着做着你就会发现------自己其实在手写半个 parser。
一句话概括:sqlglot 在这里不是"美化 SQL",而是让 SQL 安全校验和 SQL 改写不靠碰运气。
三、方言标签必须统一下发(A:行动)
sqlglot 的解析(parse)和回写(render)都依赖方言信息。问数系统接的业务库不止一种:MySQL、PostgreSQL、SQL Server、Oracle、ClickHouse、Doris、StarRocks,还有 Excel(走 SQLite 语义)。它们的 LIMIT 写法、保留字、版本能力都不一样。
关键设计:方言标签的唯一来源,必须从真实业务库上下文来,不允许业务代码自己随便传字符串。
我把它收口到一个叫 ResolvedSqlContext 的上下文对象里:每个连接器注册自己的方言(MySQL 用 mysql、Oracle 用 oracle、SQL Server 用 tsql),解析和回写分开存------因为"读 SQL 时按什么理解"和"回写 SQL 时按什么生成"是两件事。
不传或传错方言会怎样? 两类后果:
- 读不懂。 Oracle 的
FETCH FIRST ... ROWS ONLY、SQL Server 的TOP,如果解析方言不对,AST 结构就偏,后面的 LIMIT 校验也跟着偏。 - 写错样子。 解析勉强过了,回写却按错误方言生成,落到真实数据库执行时报语法错。
真正危险的不是"报错",而是**"你以为已经做了安全改写,实际上改写的是另一门 SQL 语言"**。用户只会看到"这个问数系统有时能跑、有时报数据库语法错",而不会想到是方言上下文错了。
四、三个真实踩坑:AST 节点名 ≠ 业务语义(A:行动)
坑一:CTE 别名混进了白名单校验
模型写了:
sql
WITH punch AS (
SELECT * FROM sport_activity_qzs_time WHERE sch_id = 123
)
SELECT COUNT(*) FROM punch
第一版白名单校验直接遍历所有"表节点"提取表名。问题来了:punch 既是 CTE 定义的别名,也会出现在 FROM 子句里------AST 里叫 Table 的节点,不等于物理表。 合法查询被误判成 TABLE_NOT_ALLOWED。
修法不复杂:先收集 CTE 别名,再把这些名字从物理表集合里排掉。这是全文唯一一段代码,因为它最能说明"结构理解"和"字符串判断"的差别:
python
# backend/app/sql/guard.py(节选)
def _extract_tables(parsed):
cte_names = {str(c.alias_or_name).lower() for c in parsed.find_all(exp.CTE)}
return {n.name.lower() for n in parsed.find_all(exp.Table)
if n.name and n.name.lower() not in cte_names}
这一坑的教训:AST 里的 Table 节点不全是物理表,find_all() 出来的结果不能直接拿去做白名单。
坑二:LIMIT 改写的方言差异
闸门的规矩很直接:没写 LIMIT 就补,写超了就压回。但"限制结果集"在 sqlglot 里是一个语义动作,不是一段字符串:
- MySQL 渲染成
LIMIT 100; - Oracle 渲染成
FETCH FIRST 100 ROWS ONLY; - SQL Server 是
TOP或FETCH。
如果解析时按 Oracle 读、回写时却按 MySQL 写,最后的 SQL 看起来"也挺合理",扔到 Oracle 就是语法错误。这个坑最烦的地方在于:不是 guard 完全没工作,而是 guard 工作过了,只是用错了语言。
坑三:列校验和输出别名
模型很喜欢写 SELECT COUNT(*) AS 总人数 ... ORDER BY 总人数。ORDER BY 总人数 里的"总人数"不是物理列,是输出别名------但在 AST 里它依然可能表现成"列节点"。如果直接拿别名去元数据里查,合法 SQL 就被误杀成 COLUMN_NOT_FOUND。
修法同样是先收集输出别名、校验时豁免。中文别名(AS 参与人数、AS 学校名称)也要一起处理。
这一坑的教训:AST 里的 Column 节点也不全是物理列。别名、表达式结果、排序引用都会混进来,校验前要先建立排除集。
五、方言还会反过来影响 Prompt(A:行动)
方言感知不止影响闸门,还会影响模型"倾向写什么"。那个方言上下文对象里有两个能力位:是否支持 CTE、是否支持窗口函数。它们不是摆设------会真的拼进 Prompt:
- 支持 → 提示模型"可用 CTE / 窗口函数做分路聚合";
- 不支持(如 MySQL 5.7)→ 提示模型"必须以汇聚表为 FROM 主表,每个来源表用独立标量子查询聚合"。
一个很现实的例子:MySQL 5.7 不支持 CTE 和窗口函数,即使 parser 能读懂这些语法,真实数据库也未必执行得了。所以生成前先约束(Prompt),生成后再兜底(闸门),两边都要做。
整条链路是:探测版本 → 计算能力集合 → 影响 Prompt 提示 → 影响读写方言 → 闸门按方言解析 / 回写。中间任何一环断掉,要么模型写了目标库不支持的 SQL,要么系统改写出了另一种方言的 SQL。
六、我现在认可的用法,和还没完全解决的地方(R:结果)
认可的做法:
sqlglot版本要钉。 它更新很快,AST 结构偶尔会变------"升级 parser"不是纯内部重构,而是可能影响 SQL 安全闸门行为的变更。- 解析和回写统一封装。 所有调用方都从同一个上下文拿方言,不让业务代码到处手写
sqlglot.parse_one(..., read="mysql")。 - 未注册引擎要保守回退。 当前实现里,没注册的库类型会保守回退到 MySQL 语法标签------不完美,但比整条链路直接崩掉可控。
- 别把
sqlglot当成安全本身。 它解决的是"把 SQL 当结构理解",不是"只要 parse 成功就说明安全"。真正的安全策略来自只读约束、表白名单、列 deny、行级权限和执行前校验。Parser 是基础设施,不是免责条款。
还没完全解决的地方:
SELECT *与敏感列之间的缝。*不是具体列名,当前列校验不会把它展开到真实列集合------某张表带敏感列时,SELECT *的约束还不够硬。- 模型吐半截 SQL 时的可解释性。 解析报错现在被包成
PARSE_ERROR,对终端用户还不够友好。 - Doris / StarRocks 这类 MySQL 兼容引擎。 现在借 MySQL 方言跑通主路径,但"兼容"不等于"等价"。
这些我认了,以后补。
带走三句
sqlglot在 NL2SQL 里不是装饰品,是 SQL 安全闸门和改写的基础设施------它让校验不靠碰运气;- SQL 方言必须从真实业务库上下文统一下发,解析和回写要始终站在同一门语言里;
- AST 很强,但节点名字不等于业务语义:
Table不一定是物理表,Column也不一定是物理列,校验前先建排除集。
开源仓库在下面,本地可以跑通(Compose + Fixture,不必先配云钥匙)。说明看 docs/DEMO.md / AGENTS.md。
GitHub: github.com/yanqiuping1...