正则抠 SQL 翻车之后:聊聊解析器这件事

cover-01-定位

做过 SQL 审计或上线影响面评估的,大概都试过一招:正则抠 FROM / JOIN

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,就容易翻车:

  • CTE(WITH w AS (...))里的 w 被当成表;
  • 链式 JOIN 漏掉最后一张;
  • FROM (VALUES (1),(2)) AS tVALUES 抠成了表名;
  • schema.table 混进库名,去重全乱。

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

正经做法是把 SQL 解析成 AST,再问这棵树:动了哪些表、是不是只读、有没有持锁。这件事业界早有成熟方案——Druid SQL ParserJSqlParser 都能干。


多数场景,Druid / JSqlParser 够用

日常 CRUD、普通 JOIN、分页,这两个库都好使。比如抽表名:

// 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");
TablesNamesFinder finder = new TablesNamesFinder();
System.out.println(finder.getTableList(stmt));
// [users, orders]

网关审计、读写分离路由、简单改写,很多人就这么用,没问题。

但业务 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

不是"写得花哨",是业务上真的这么跑。拿三个解析器对着测(对比环境:Druid 1.2.23、JSqlParser 4.9、jkit-sql 2.0.1;Druid 分别用 oracle / dm,jkit 走 dm):

@Test
public void testComplexBusinessSql() {
    String sql = "SELECT  REGEXP_SUBSTR(E14, '[^,]+', 1, LEVEL) AS E14_SPLIT,\n" +
            "    G14, H14 FROM(SELECT REPLACE(E14,'''','') E14,G14, H14 FROM (select * from TB9_3.业务影响分析 )\n" +
            "WHERE day_id = '202601--'   AND org_cd = 'F0001') T\n" +
            "CONNECT BY LEVEL <= LENGTH(E14) - LENGTH(REPLACE(E14, ',', '')) + 1\n" +
            "AND PRIOR ROWID = ROWID \n" +
            "AND PRIOR SYS_GUID() IS NOT NULL";

    System.out.println("druid(oracle): " + parseDruid(sql, "oracle"));
    System.out.println("druid(dm):     " + parseDruid(sql, "dm"));
    System.out.println("jsqlparser:    " + parseJsql(sql));
    System.out.println("jkit-sql(dm):  " + parseJkit(sql, "dm"));
}

static boolean parseJkit(String sql, String dialect) {
    try {
        com.alianga.jkit.sql.SQL.parse(
                sql, com.alianga.jkit.sql.SqlDialect.fromName(dialect));
        return true;
    } catch (Throwable e) {
        return false;
    }
}

static boolean parseDruid(String sql, String dialect) {
    try {
        List<?> stmts = SQLUtils.parseStatements(sql, toDbType(dialect));
        return stmts != null && !stmts.isEmpty();
    } catch (Throwable e) {
        return false;
    }
}

static boolean parseJsql(String sql) {
    try {
        return CCJSqlParserUtil.parse(sql) != null;
    } catch (Throwable e) {
        return false;
    }
}

/** 跟 Druid DbType 对齐;dm/dameng 走达梦,避免“方言映射错了才失败”。 */
static com.alibaba.druid.DbType toDbType(String dialect) {
    switch (dialect.toLowerCase(Locale.ROOT)) {
        case "dm":
        case "dameng":
            return com.alibaba.druid.DbType.dm;
        case "oracle":
            return com.alibaba.druid.DbType.oracle;
        case "mysql":
            return com.alibaba.druid.DbType.mysql;
        default:
            return com.alibaba.druid.DbType.other;
    }
}

illust-parser-compare

复跑结论先说清楚,避免误导:

  1. 普通 CONNECT BY PRIOR emp_id = mgr_id,Druid 官方语料本来就能过,不是“凡是 CONNECT BY 都挂”;
  2. 正文这条业务 SQL:Druid 1.2.23DbType.oracle能解析成功,换成 DbType.dm 才失败;JSqlParser 4.9 失败,卡在 SYS_GUID();jkit-sql 2.0.1SqlDialect.fromName("dm") 能过。
  3. 所以差距不在“Druid 一律解析不来”,而在达梦方言下这条 REGEXP_SUBSTR + 中文表名 + 嵌套 CONNECT BY 组合

跑出来是:

解析器版本方言 / 入口结果(上述业务 SQL)
Druid1.2.23DbType.oracle解析成功
Druid1.2.23DbType.dm解析失败
JSqlParser4.9(无方言参数)解析失败(卡在 SYS_GUID()
jkit-sql2.0.1SqlDialect.dm解析成功

所以这篇不是要说“扔掉 Druid / JSqlParser”。常见路径、甚至 Oracle 方言下的复杂层次查询,它们往往够用。但信创落地常走达梦:同一条 SQL 换 DbType.dm,Druid 就可能挂;JSqlParser 又过不了 SYS_GUID() 这类组合。这时才需要一个覆盖面更宽、又不想拖进连接池全家桶的解析器。

这就是 jkit-sql 的切入点。

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


jkit-sql 是什么?

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

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

能力上对标 Druid SQL Parser、JSqlParser 的常用入口——解析、抽表列、改写、跨方言格式化。有一条边界建议先记住:

它不执行 SQL,也不引 JDBC 驱动。

不连库、不跑查询、不替代 MyBatis / JPA。它只干这一层:

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

真正落库,交给你自己的数据源;需要启动时按实体建表,再看搭档模块 jkit-sql-auto


它能帮你干什么?

下面几类需求,ORM 做起来别扭,解析器一行就够:

1. 一条 SQL 动了哪些表、哪些列

import com.alianga.jkit.sql.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. 判断走主库还是从库

SqlStatement stmt = SQL.parse("SELECT * FROM t 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. 统一注入租户条件 / 改表名

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;MySQL 迁 PostgreSQL 时,convert(...) 能把 GROUP_CONCAT → STRING_AGG 这类函数翻过去:

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


为什么是手写,而不是生成器?

两条常见路线:

  • 生成器派(如 JSqlParser,JavaCC):文法好维护,生成代码体积大,热路径更慢;
  • 手写派(Druid、jkit-sql):词法 + 递归下降,可控、轻、快。

jkit-sql 选了手写,并且只依赖自己的内核,JDK 8+。Druid 往往是“连接池 + 监控 + 解析”一起上;你要是只想在网关 / 审计里安静解析改写一条 SQL,会更在意依赖面。

性能对比和成功率数字后面再写,这篇先把定位说清楚。


最小例子

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。


它不做什么

  1. 不执行 SQL,不管连接池、事务、结果映射;
  2. 不是 ORM,不做对象关系映射;
  3. 不做查询优化,不生成执行计划;
  4. 适合网关、审计、防火墙、改写、迁移、建表这类中间层,不当业务数据访问层本身。

需要“读懂并改造别人给的 SQL”时用它;需要“方便存取业务对象”时,继续用 MyBatis / JPA。


小结

正则抠表名不靠谱;Druid / JSqlParser 覆盖了大多数日常 SQL,复杂 CONNECT BY 在 Oracle 下 Druid 也能扛。真正容易拉开差距的,是达梦方言 + 嵌套拆分 + 中文标识符叠在一起的业务遗留语句。jkit-sql 做的是 SQL 文本和 AST 之间那一层:零第三方依赖,不越界执行。

后面还会写性能、读写分离、注入防护、防火墙、跨方言分页、多租户、脱敏、迁移和自动建表。性能那篇会专门看手写词法在热路径上到底有多快。

你那边有没有“正则抠 SQL”,或某条方言 SQL 把解析器干趴的经历?评论区见。

Q.E.D.


寻门而入,破门而出