
14 个业务场景,一个 SQL 解析器全干完
这里写「网关」,是因为审计、路由、Wall、租户改写最常落在这一层;但同一套能力也覆盖迁移、分页、实体建表、格式化、遗留模板——凡是要读懂、改写、校验 SQL 文本的地方都用得上。
真正卡住的往往不是「能不能执行 SQL」,而是「能不能处理别人给过来的 SQL」。影响面评估要抽表名;读写分离要判断只读;开放查询要拦危险语句;信创迁移要把 MySQL 翻成 PostgreSQL / Oracle。这些事落在 ORM 里别扭,用正则剪字符串又容易翻车。
jkit-sql 2.0.1 做的是中间这一层:把 SQL 解析成 AST,再统计、改写、格式化、跨方言转换。它不执行 SQL,也不引 JDBC 驱动。 真正落库,仍交给你自己的数据源。
下面按业务把 14 个场景收成五组,每组给最小可跑通的写法。

文档:https://jkit.alianga.com/
源码:jkit-sql(develop)
审计与路由
上线评审、代理分流,先要「读懂」语句:动了哪些表、是不是只读、持锁该不该打主库。
1. SQL 审计与依赖分析
何时用: 变更评审、灰度影响面,要一份真实被访问的表/列清单,而不是正则抠出来的噪声。
import com.alianga.jkit.sql.SQL;
import com.alianga.jkit.sql.ast.SqlStatement;
SqlStatement stmt = SQL.parse(
"SELECT u.id, u.name FROM users u JOIN orders o ON u.id = o.uid WHERE o.amount > 100");
List<String> tables = SQL.tables(stmt); // [users, orders]
boolean hit = SQL.stat(stmt).getColumns().contains("o.amount");
批量脚本里还可以按语句标记写操作:
List<SqlStatement> stmts = SQL.parseAll("SELECT 1; UPDATE t SET a = 1 WHERE id = 2");
long writes = 0;
for (SqlStatement s : stmts) {
if (!s.isReadOnly()) {
writes++; // 1
}
}
tables() 只收真实被访问的表;CTE 名、库名、例程名不会混进来。
2. 读写分离路由
何时用: 代理层按语句类型把流量分到主库 / 从库。
SqlStatement q = SQL.parse("SELECT * FROM t_user WHERE id = 1");
boolean replica = q.isReadOnly(); // true → 从库
持锁 SELECT 必须打主库,否则行锁失效:
boolean primary = !SQL.parse("SELECT * FROM t_user WHERE id = 1 FOR UPDATE").isReadOnly(); // true
boolean write = !SQL.parse("INSERT INTO t_user(id, name) VALUES (1, 'a')").isReadOnly(); // true
FOR UPDATE / LOCK IN SHARE MODE 会被判为写——这是有意设计,不是漏判。
安全
开放接口、低代码、用户可编辑查询,入口不可信时,先把字面量收编成绑定,再用 Wall 做前置拦截。
3. SQL 注入防护
何时用: 外部拼接进 SQL 的字面量,要交给预编译语句执行。
String out = SQL.parameterize(
"SELECT id FROM t_user WHERE name = 'alice' AND age = 18 AND deleted = false");
// SELECT id FROM t_user WHERE name = ? AND age = ? AND deleted = ?
也可以先导出绑定值,再参数化下发:
SqlStatement stmt = SQL.parse(
"SELECT * FROM t_user WHERE name = 'alice' AND age = 18 AND id = ?");
List<Object> values = SQL.exportParameterValues(stmt); // [alice, 18]
String parameterized = SQL.parameterize(stmt); // 已有的 ? 保持不变
4. SQL 防火墙(Wall)
何时用: 不可信入口上的前置拦截——多语句、无 WHERE 的 DELETE/UPDATE、SLEEP、注释绕过等。
import com.alianga.jkit.sql.SqlDialect;
import com.alianga.jkit.sql.SqlWallConfig;
SQL.wall("SELECT 1; DELETE FROM t WHERE id = 1").violations(); // [multi-statement]
SQL.wall("DELETE FROM t").violations(); // [delete-without-where]
SQL.wall("UPDATE t SET a = 1").violations(); // [update-without-where]
SQL.wall("SELECT SLEEP(5) FROM t").violations(); // [dangerous-function]
SQL.wall("SELECT * FROM t WHERE name = 'x' --").violations(); // [comment-bypass]
SQL.wall("SELECT * FROM t WHERE id = 1").passed(); // true
运维通道可以按需放行 DDL:
SQL.wall("DROP TABLE t").violations(); // [deny-ddl]
SqlWallConfig allowDdl = SqlWallConfig.defaults().denyDdl(false);
SQL.wall("DROP TABLE t", SqlDialect.MYSQL, allowDdl).passed(); // true
改写与迁移
同一份业务 SQL,要换方言、换分页、注入租户、裁敏感列——改写链统一走 AST,而不是字符串替换。
5. 跨方言数据库迁移
何时用: 存量 MySQL 迁到 PostgreSQL / Oracle 等,希望翻译结果能被目标方言再解析,形成闭环。
import com.alianga.jkit.sql.schema.convert.ConversionResult;
import com.alianga.jkit.sql.schema.convert.SqlSchemaConverter;
// 修改转换
ConversionResult r = SqlSchemaConverter.convert(
"ALTER TABLE t MODIFY c INT NOT NULL", SqlDialect.MYSQL, SqlDialect.POSTGRES);
// ALTER TABLE t ALTER COLUMN c TYPE INTEGER
SQL.parse(r.sql(), SqlDialect.POSTGRES);
// 查询转换
String selectSql = "select `userid` from aaa Where userid like concat('%',?,'%') ";
SQL.convert(selectSql,SqlDialect.MYSQL, SqlDialect.ORACLE)
// selectSql = SELECT "userid" FROM aaa WHERE userid LIKE '%' || ? || '%'
批量 DML 会顺带改写方言函数:
List<ConversionResult> rs = SQL.convertBatch(
Arrays.asList("SELECT GROUP_CONCAT(name) FROM t", "SELECT IFNULL(a, b) FROM t"),
SqlDialect.MYSQL, SqlDialect.POSTGRES);
// rs.get(0).sql() → SELECT STRING_AGG(name, ',') FROM t
6. 多方言分页
何时用: 同一份查询按目标库生成 LIMIT / TOP / ROWNUM / OFFSET FETCH。
SqlStatement page = SQL.setPage(
SQL.parse("SELECT id FROM orders WHERE status = 1"), 2, 10, SqlDialect.MYSQL);
SQL.getLimit(page); // 10
SQL.getOffset(page); // 10
经典 Oracle:首页单层包装,翻页走双层 RN:
SqlStatement first = SQL.setPage(
SQL.parse("SELECT * FROM emp"), 1, 8, SqlDialect.ORACLE);
SQL.toSqlString(first, SqlDialect.ORACLE);
// SELECT * FROM (SELECT * FROM emp) XX WHERE ROWNUM <= 8
SqlStatement mysql = SQL.parse("SELECT * FROM emp LIMIT 10");
SQL.toSqlString(mysql, SqlDialect.ORACLE); // 单层 ROWNUM
SQL.toSqlString(mysql, SqlDialect.SQLSERVER); // SELECT TOP 10 ...
SQL.adaptPagination(mysql, SqlDialect.ORACLE12); // OFFSET 0 FETCH FIRST 10 ROWS ONLY
setPage / adaptPagination / format / toSqlString 共用同一套形态判断。
7. 多租户改写
何时用: 分表路由 + 统一注入租户条件,避免某个 Mapper 漏加 tenant_id。
import com.alianga.jkit.sql.SqlRewrites;
SqlStatement out = SQL.rewrite(SQL.parse("SELECT id, name FROM t_user WHERE status = 1"),
SqlRewrites.create().add(SqlRewrites.replaceTable("t_user", "t_user_2026")));
// SELECT id, name FROM t_user_2026 WHERE status = 1
网关注入条件时,原 AST 不会被就地改掉:
SqlStatement stmt = SQL.parse("SELECT id FROM t_order WHERE status = 1");
SqlStatement rewritten = SQL.rewrite(stmt, SqlRewrites.create()
.add(SqlRewrites.andWhere(SQL.parseExpr("tenant_id = 100"))));
// SELECT id FROM t_order WHERE status = 1 AND tenant_id = 100
SQL.toSqlString(stmt); // 原语句未变
8. 数据脱敏与列级权限
何时用: 对外接口裁掉手机号 / 证件号;库表重构后旧 SQL 映射到新列名。
SqlStatement stmt = SQL.parse("SELECT id, name, phone, id_card FROM t_customer WHERE id = 1");
SqlStatement out = SQL.removeSelectItem(stmt, "phone");
out = SQL.removeSelectItem(out, "id_card");
// SELECT id, name FROM t_customer WHERE id = 1
物理列改名:
SqlStatement mapped = SQL.rewrite(SQL.parse("SELECT name FROM t_user WHERE name = 'a'"),
SqlRewrites.create().add(SqlRewrites.replaceColumn("name", "user_name")));
// SELECT user_name FROM t_user WHERE user_name = 'a'
构建与建表
从零拼 SQL、或从实体注解产出多方言 DDL,都可以走 AST,少碰字符串黑客。
9. 动态 SQL 构建
何时用: 条件查询 / INSERT / UPDATE 不想手写逗号和括号。
import com.alianga.jkit.sql.SqlBuilder;
String sql = SqlBuilder.select("id", "name").from("users")
.where("status = 1").and("age > 18").orderBy("id").limit(10).toSql();
// SELECT id, name FROM users WHERE status = 1 AND age > 18 ORDER BY id LIMIT 10
SQL.parse(sql);
String insert = SqlBuilder.insertInto("t_user").columns("id", "name").values(1L, "alice").toSql();
// INSERT INTO t_user(id, name) VALUES (1, 'alice')
String update = SqlBuilder.update("t_user").set("name", "bob").where("id = 1").toSql();
// UPDATE t_user SET name = 'bob' WHERE id = 1
10. 实体驱动多方言建表
何时用: 一套 @SqlTable / @SqlColumn 注解,按目标库产出 DDL,少维护多份建表脚本。
import com.alianga.jkit.sql.entity.SqlEntities;
import com.alianga.jkit.sql.entity.SqlTable;
import com.alianga.jkit.sql.entity.SqlId;
import com.alianga.jkit.sql.entity.SqlColumn;
@SqlTable(name = "t_member", comment = "会员表")
class Member {
@SqlId
Long id;
@SqlColumn(comment = "昵称")
String nick;
}
String mysql = SqlEntities.createTable(Member.class, SqlDialect.MYSQL);
// CREATE TABLE t_member (...) COMMENT '会员表',列上带 COMMENT '昵称'
String pg = SqlEntities.createTable(Member.class, SqlDialect.POSTGRES);
// COMMENT ON TABLE / COMMENT ON COLUMN 走独立语句
也认 JPA / MyBatis-Plus 注解(反射按类名,无编译依赖)。jkit-sql 只生成 SQL;启动时连库建表看 jkit-sql-auto。
工程细节
日志规范、遗留模板、常量折叠、缓存安全——这些不像「功能清单」那么显眼,但网关 / 代理里天天碰到。
11. SQL 格式化与规范统一
何时用: 日志脱乱、评审 diff 收敛、关键字大小写归一。
import com.alianga.jkit.sql.SqlFormatOptions;
import com.alianga.jkit.sql.SqlKeywordCase;
SqlStatement stmt = SQL.parse("select Id, Name from MyTable where Age > 18", SqlDialect.MYSQL);
SQL.format(stmt, SqlDialect.MYSQL, false, SqlFormatOptions.defaults());
// SELECT Id, Name FROM MyTable WHERE Age > 18
SQL.format(stmt, SqlDialect.MYSQL, false,
SqlFormatOptions.defaults().keywordCase(SqlKeywordCase.LOWER));
// select Id, Name from MyTable where Age > 18
pretty 多行后再解析,表集合应保持一致——format 是 AST 回写,不保证注释和空白 round-trip,但语义往返是硬要求。
12. 遗留模板占位符迁移
何时用: 老系统里的 @xx@ / %s 风格 SQL,不先改写文本也想解析。
import com.alianga.jkit.sql.SqlParseOptions;
import com.alianga.jkit.sql.SqlPlaceholders;
SqlStatement stmt = SQL.parse(
"select * from t_user where id = 1 and age > @minAge@ limit 10",
SqlDialect.MYSQL,
SqlParseOptions.defaults().placeholders(SqlPlaceholders.create().atWrapped()));
SQL.tables(stmt); // [t_user]
SQL.getLimit(stmt); // 10
printf 风格同样可开:
SqlStatement printf = SQL.parse("SELECT %s FROM (SELECT '20221111' AS %s) AS a",
SqlDialect.MYSQL,
SqlParseOptions.defaults().placeholders(SqlPlaceholders.create().printf()));
SQL.tables(printf); // [],FROM 的是派生表
默认关闭占位符,保持严格解析;需要时显式打开。
13. 表达式预计算
何时用: 规则引擎、前端预览、报表预校验里先做常量折叠。
import com.alianga.jkit.sql.ast.SqlSelect;
SqlStatement stmt = SQL.parse("SELECT 1 + 2 * 3");
Object v = SQL.eval(((SqlSelect) stmt).selectItems().get(0).expr()); // BigDecimal 7
含列引用时不抛错,返回 null,交给下游:
SqlStatement priced = SQL.parse("SELECT price * 0.8 FROM t");
Object maybe = SQL.eval(((SqlSelect) priced).selectItems().get(0).expr()); // null
14. 安全改写不污染原语句
何时用: 网关 / 代理会缓存解析结果,改写必须是 clone-then-mutate。
SqlStatement original = SQL.parse("SELECT id, name FROM t_user WHERE status = 1");
SqlStatement masked = SQL.removeSelectItem(original, "name");
SQL.toSqlString(original).contains("name"); // true
SQL.toSqlString(masked).contains("name"); // false
分页同理——setPage 先 clone,再改副本:
SqlStatement cached = SQL.parse("SELECT id FROM users WHERE status = 1");
SQL.setPage(cached, 2, 10, SqlDialect.MYSQL);
((SqlSelect) cached).limit(); // null,缓存里的原句无 LIMIT
门面改写(addLimit / andWhere / replaceTable / removeSelectItem / adaptPagination 等)和 SQL.rewrite 都是这条约定:返回新树,入参 AST 不变。
接入方式
<dependency>
<groupId>com.alianga</groupId>
<artifactId>jkit-sql</artifactId>
<version>2.0.1</version>
</dependency>
依赖 com.alianga:jkit(日志等),无其它第三方库。JDK 8+。
再强调一次边界:解析、统计、改写、格式化、跨方言转换——到此为止;不连库、不跑查询、不替代 MyBatis / JPA。
这 14 个场景对应的可执行用例都在 SqlBusinessScenarioTest。你那边若有网关审计、租户注入、方言迁移的实战坑,欢迎评论区丢一条脱敏 SQL,看看解析器卡不卡得住。
文档:https://jkit.alianga.com/
源码:jkit-sql(develop)
Q.E.D.


