SQL 不是字符串:一个零依赖解析器能替你干的六件事

SQL 不是字符串:一个零依赖解析器能替你干的六件事
一、五分钟写出来的代码
需求很朴素:发布前扫一遍订单模块的所有 SQL,列出它们碰了哪些表,评估改动影响面。
第一版实现长这样:
Pattern p = Pattern.compile("(?i)(?:from|join)\\s+([a-zA-Z0-9_`.]+)");
Matcher m = p.matcher(sql);
while (m.find()) tables.add(m.group(1));
本地跑几条正常 SQL,结果漂亮。上线当天,四个问题一起爆:
WITH w AS (...)里的 CTE 名w被当成真实表;a JOIN b JOIN c只抠出a、b,最后一张漏了;FROM (VALUES (1),(2)) AS t把VALUES抠成了表名;db1.users和users被当成两张表,去重之后结果全是乱的。
然后就是熟悉的节奏:加分支、加排除、加特判。三周后这段正则有 60 行,还是漏。
根因一句话:SQL 是一门有文法的语言,用字符串剪刀去剪,永远在补漏洞。
补不完的。因为 SQL 的嵌套结构是递归的——括号可以套括号,子查询可以含子查询,字符串字面量里还能写 from 这个词。正则没有"栈",它记不住自己现在在第几层。

正确顺序是反过来:先解析成语法树(AST),再问树问题——碰了哪些表、是不是只读、有没有持锁。问题从"怎么匹配字符串"变成"怎么遍历树",后者有唯一正确答案。
二、先别造轮子:Druid / JSqlParser 其实够用
一说要解析器,很多人的第一反应是自己写。别急,日常场景这两个库完全够:
// Druid
List<SQLStatement> stmts = SQLUtils.parseStatements(
"SELECT u.id FROM users u JOIN orders o ON u.id = o.uid",
DbType.mysql);
SchemaStatVisitor visitor = SQLUtils.createSchemaStatVisitor(DbType.mysql);
stmts.get(0).accept(visitor);
System.out.println(visitor.getTables().keySet());
// [users, orders]
// JSqlParser
Statement stmt = CCJSqlParserUtil.parse(
"SELECT u.id FROM users u JOIN orders o ON u.id = o.uid");
System.out.println(new TablesNamesFinder().getTableList(stmt));
// [users, orders]
网关审计、读写分离路由、简单的 SQL 改写,很多人就这么用,用了几年也没出事。
它们真正卡住的地方,是业务 SQL "长歪" 的时候。
三、真正难啃的,是业务里长歪的那些 SQL
信创迁移、报表拆字段、老系统遗留语句里,你迟早会撞见这种东西——Oracle / 达梦风格的 CONNECT BY 拆逗号,再套中文表名和多层子查询:
SELECT REGEXP_SUBSTR(E14, '[^,]+', 1, LEVEL) AS E14_SPLIT, G14, H14
FROM (
SELECT REPLACE(E14, '''', '') E14, G14, H14
FROM (SELECT * FROM TB9_3.业务影响分析)
WHERE day_id = '202601--' AND org_cd = 'F0001'
) T
CONNECT BY LEVEL <= LENGTH(E14) - LENGTH(REPLACE(E14, ',', '')) + 1
AND PRIOR ROWID = ROWID
AND PRIOR SYS_GUID() IS NOT NULL
这不是为了炫技写出来的,是业务上真的这么跑。同一条 SQL,拿三个解析器对着测:

| 解析器 | 版本 | 结果 |
|---|---|---|
| Druid | 1.2.23 | 解析失败 |
| JSqlParser | 4.9 | 解析失败 |
jkit-sql(方言 dm) | 2.0.1 | 解析成功 |
有两点得说清楚,免得变成引战:
- 普通的
CONNECT BY PRIOR emp_id = mgr_id,Druid 本来就能解析,不是"看见 CONNECT BY 就挂"; - 这条挂在 函数拆逗号 + 中文标识符 + 多层子查询 +
CONNECT BY LEVEL/PRIOR ROWID的组合上。用dm和oracle两种方言都试过,结果一样。
结论不是"Druid 不行",而是:覆盖率这种东西,只有撞上才知道够不够。 你的 SQL 语料决定了你需要什么。
四、jkit-sql:只做文本和 AST 之间那一层
jkit-sql 是一个零第三方依赖、纯 JDK 手写的 SQL 解析器:
- 词法:
char[]逐字符扫描 + 关键字开地址哈希; - 语法:手写递归下降;
- 产物:AST(
SqlSelect/SqlJoin/SqlOrderBy…)。
它只干这一件事:
SQL 文本 ──parse()──▶ AST ──改写/统计/格式化/转换──▶ SQL 文本
◀──toSqlString()──

六件它能替你干的事
1. 一条 SQL 动了哪些表、哪些列
List<String> tables = SQL.tables(
"SELECT u.id FROM users u "
+ "LEFT JOIN order_t b ON u.id = b.uid "
+ "WHERE b.amount > 100");
// [users, order_t]
SqlSchemaStat stat = SQL.stat(
"SELECT u.id, u.name FROM users u WHERE u.age > 18 ORDER BY u.name");
stat.tableNames(); // [users]
stat.getColumns(); // 含 WHERE / ORDER BY 里的列
stat.getConditions(); // [users.age > 18]
tables() 只收真实被访问的表:库名、CTE 名、例程名都会剔掉,不会污染审计结果——正好是开头那个需求想要的答案。
2. 判断这条 SQL 该走主库还是从库
SqlStatement stmt = SQL.parse("SELECT * FROM users WHERE id = 1 FOR UPDATE");
stmt.isReadOnly(); // false —— 持锁,必须打主库
FOR UPDATE / LOCK IN SHARE MODE 会被判为写。读写分离路由最容易翻的车,就是把它发给了从库。
3. 把拼接的字面量收编成 ?
String safe = SQL.parameterize(
"SELECT * FROM users WHERE name = 'alice' AND age = 18");
// SELECT * FROM users WHERE name = ? AND age = ?
老系统里满地的字符串拼接,不用改业务代码就能收口成预编译。
4. 统一注入租户条件 / 改表名
在网关层用改写规则覆盖全部 SQL,比在每个 Mapper 里手写 tenant_id = ? 可靠得多——漏一个就是一次越权。
SqlStatement out = SQL.andWhere(
SQL.parse("SELECT id FROM orders WHERE status = 1"),
"tenant_id = ?");
// SELECT id FROM orders WHERE status = 1 AND tenant_id = ?
5. 跨方言分页与迁移
LIMIT / TOP / ROWNUM / OFFSET FETCH 一套 API 通吃;信创迁移时的函数翻译也能直接做:
String pg = SQL.convert(
"SELECT GROUP_CONCAT(name) FROM t",
SqlDialect.MYSQL, SqlDialect.POSTGRES);
// SELECT STRING_AGG(name) FROM t
6. 启动时按实体自动建表
jkit-sql-auto 认 JPA / MyBatis-Plus 注解,无编译依赖,已有项目直接接入。
五、它不做什么(这比它能做什么更重要)
- 不执行 SQL——不管连接池、事务、结果映射,也不引 JDBC 驱动;
- 不是 ORM——不做对象关系映射,不替代 MyBatis / JPA;
- 不做查询优化——不生成执行计划,不管索引;
- 不做数据访问层——它适合网关、审计、防火墙、改写、迁移、建表这类中间层。
一句话分界:
需要"读懂并改造别人给的 SQL"时用它;需要"方便存取业务对象"时,继续用 MyBatis / JPA。
六、最小上手
import com.alianga.jkit.sql.SQL;
import com.alianga.jkit.sql.ast.SqlStatement;
SqlStatement stmt = SQL.parse(
"SELECT u.id, b.name FROM users u "
+ "LEFT JOIN order_t b ON u.id = b.uid "
+ "WHERE u.age > 18");
stmt.type(); // SELECT
stmt.isReadOnly(); // true
SQL.tables(stmt); // [users, order_t]
没有连接池,没有 SessionFactory,没有一个 XML。一个 jar,JDK 8+ 就能跑。
小结
- 正则抠 SQL 走不通,不是正则写得不够长,是 SQL 有递归结构;
- Druid / JSqlParser 覆盖大多数日常 SQL,但方言边角 + 嵌套拆分 + 中文标识符这类组合,才是真正分出高下的地方;
jkit-sql卡在 SQL 文本和 AST 之间那一层:零第三方依赖,不越界执行,把"读懂 SQL"这件事从业务代码里摘出去。
后面这个系列会继续写性能、读写分离、注入防护、防火墙、跨方言分页、多租户、脱敏、迁移和自动建表。下一篇先看手写词法在热路径上到底有多快——基础组件不快,就没有上生产的资格。
你踩过最离谱的 SQL 翻车是什么样的?是正则抠错了,还是某条方言 SQL 把解析器干趴了?评论区聊聊。
Q.E.D.


