上一篇讲了越权评测。评测里反复出现一个名字:sqlglot。这篇回答一个更基本的问题——
在企业级 NL2SQL 里,为什么 SQL 安全校验和改写不能靠字符串碰运气?
一句话结论放最前面:模型吐出来的是字符串,但你必须把它当结构来审查。 字符串判断挡得住 80% 的脏 SQL,挡不住剩下那 20% 出事的。
一、为什么正则不够(情境)
很多团队第一次做 Chat-to-SQL,会先写一层正则:判断是不是 SELECT、有没有 LIMIT、有没有敏感表。Demo 阶段大多能跑,接到真实业务库就开始露馅:
WITH x AS (SELECT ...) SELECT * FROM x——正则看开头是WITH,直接误杀;- 子查询里嵌着别的表——字符串切割切不动;
- Oracle 分页是
FETCH FIRST 100 ROWS ONLY,SQL Server 是TOP 100——正则不知道这些是一个意思。
问题本质: 字符串判断只能看"样子",看不到"结构"。而 SQL 安全关心的恰恰是结构——最外层是不是查询、FROM 里引用了哪些物理表、有没有写库动作。
二、把 SQL 当结构处理:四件事(任务)
我们把 sqlglot 当执行前的结构化理解层,主要做四件事:
| 用途 | 不用 AST 会怎样 |
|---|---|
判断最外层是不是 SELECT | WITH ... SELECT、嵌套查询很容易骗过正则 |
| 提取物理表名做白名单 | CTE、别名、子查询一多,字符串切割马上变脆 |
强制追加 / 压回 LIMIT | 字符串补尾巴遇到子查询和不同方言很容易出错 |
| 校验列是否真实存在 / 命中敏感列 | 别名、表达式列、中文别名很容易被误杀 |
还有一件是行级范围注入(C 系列会讲):要在 WHERE 上补条件。字符串拼接也能做,但做着做着你就会发现——自己其实在手写半个 parser。
一句话概括:sqlglot 在这里不是"美化 SQL",而是让 SQL 安全校验和 SQL 改写不靠碰运气。
三、方言标签必须统一下发(关键设计)
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、SQL Server 的 TOP,解析方言不对,AST 结构就偏,LIMIT 校验跟着偏);二是写错样子(解析勉强过了,回写却按错误方言生成,落到真实库报语法错)。
真正危险的不是报错,而是你以为已经做了安全改写,实际上改写的是另一门 SQL 语言。
四、三个真实踩坑:AST 节点名 ≠ 业务语义
三个坑有个共同主题,也是最值得带走的一条经验。
坑一:CTE 别名混进了白名单校验。 模型写 WITH punch AS (...) SELECT COUNT(*) FROM punch,punch 既是 CTE 定义的别名,也会出现在 FROM 子句里——AST 里叫 Table 的节点,不等于物理表。 合法查询被误判成 TABLE_NOT_ALLOWED。修法:先收集 CTE 别名,再排除。这是全文唯一一段代码,它最能说明"结构理解"和"字符串判断"的差别:
# 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}
坑二:LIMIT 改写的方言差异。 "限制结果集"在 sqlglot 里是一个语义动作,不是一段字符串:MySQL 渲染成 LIMIT 100,Oracle 渲染成 FETCH FIRST 100 ROWS ONLY,SQL Server 是 TOP。解析按 Oracle 读、回写却按 MySQL 写,最后的 SQL 看起来合理,扔到 Oracle 就是语法错误。这个坑最烦的地方:不是闸门没工作,而是闸门工作过了、只是用错了语言。
坑三:列校验和输出别名。 SELECT COUNT(*) AS 总人数 ... ORDER BY 总人数——ORDER BY 里的"总人数"不是物理列,是输出别名,但 AST 里依然可能表现成"列节点",直接查元数据就误杀。修法:先收集输出别名,校验时豁免。中文别名(AS 参与人数)同理。
三个坑合起来一句话:AST 里的 Table 节点不全是物理表,Column 节点也不全是物理列。校验前先建立排除集。
五、方言还会反过来影响 Prompt
ResolvedSqlContext 里有两个能力位:是否支持 CTE、是否支持窗口函数。它们会真的拼进 Prompt:支持就提示"可用 CTE / 窗口函数做分路聚合",不支持(如 MySQL 5.7)就提示"必须以汇聚表为 FROM 主表,每个来源表用独立标量子查询聚合"。
很现实的例子:MySQL 5.7 不支持 CTE 和窗口函数,即使 parser 能读懂这些语法,真实数据库也未必执行得了。所以生成前先约束(Prompt),生成后再兜底(闸门),两边都要做。
整条链路:探测版本 → 计算能力集合 → 影响 Prompt 提示 → 影响读写方言 → 闸门按方言解析回写。中间任何一环断掉,要么模型写了目标库不支持的 SQL,要么系统改写出了另一种方言的 SQL。
六、认可的用法,和还没解决的(边界)
认可的做法: sqlglot 版本要钉(更新快,AST 结构偶尔变);解析和回写统一封装(不让业务代码到处手写 parse_one(read="mysql"));未注册引擎保守回退到 MySQL 语法标签;别把 sqlglot 当成安全本身——它解决的是"把 SQL 当结构理解",真正的安全策略来自只读约束、表白名单、列 deny、行级权限。Parser 是基础设施,不是免责条款。
还没解决的: SELECT * 与敏感列之间有缝(* 不是具体列名,列校验不会展开);模型吐半截 SQL 时解析报错不够友好;Doris / StarRocks 借 MySQL 方言跑通主路径,但"兼容"不等于"等价"。这些认了,以后补。
带走三句
sqlglot在 NL2SQL 里是安全闸门和改写的基础设施——它让校验不靠碰运气;- SQL 方言必须从真实业务库上下文统一下发,解析和回写要始终站在同一门语言里;
- AST 很强,但节点名字不等于业务语义:
Table不一定是物理表,Column也不一定是物理列,校验前先建排除集。
开源仓库在下面,本地 Compose + Fixture 可以直接跑通,不需要先配云 API Key。说明看 docs/DEMO.md / AGENTS.md。
GitHub: https://github.com/yanqiuping110-cloud/xb-data-copilot-bot

781

被折叠的 条评论
为什么被折叠?



