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

cover-01B-定位

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 只抠出 ab,最后一张漏了;
  • FROM (VALUES (1),(2)) AS tVALUES 抠成了表名;
  • db1.usersusers 被当成两张表,去重之后结果全是乱的。

然后就是熟悉的节奏:加分支、加排除、加特判。三周后这段正则有 60 行,还是漏。

根因一句话:SQL 是一门有文法的语言,用字符串剪刀去剪,永远在补漏洞。

补不完的。因为 SQL 的嵌套结构是递归的——括号可以套括号,子查询可以含子查询,字符串字面量里还能写 from 这个词。正则没有"栈",它记不住自己现在在第几层。

illust-regex-vs-ast

正确顺序是反过来:先解析成语法树(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,拿三个解析器对着测:

illust-parser-compare

解析器版本结果
Druid1.2.23解析失败
JSqlParser4.9解析失败
jkit-sql(方言 dm2.0.1解析成功

有两点得说清楚,免得变成引战:

  1. 普通的 CONNECT BY PRIOR emp_id = mgr_id,Druid 本来就能解析,不是"看见 CONNECT BY 就挂";
  2. 这条挂在 函数拆逗号 + 中文标识符 + 多层子查询 + CONNECT BY LEVEL/PRIOR ROWID 的组合上。用 dmoracle 两种方言都试过,结果一样。

结论不是"Druid 不行",而是:覆盖率这种东西,只有撞上才知道够不够。 你的 SQL 语料决定了你需要什么。


四、jkit-sql:只做文本和 AST 之间那一层

jkit-sql 是一个零第三方依赖、纯 JDK 手写的 SQL 解析器:

  • 词法:char[] 逐字符扫描 + 关键字开地址哈希;
  • 语法:手写递归下降;
  • 产物:AST(SqlSelect / SqlJoin / SqlOrderBy …)。

它只干这一件事:

SQL 文本  ──parse()──▶  AST  ──改写/统计/格式化/转换──▶  SQL 文本
              ◀──toSqlString()──

illust-jkit-boundary

六件它能替你干的事

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 注解,无编译依赖,已有项目直接接入。


五、它不做什么(这比它能做什么更重要)

  1. 不执行 SQL——不管连接池、事务、结果映射,也不引 JDBC 驱动;
  2. 不是 ORM——不做对象关系映射,不替代 MyBatis / JPA;
  3. 不做查询优化——不生成执行计划,不管索引;
  4. 不做数据访问层——它适合网关、审计、防火墙、改写、迁移、建表这类中间层。

一句话分界:

需要"读懂并改造别人给的 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 把解析器干趴了?评论区聊聊。

在线文档:https://jkit.alianga.com/
源码:jkit-sql(develop)

Q.E.D.


寻门而入,破门而出