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

上一篇讲了越权评测。评测里反复出现一个名字: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 会怎样
判断最外层是不是 SELECTWITH ... 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 时按什么生成"是两件事。

业务库配置

连接器 registry

ResolvedSqlContext

解析方言 sqlglot_read

回写方言 sqlglot_dialect

能力集合 features

parse_sql

render_sql

Prompt 能力提示

不传或传错方言会怎样? 两类后果:一是读不懂(Oracle 的 FETCH FIRST、SQL Server 的 TOP,解析方言不对,AST 结构就偏,LIMIT 校验跟着偏);二是写错样子(解析勉强过了,回写却按错误方言生成,落到真实库报语法错)。

真正危险的不是报错,而是你以为已经做了安全改写,实际上改写的是另一门 SQL 语言

四、三个真实踩坑:AST 节点名 ≠ 业务语义

三个坑有个共同主题,也是最值得带走的一条经验。

坑一:CTE 别名混进了白名单校验。 模型写 WITH punch AS (...) SELECT COUNT(*) FROM punchpunch 既是 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值