【NL2SQL 实战 05】sqlglot 这把刀:把 SQL 当结构处理,安全校验才不靠碰运气

上一篇讲了越权评测。评测里反复出现一个名字: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 时按什么生成"是两件事。

不传或传错方言会怎样? 两类后果:

  1. 读不懂。 Oracle 的 FETCH FIRST ... ROWS ONLY、SQL Server 的 TOP,如果解析方言不对,AST 结构就偏,后面的 LIMIT 校验也跟着偏。
  2. 写错样子。 解析勉强过了,回写却按错误方言生成,落到真实数据库执行时报语法错。

真正危险的不是"报错",而是**"你以为已经做了安全改写,实际上改写的是另一门 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 是 TOPFETCH

如果解析时按 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:结果)

认可的做法:

  1. sqlglot 版本要钉。 它更新很快,AST 结构偶尔会变------"升级 parser"不是纯内部重构,而是可能影响 SQL 安全闸门行为的变更。
  2. 解析和回写统一封装。 所有调用方都从同一个上下文拿方言,不让业务代码到处手写 sqlglot.parse_one(..., read="mysql")
  3. 未注册引擎要保守回退。 当前实现里,没注册的库类型会保守回退到 MySQL 语法标签------不完美,但比整条链路直接崩掉可控。
  4. 别把 sqlglot 当成安全本身。 它解决的是"把 SQL 当结构理解",不是"只要 parse 成功就说明安全"。真正的安全策略来自只读约束、表白名单、列 deny、行级权限和执行前校验。Parser 是基础设施,不是免责条款。

还没完全解决的地方:

  1. SELECT * 与敏感列之间的缝。 * 不是具体列名,当前列校验不会把它展开到真实列集合------某张表带敏感列时,SELECT * 的约束还不够硬。
  2. 模型吐半截 SQL 时的可解释性。 解析报错现在被包成 PARSE_ERROR,对终端用户还不够友好。
  3. Doris / StarRocks 这类 MySQL 兼容引擎。 现在借 MySQL 方言跑通主路径,但"兼容"不等于"等价"。

这些我认了,以后补。


带走三句

  • sqlglot 在 NL2SQL 里不是装饰品,是 SQL 安全闸门和改写的基础设施------它让校验不靠碰运气
  • SQL 方言必须从真实业务库上下文统一下发,解析和回写要始终站在同一门语言里
  • AST 很强,但节点名字不等于业务语义:Table 不一定是物理表,Column 也不一定是物理列,校验前先建排除集。

开源仓库在下面,本地可以跑通(Compose + Fixture,不必先配云钥匙)。说明看 docs/DEMO.md / AGENTS.md

GitHub: github.com/yanqiuping1...

相关推荐
有来技术43 分钟前
youlai-boot 实战:MinIO 停止维护,Docker 迁移 RustFS 完整记录
java·后端·docker
Lyy44 分钟前
DevOps平台 — 第七篇:我的项目与表格组件抽取
后端·devops
唐青枫1 小时前
别只会写 fn:Zig 函数、错误处理、泛型与回调实战
后端
Rain的Java大神之路1 小时前
Docker搭建Redis集群完全指南
java·运维·redis·后端·docker·容器·架构
jj_ccwgw1 小时前
Kafka KRaft 多机集群安装指南
后端
小蒜学长1 小时前
基于Django的社区团购购物平台的设计与实现(代码+数据库+LW)
数据库·后端·python·django
灯澜忆梦1 小时前
【基于GO的Web开发9】gin框架返回json
前端·后端·golang·gin
kymjs张涛2 小时前
DeepSeek Harness源码分析:非 AI 开发者的 Agent 学习总结
前端·后端·面试
jj_ccwgw2 小时前
UOS系统 Kafka KRaft 集群安装指南
后端