SQL 解析模块
com.alianga:jkit-sql 是零依赖的 SQL 解析器:手写词法(char[] + 关键字开地址哈希),递归下降生成 AST,支持格式化、表/列统计和改写。
设计上对标:
- Druid SQL Parser:手写解析、线程内复用 Parser、
SchemaStatVisitor式抽表列、生产环境吞吐 - JSqlParser:AST + Visitor、
TablesNamesFinder、pretty/compact 回写
不执行 SQL,不引 JDBC 驱动。
引入
<dependency>
<groupId>com.alianga</groupId>
<artifactId>jkit-sql</artifactId>
<version>2.0.1</version>
</dependency>依赖 com.alianga:jkit(日志等),无其它第三方库。JDK 8+。
解析
import com.alianga.jkit.sql.SQL;
import com.alianga.jkit.sql.SqlDialect;
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]
List<SqlStatement> batch = SQL.parseAll("SELECT 1; DELETE FROM t WHERE id=1");
// 容错多语句(审计):失败条目为 SqlSimpleStatement,parseError() 有值,继续下一条
List<SqlStatement> audit = SQL.parseAll(sql, SqlDialect.MYSQL, true);
// 裸表达式(须消费完整输入;尾部垃圾抛 SqlParseException)
SqlExpr pred = SQL.parseExpr("tenant_id = ?");
SqlExpr fn = SQL.parseExpr("REPLACE(email, '@', '^-^')");
SqlExpr withOpt = SQL.parseExpr("age > @age@", SqlDialect.MYSQL,
SqlParseOptions.defaults().placeholders(SqlPlaceholders.create().atWrapped()));tables() 只收真实被访问的表:DML/SELECT 的 FROM/JOIN/INTO;DDL 按对象类型区分——DROP/ALTER/TRUNCATE/RENAME TABLE 的表、CREATE TABLE t2 LIKE 源表、CREATE TRIGGER ... ON t、维护语句(OPTIMIZE/ANALYZE/CHECK/REPAIR TABLE t, s)的全组表;VIEW 名按既有语义计入。不计入:库名(USE db)、例程/索引/事件等对象名(CREATE INDEX idx)、CTE 名(WITH w AS (...) SELECT FROM w)、CALL sp / DECLARE / GRANT 的目标。
默认方言是 MySQL(GBase / MariaDB / TiDB 走同一套)。一等方言枚举:
SQL.parse(sql, SqlDialect.POSTGRES);
SQL.parse(sql, SqlDialect.ORACLE); // 12c 以下:分页改写用 ROWNUM
SQL.parse(sql, SqlDialect.ORACLE12); // 12c+:可用 OFFSET/FETCH
SQL.parse(sql, SqlDialect.SQLSERVER);
SQL.parse(sql, SqlDialect.ANSI);
SQL.parse(sql, SqlDialect.H2);
SQL.parse(sql, SqlDialect.DB2); // 仅 FETCH FIRST 分页
SQL.parse(sql, SqlDialect.SQLITE); // LIMIT 族,无 FETCH FIRST
SQL.parse(sql, SqlDialect.HIVE); // 反引号、|| 拼接;别名 maxcompute/odps
SQL.parse(sql, SqlDialect.CLICKHOUSE); // 反引号、双引号也是标识符、逗号 LIMIT
SQL.parse(sql, SqlDialect.PRESTO); // 双引号、|| 拼接;别名 trino
SQL.parse(sql, SqlDialect.DAMENG); // 达梦:双引号、LIMIT/OFFSET、IDENTITY
SqlDialect.fromName("gbase"); // MYSQL
SqlDialect.fromName("gaussdb"); // POSTGRES
SqlDialect.fromName("dm"); // DAMENG
SqlDialect.fromName("oracle12"); // ORACLE12
SqlDialect.fromName("19c"); // ORACLE12
SqlDialect.fromName("tidb"); // MYSQL
SqlDialect.fromName("sqlite"); // SQLITE
SqlDialect.fromName("db2"); // DB2
SqlDialect.fromName("hive"); // HIVE
SqlDialect.fromName("clickhouse");// CLICKHOUSE
SqlDialect.fromName("trino"); // PRESTO
// 国产与主流别名:goldendb/selectdb/analyticdb/matrixone/stonedb/oceanbase/polardb/tdsql/starrocks/doris → MYSQL;
// highgo/uxdb/mogdb/vastbase/antdb/ivorysql/kingbase/opengauss/greenplum → POSTGRES;oscar → ORACLE;dm/dameng → DAMENG
// common-model(icell)数据源对齐:argo/argodb → HIVE(Transwarp Hive JDBC)、xcloud → POSTGRES(行云)、
// gbase8a → MYSQL、gbase8s → SQLITE(双引号、LIMIT、无 FETCH)
// 能力查询(改写/格式化单一事实来源)
SqlDialect.MYSQL.supportsLimitOffset(); // true
SqlDialect.SQLSERVER.supportsTop(); // true
SqlDialect.ORACLE.supportsFetchFirst(); // false(12c 以下)
SqlDialect.ORACLE12.supportsFetchFirst(); // true
SqlDialect.ORACLE.supportsRownum(); // true
SqlDialect.POSTGRES.pipesAreConcat(); // true
SqlDialect.MYSQL.quoteIdent("user"); // `user`
SqlDialect.ORACLE.maxIdentifierLength(); // 30
SqlDialect.ORACLE.fitIdentifier("t_schedule_auth_resource_id_idx");
// 超长则保留前缀 + 4 位散列,保证 ≤30内置方言不够用时,用 SqlDialectWrapper 基于某个方言微调个别能力(SQL.parse* / format / setPage / wall 等所有方言参数都接受 SqlDialectSpec):
// MySQL + ANSI_QUOTES:双引号是标识符不是字符串
SqlDialectSpec ansiQuotes = new SqlDialectWrapper(SqlDialect.MYSQL) {
@Override
public boolean doubleQuoteIsString() {
return false;
}
};
SQL.parse("SELECT \"id\" FROM t", ansiQuotes); // "id" 按标识符解析可覆写的能力覆盖解析到改写全链路:引号(identQuoteOpen;identQuoteClose / quoteIdent 是派生,只改开引号为 [ 时闭引号自动变 ])、|| 语义(pipesAsOr;pipesAreConcat 派生)、反斜杠转义(backslashEscapes)、方括号标识符(bracketIdentifiers)、~ 正则(supportsTildeRegex)、# 注释(hashLineComment)、分页形态(supportsLimitOffset/Top/FetchFirst/Rownum/CommaLimitOffset)、标识符长度(maxIdentifierLength;fitIdentifier 派生,超长保留前缀并追加 4 位散列)。preferredLimitStyle() 是查询用派生值,不驱动 setPage。直接实现 SqlDialectSpec 时未覆写的方法按 ANSI 基线取默认值。
非法 SQL 抛 SqlParseException,带行号、列号和附近原文,不返回半棵树。
模板占位符(可选)
common-model 一类模板 SQL会用 @age@、%s、<sheet>、<-sheet-> 等占位槽。默认解析关闭占位符(保持严格);需要时通过 SqlParseOptions.placeholders() 显式打开:
SqlParseOptions opt = SqlParseOptions.defaults()
.placeholders(SqlPlaceholders.create()
.atWrapped() // @name@
.printf() // %s / %d / %f …
.angle() // <sheet>
.arrowAngle() // <-sheet->
.add("{{*}}")); // 自定义:恰好一个 * 表示正文
SqlStatement stmt = SQL.parse(
"select * from <20241230.1> where age > @age@ and name in (%s)",
SqlDialect.MYSQL, opt);也可用 SqlPlaceholders.create().commonModelTemplates() 一次打开上述四类内置预设。
规则摘要:
| 模式 | 含义 | 词法结果 |
|---|---|---|
@*@ | 前后 @ 包裹的标识体 | IDENT(可作列/值原子) |
| printf | % + 一个字母 | IDENT |
<*> / <-*-> | 表名占位(允许 . -) | IDENT(可作表名) |
自定义 {{*}} 等 | 非空前后缀 + 正文 | IDENT |
未配置时 @age@ / %s / <sheet> 仍按原行为失败或拆成运算符。故意残缺的语句(如 select * from)即使开启占位符也会失败。
自定义语句解析器(SPI,可选)
内建分派未覆盖的语句(前导关键字不在 SELECT / INSERT / CREATE 等内建清单里,如 BACKUP … / SIGNAL …)默认抛 unsupported statement。需要接住这类语句时,通过 SqlParseOptions.statementParsers() 按前导关键字注册 SqlStatementParser(不区分大小写,仅兜内建未覆盖的关键字——注册 SELECT 不会覆盖内建解析):
SqlStatementParsers registry = SqlStatementParsers.create()
.add("BACKUP", ctx -> {
ctx.next(); // 消费 BACKUP 关键字
String rest = ctx.consumeRest().trim(); // 剩余原文(保留原始间距)
SqlSimpleStatement stmt = new SqlSimpleStatement();
stmt.setText(rest.isEmpty() ? "BACKUP" : "BACKUP " + rest);
return stmt;
});
SqlParseOptions opt = SqlParseOptions.defaults().statementParsers(registry);
SqlStatement stmt = SQL.parse("BACKUP DATABASE shop TO DISK='/tmp/shop.bak'",
SqlDialect.MYSQL, opt);SqlParseContext 提供游标操作子集:token() / dialect() / is(type) / isIdent(word) / match(type) / matchIdent(word) / next() / name() / atStmtBreak() / consumeRest() / error(message)。实现约定:
- 进入
parse时当前记号即注册关键字;实现负责把语句消费到atStmtBreak()(终止符留给框架),留下未消费记号会得到带位置的明确错误。 - 返回
null视为解析失败;抛出的SqlParseException在容错parseAll(..., true)下转为失败占位并继续。 - 同关键字后注册覆盖先注册;
SQL.parse/parseAll/parseExpr的 options 重载均生效。
DELIMITER(批处理终止符)
MySQL 客户端的 DELIMITER ;; / DELIMITER $ / DELIMITER // 等会解析为 SqlSimpleStatement.OTHER,并切换后续 parseAll / 过程体尾部的批处理终止符(默认 ;)。// 与除法同形时,在语句终止处不再当二元运算符。过程体内部语句分隔仍用 ;,不受客户端 DELIMITER 影响。
List<SqlStatement> batch = SQL.parseAll(
"DELIMITER ;;\n"
+ "CREATE PROCEDURE p() BEGIN SELECT 1; END;;\n"
+ "DELIMITER ;\n"
+ "CALL p()",
SqlDialect.MYSQL);统计与改写
门面改写(addLimit / setPage / andWhere / replaceTable / replaceColumn / addSelectItem / removeSelectItem / adaptPagination)都是 先 SQL.clone 再改:返回新树,入参 AST 不变。SQL.clone 是 AST 深拷贝(SqlAstCloner / SqlNode.copy),与方言无关(clone(stmt, dialect) 的方言参数仅保留 API 兼容)。
SqlSchemaStat stat = SQL.stat(sql);
stat.tableNames();
stat.getColumns();
SQL.isReadOnly(stmt); // 是否只读语句(SELECT / SHOW / EXPLAIN…,用于读写分离判断)
stat.getConditions(); // WHERE / JOIN ON / HAVING 紧凑片段
stat.getOrderByColumns();
stat.getGroupByColumns();
stat.getTables(); // Map<String, SqlTableAccess>,同表可 INSERT+SELECT
SqlStatement limited = SQL.addLimit(stmt, 100); // 先 clone,再按默认 MySQL 补 LIMIT
SQL.getLimit(stmt); // Long:LIMIT / TOP / ROWNUM / row_number 页大小
SQL.getOffset(stmt);
SqlStatement page = SQL.setPage(stmt, 2, 20, SqlDialect.MYSQL); // offset=20
SQL.setLimit(stmt, 50, SqlDialect.POSTGRES);
SQL.setOffset(stmt, 10, SqlDialect.POSTGRES);
SqlStatement w = SQL.andWhere(stmt, "tenant_id = ?"); // 内部 parseExpr
SqlStatement t2 = SQL.replaceTable(w, "users", "users_archive");
SqlStatement c2 = SQL.replaceColumn(t2, "name", "user_name"); // 跳过表名 / 表别名
SqlStatement c3 = SQL.addSelectItem(c2, "status"); // 追加 SELECT 列
SqlStatement c4 = SQL.removeSelectItem(c3, "name"); // 按简单列名或别名移除(不可删光)
SqlStatement c5 = SQL.adaptPagination(c4, SqlDialect.ORACLE); // 按目标方言换分页形态
SqlStatement copy = SQL.clone(stmt); // AST 深拷贝addLimit:已有分页(LIMIT / TOP / ROWNUM /row_number包装)时不覆盖。SQL Server 写TOP;经典 Oracle 写单层 ROWNUM 包装;其余写LIMIT。setLimit/setOffset/setPage:替换分页;setPage(pageNo, pageSize)的 pageNo 从 1 起。removeSelectItem:忽略大小写,匹配简单列名(t.col的最后一段)或显式别名;删到只剩一项时再删会抛IllegalArgumentException。- 分页形态、Oracle 包装、
format/toSqlString按需适配见下一节。
改写规则链(可选)
多个改写(自定义 + 内建)按序组合时,用 SqlRewrites 组链、SQL.rewrite 执行。 自定义规则排在内建适配器之前即「前 hook」、之后即「后 hook」。SQL.rewrite 先深拷贝,原 AST 不受影响(空链或 null 直接返回原语句):
SqlStatement out = SQL.rewrite(stmt, SqlRewrites.create()
.add(new TenantRule()) // 前 hook:自定义规则
.add(SqlRewrites.replaceTable("users", "users_2026"))
.add(SqlRewrites.andWhere(SQL.parseExpr("tenant_id = ?")))
.add(SqlRewrites.addSelectItem("status"))
.add(SqlRewrites.removeSelectItem("secret"))
.add(SqlRewrites.adaptPagination(SqlDialect.ORACLE))
.add(SqlRewrites.addLimit(100, SqlDialect.MYSQL)));规则是 SqlRewriteHook 函数式接口:收当前语句、返回继续传递的语句(就地修改返回原对象、或整体替换均可;返回 null 抛 IllegalArgumentException)。 内建适配器与 SqlRewriter 对应静态方法等价(addLimit / setLimit / setOffset / setPage / andWhere / replaceTable / replaceColumn / addSelectItem / removeSelectItem / adaptPagination),就地作用于链上语句。 只改一条时直接用 SQL 门面即可,不必进链。
跨方言分页
改写与回写都以 SqlDialect 的能力方法为准:supportsLimitOffset / supportsTop / supportsFetchFirst / supportsRownum / supportsCommaLimitOffset。preferredLimitStyle() 只描述「只补行数」时的首选形态(TOP / ROWNUM / LIMIT),不单独驱动 setPage。
| 方言 | 标识符 | || | # 行注释 | 分页形态 |
|---|---|---|---|---|
| MYSQL | 反引号 | OR | 是 | LIMIT n / LIMIT offset, n |
| POSTGRES / H2 / ANSI | 双引号 | 拼接 | H2 是 | LIMIT / OFFSET(兼认 FETCH) |
| DAMENG | 双引号 | 拼接 | 否 | LIMIT / OFFSET |
| SQLITE / PRESTO | 双引号 | 拼接 | 否 | LIMIT / OFFSET,无 FETCH FIRST |
| HIVE | 反引号(双引号当字符串) | 拼接 | 否 | LIMIT(supportsLimitOffset=true,带偏移时 AST 仍写 OFFSET;Hive 引擎通常只认 LIMIT n) |
| CLICKHOUSE | 反引号(双引号亦标识符) | 拼接 | 否 | LIMIT n / LIMIT offset, n |
| SQLSERVER | [] | 拼接 | 否 | offset=0 用 TOP,其后 OFFSET FETCH |
| ORACLE(≤11g) | 双引号 | 拼接 | 否 | 裸 SELECT → ROWNUM 子查询包装 |
| ORACLE12 | 双引号 | 拼接 | 否 | 裸 SELECT → OFFSET … FETCH FIRST … ROWS ONLY;已有 ROWNUM 包装仍识别 |
| DB2 | 双引号 | 拼接 | 否 | 仅 FETCH FIRST n ROWS ONLY |
已存在的 Oracle ROWNUM 双层 / WHERE ROWNUM <= n 与 SQL Server row_number 包装:getLimit 返回页大小,setPage / setLimit 只改数值边界(不叠 OFFSET/FETCH)。经典单层 ROWNUM 在 offset>0 时扩成双层。UNION 的分页挂在集合运算链末端。SqlBuilder.limit / offset / toSql(dialect) 走同一套改写(toSql 的方言覆盖 builder 方言)。
经典 Oracle 的 ROWNUM 包装
SqlDialect.ORACLE(supportsRownum && !supportsFetchFirst)对裸 SELECT:
- offset=0:单层子查询
SELECT * FROM ( <原查询> ) XX WHERE ROWNUM <= n - offset>0:双层(中层别名
XX选出ROWNUM AS RN并截ROWNUM <= offset+n,外层别名XXX再滤RN > offset)
WITH 留在外层。setPage / setLimit / adaptPagination 转经典 Oracle 时会先清掉子查询内残留的旧 LIMIT/TOP,避免包装后内层还带着源方言分页。
format / toSqlString(..., ORACLE) 对「offset=0 的 LIMIT/TOP → 单层 ROWNUM」走同一语义的快路径:临时清掉 LIMIT/TOP,按上面的子查询包装回写,再恢复入参(不永久改 AST)。有 offset 时走完整 adaptPagination。
format / toSqlString / adaptPagination
指定目标方言回写时,若 AST 上已有分页(LIMIT / TOP / ROWNUM / row_number / FETCH)与目标形态不兼容(含 MySQL 逗号 LIMIT → PG / ANSI),仅在需要时 clone + adaptPagination 再回写;同形态零额外开销,不改入参。
SQL.adaptPagination(stmt, dialect) 是同一套适配的显式入口(同样先 clone)。
常见方向:
- MySQL
LIMIT 10000/LIMIT 0,10000→ 经典 ORACLE 单层 ROWNUM - MySQL
LIMIT 10,20→ 经典 ORACLE 双层 RN - → ORACLE12 / DB2:
OFFSET … FETCH - → SQLSERVER:offset=0 用
TOP,有 offset 用OFFSET FETCH - ROWNUM → POSTGRES / MYSQL:还原为
LIMIT(offset=0 可省略 OFFSET,不保留逗号风格)
参数化 / Wall / 求值
String finger = SQL.parameterize("SELECT * FROM t WHERE name = 'a' AND age = 1");
// SELECT * FROM t WHERE name = ? AND age = ?
List<Object> litValues = SQL.exportParameterValues(sql); // "a", 1 —— 不是 ?/:name
List<String> binds = SQL.parameters(sql); // "?", ":name"
SqlWallResult wall = SQL.wall(sql); // 默认不拦截解析;显式调用
wall.passed();
wall.violations(); // multi-statement / comment-bypass / always-true-condition / sleep-function / delete-without-where / update-without-where
// 可配置规则(SqlWallConfig);defaults() 打开安全关键项,denyUnion / denyInformationSchema / selectOnly 默认关
SqlWallConfig cfg = SqlWallConfig.defaults()
.denyDdl(true)
.denyDangerousFunctions(true) // SLEEP / BENCHMARK / LOAD_FILE …
.denyIntoOutfile(true)
.selectOnly(false);
SqlWallResult w2 = SQL.wall(sql, SqlDialect.MYSQL, cfg);
// 自定义规则(SqlWallRule SPI):在全部内置检查之后、按注册顺序执行,违规码自动去重
cfg.rules((statement, config, violations) -> {
if (statement.type() != SqlStatementType.SELECT) {
violations.add("non-select");
}
});
SqlWallResult w3 = SQL.wall(sql, SqlDialect.MYSQL, cfg); // violations 可同时含内置码与 non-select
Object v = SQL.eval(expr); // 仅字面量算术与比较;读列则 null
stmt.accept(new SqlAstVisitor() {
@Override protected boolean visitSelect(SqlSelect node) { return true; }
});格式化
format / toSqlString 是 AST 回写(不保留空白与注释)。指定目标方言时若分页形态不兼容会先适配再回写(见「跨方言分页」)。语义往返(parse → format → parse)保证 type()、tables()(忽略大小写)、isReadOnly() 与原文一致;黄金集 SqlGoldenCorpusTest 全覆盖。词级保真由 SqlRoundTripFidelityTest 额外保证:回写文本与原文归一化后逐字等价(去注释 / 去全部空白 / 去独立 AS / 统一大小写,只容忍纯排版差异),覆盖 JOIN 修饰符、DDL 关键字、RENAME 多组、引号形态、DML 修饰符、JDBC 转义等 118 条坑位语料——回写丢词(如 NATURAL LEFT JOIN 丢 LEFT、STRAIGHT_JOIN 丢 STRAIGHT)会直接抓出,不会静默通过。同一方法在 tools-test 由 SqlRoundTripFidelityCorpusTest 批量应用到全部 379 条文件语料(另加语义等价写法归一与 5 条有据白名单,硬断言)。
SQL.format(stmt); // 换行缩进(SELECT 子句换行;CREATE TABLE 按列缩进)
SQL.toSqlString(stmt); // 紧凑单行
SQL.format(stmt, SqlDialect.MYSQL, true);
// 强制给每个标识符段加方言引号(默认 false;不影响字面量/关键字/*/函数名)
SqlFormatOptions opts = SqlFormatOptions.defaults().quoteIdentifiers(true);
SQL.format(stmt, SqlDialect.MYSQL, opts);
SQL.toSqlString(stmt, SqlDialect.POSTGRES, opts);
// 关键字大小写策略(默认 AS_IS = formatter 原生输出,子句/运算符关键字大写)
SQL.format(stmt, SqlDialect.MYSQL, false,
SqlFormatOptions.defaults().keywordCase(SqlKeywordCase.LOWER)); // select ... from ... where ...keywordCase 覆盖子句、DDL、事务控制与表达式运算符关键字(AND / OR / NOT / LIKE / IN / BETWEEN…);不影响标识符、字符串字面量与 raw 直通原文(过程体等保真回写)。可与 quoteIdentifiers 叠加。
原文已带引号的标识符按方言回写:MySQL 反引号、PostgreSQL/Oracle/ANSI/H2 双引号、SQL Server []。 开启 quoteIdentifiers 后,未引号的表/列名也会强制加同套引号。 || 按 AST 回写(CONCAT→||,MySQL 默认解析出的 OR→OR)。
回写是 pretty-print,不保证注释和空白 round-trip。
方言差异
完整分页形态见「跨方言分页」。这里只列解析/回写最容易踩的点:
| 点 | MYSQL | HIVE | SQLSERVER | 其余(PG / Oracle / 达梦 / ANSI / H2 / DB2 / SQLite / Presto) |
|---|---|---|---|---|
| 标识符 | 反引号 ` | 反引号 | [] | 双引号 |
| 双引号 | 默认当字符串 | 当字符串 | 当标识符 | 当标识符 |
|| | 逻辑 OR(SqlParseOptions.pipesAsConcat(true) 可改为拼接) | 拼接 | 拼接 | 拼接 |
# 行注释 | 是 | 否 | 否 | 仅 H2 是 |
| 分页能力 | supportsLimitOffset + 逗号风格 | supportsLimitOffset | supportsTop + FETCH | 见上一节 |
ClickHouse 与 MySQL 一样用反引号,但双引号也是标识符,且分页支持逗号 LIMIT。
快速构建(SqlBuilder)
String sql = SqlBuilder.select("id", "name")
.distinct()
.from("users", "u")
.where("u.status = 1")
.and("u.age > 18")
.leftJoin("orders", "u.id = orders.uid")
.rightJoin("depts", "u.dept = depts.id")
.with("c", "SELECT id FROM t WHERE active = 1")
.groupBy("u.id")
.having("count(1) > 1")
.orderBy("u.id")
.limit(10)
.unionAll(SqlBuilder.select("id", "name").from("archive"))
.toSql();
// 分页按有效方言生成:ORACLE→ROWNUM,ORACLE12/SQLSERVER→OFFSET/FETCH,MySQL→LIMIT
SqlBuilder.select("*").from("t").limit(10).offset(20).toSql(SqlDialect.ORACLE);
SqlBuilder.select("*").from("t").limit(10).offset(20).toSql(SqlDialect.ORACLE12);
// 强制标识符引号(可开可关;默认关)
SqlBuilder.select("id", "name").from("users").quoteIdentifiers(true).toSql();
SqlBuilder.insertInto("t").columns("id", "name").values(1, "a").toSql();
SqlBuilder.update("t").set("name", "b").where("id = 1").toSql();
SqlBuilder.deleteFrom("t").where("id = 1").toSql();
// AST 级拼接(无字符串黑客)
SQL.and(SqlBuilder.parsePredicate("a=1"), SqlBuilder.parsePredicate("b=2"));
SQL.or(SqlBuilder.parsePredicate("a=1"), SqlBuilder.parsePredicate("b=2"));
SQL.concat(Arrays.asList(SQL.parse("SELECT 1"), SQL.parse("SELECT 2")));
SQL.builder().from("t").where("id = ?").limit(5).toSql();构建结果是 AST,再经 SQL.format / toSqlString 回写。toSql(dialect) 的方言参数覆盖 builder 自身方言,并决定分页形态。
绑定占位符与字面量抽取见「参数化 / Wall / 求值」。
别名 API 注意
SELECT 列表项与表源的别名用 alias() 读取:
SqlSelectItem.alias()— 列别名(含点号限定如AS a.b)SqlTableSource.alias()— 表/子查询/表函数别名
不要用 SqlIdentifier.names() 的下标去当「第几个别名」;names() 是限定名各段(db.schema.table),与别名无关。
语法覆盖
- SELECT:列、
*、t.*、DISTINCT / DISTINCTROW / DISTINCT ON、HIGH_PRIORITY / STRAIGHT_JOIN 修饰符 / SQL_SMALL_RESULT / SQL_BIG_RESULT / SQL_BUFFER_RESULT / SQL_CACHE / SQL_NO_CACHE / SQL_CALC_FOUND_ROWS、TOP、INTO表 /@var/ 多变量INTO c,d/INTO (c,d)/OUTFILE(抽目标表进tables/INSERT)、ODPSFORCE PARTITION /SIGNED INTEGER/IGNORE NULLS/LIMIT BY/ jsonb?'pt'/FORCE ALL PARTITIONS、FROM(含 MySQLPARTITION (p0,p1)表分区限定;OraclePARTITION BY (expr)分区外连接;别名后亦可FORCE/USE/IGNORE INDEX)、JOIN(INNER/LEFT/RIGHT/FULL/CROSS/STRAIGHT/逗号;LEFT|RIGHT ANTI|SEMI JOIN;NATURAL与连接类型正交)、SQL Server 表提示WITH (NOLOCK)/WITH (INDEX(ix))(别名前后均可,原文保留)、CROSS APPLY/OUTER APPLY、LATERAL子查询/表函数、UNNEST(...) [WITH ORDINALITY]/TABLE(fn(...))/TABLE(SELECT…)/OPENJSON(...) WITH (...)表函数、PIVOT/UNPIVOT [INCLUDE|EXCLUDE NULLS](含((SELECT…) PIVOT/UNPIVOT …)括号表源)、(VALUES …) AS v(cols)、括号集合运算子查询((SELECT…) UNION …)、ON/USING、WHERE、GROUP BY [WITH ROLLUP|WITH CUBE|DISTINCT|GROUPING SETS(可多个逗号连接)]、HAVING、Teradata/SnowflakeQUALIFY窗口过滤、WINDOW … AS (…)(可继承另一窗口名)、ORDER BY、LIMIT/OFFSET/FETCH FIRST n ROWS ONLY、FOR UPDATE [OF cols] [NOWAIT|SKIP LOCKED]、LOCK IN SHARE MODE、UNION/UNION ALL/INTERSECT/EXCEPT/MINUS、CONNECT BY [NOCYCLE] / START WITH / PRIOR /CONNECT_BY_ROOT、WITH CTE(含 OracleSEARCH DEPTH|BREADTH FIRST BY … SET/CYCLE …);Hive UDTF 多列别名fn(...) AS (c0,c1) - Oracle / 时态:
MODEL→SqlModelClause(PARTITION/DIMENSION/MEASURES/RULES;RULES UPSERT SEQUENTIAL ORDER;MEASURES 字面量/AS别名;可位于 WHERE 后;RULES→SqlModelRule,cellDims/cellDimExprs,失败保留raw);MATCH_RECOGNIZE→SqlMatchRecognize(PARTITION BY/ORDER BY/MEASURES/PATTERN字符串/DEFINE/SUBSET/WITHIN,ROWS PER MATCH/AFTER MATCH字段;PATTERN未建 DSL 树);表级AS OF TIMESTAMP|SCN、VERSIONS BETWEEN TIMESTAMP|SCN … AND …、SQL ServerFOR SYSTEM_TIME AS OF;ClickHouse 参数化函数fn(params)(args) - 窗口函数:
OVER (PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...)、命名窗口引用OVER w、SELECT 级WINDOW w AS (...)(可多个;w2 AS (w)/w2 AS (w ORDER BY …)继承)、FILTER (WHERE ...)、SparkOVER (DISTRIBUTE BY … SORT BY …)(SqlOverExpr.sparkStyle) - 特殊函数:
EXTRACT(field FROM expr)、TRIM(BOTH/LEADING/TRAILING ... FROM expr)、SUBSTRING(expr FROM n FOR m)、POSITION(a IN b)、IF(a,b,c)(MySQL)、CONVERT(expr USING charset)/CONVERT(type, expr)(SQL Server)、GROUP_CONCAT(... ORDER BY ... SEPARATOR ...)、STRING_AGG(... ORDER BY ...)/WITHIN GROUP (ORDER BY ...)、MATCH (cols) AGAINST (...)(含WITH QUERY EXPANSION)、WEIGHT_STRING(… AS CHAR(n) LEVEL n [DESC])(MySQL 8)、SQL/JSON 构造器(json_object/json_array/json_objectagg/json_arrayagg/json_table,key:value / KEY…VALUE / ON NULL / UNIQUE KEYS / FORMAT JSON / COLUMNS…PATH 专用文法整体保留)、XML 系函数(XMLSERIALIZE/XMLPARSE/XMLROOT/XMLAGG/XMLELEMENT/XMLFOREST/EXTRACTVALUE)、TRANSLATE(… USING CHAR_CS) - INSERT / REPLACE:列清单、VALUES 多行、INSERT SELECT、
INSERT … (WITH … SELECT …)、INSERT SET、ON DUPLICATE KEY UPDATE、PGON CONFLICT(DO NOTHING/DO UPDATE/ON CONSTRAINT)、RETURNING(*或多列列表)、SQL ServerOUTPUT/OUTPUT … INTO、OracleINSERT ALL/INSERT FIRST;MySQLLOW_PRIORITY/DELAYED/HIGH_PRIORITY/IGNORE可叠加并回写(REPLACE 亦支持 DELAYED);HiveINSERT OVERWRITE [TABLE] t [PARTITION (...)] SELECT …(SqlInsert.overwrite/tableKeyword/partitionRaw);ODPSUPDATE/DELETE FORCE PARTITION … - UPDATE / DELETE:JOIN、WHERE、ORDER BY、LIMIT、PG
UPDATE … FROM、PG/MySQLDELETE … USING、RETURNING(多列)、SQL ServerOUTPUT/OUTPUT … INTO(表 /@var/#tmp,进tables());MySQLLOW_PRIORITY/QUICK/IGNORE修饰符保留并回写;MySQL 多表删除第二形式DELETE FROM a1, a2 USING …(SqlDelete.targets) - MERGE:INTO / USING / ON、多个
WHEN MATCHED [AND pred]、WHEN NOT MATCHED [BY TARGET|SOURCE]、UPDATE … DELETE WHERE、INSERT … VALUES … WHERE、OUTPUT/OUTPUT … INTO - DDL:CREATE/DROP/ALTER TABLE|VIEW|INDEX|DATABASE|PROCEDURE|FUNCTION|TRIGGER|EVENT|USER(抽对象名;
CREATE OR REPLACE;MySQLALGORITHM/DEFINER/SQL SECURITY;VIEW/CTAS 的 AS query;过程/函数参数 →SqlRoutineParam,FUNCTION RETURNS→returnsType,BEGIN 体 →bodyStatements(保留bodyRaw/tail往返);CREATE TABLE 列定义原文(columnDefinitions)+ ENGINE/CHARSET/COLLATE/COMMENT + 表级 FOREIGN KEY 引用表;CREATE TABLE t2 LIKE t1抽源表进tables();ALTER ADD/DROP INDEX(含ADD UNIQUE KEY|INDEX保留UNIQUE)、DROP INDEX idx ON t、RENAME TO、CHANGE/MODIFY 列定义、ADD CONSTRAINT);独立语句RENAME TABLE a TO b[, c TO d](多组完整回写);CREATE/DROP USER 'u'@'%'账号原文保留(userSpec);CREATE TYPE … AS OBJECT/VARRAY/ENUM原文保留;DROP … PURGE/DROP TABLESPACE … ENGINE、TRUNCATE … PURGE SNAPSHOT LOG尾段原文;MySQL 8 函数索引ADD KEY idx ((expr));CTAS 尾缀WITH [NO] DATA EXPLAIN/DESCRIBE→SqlExplainStatement(ANALYZE/FORMAT/BUFFERS 等选项 + 嵌套 statement)、SET→SqlSetStatement(多赋值 / NAMES / CHARACTER SET / SESSION|GLOBAL)、USE、SHOW、CALL(实参进 AST)、TRUNCATE、GRANT / REVOKE(权限 + ON 对象名;收件人user@host紧凑回写;REVOKE 用 FROM)- 过程块 / 维护 / 事务:
BEGIN … END/ 顶层匿名DECLARE … BEGIN … END→SqlBlockStatement(支持EXCEPTION WHEN、标签lab: BEGIN…END lab);PostgreSQLDO $$…$$/DO $tag$…$tag$;IF…ELSIF/ELSEIF…END IF;会话式DECLARE x INT(OTHER);过程体内DECLARE/CURSOR FOR→SqlDeclareStatement,CONTINUE|EXIT|UNDO HANDLER→SqlHandlerStatement;IF/WHILE/LOOP/REPEAT/CASE…END CASE/LEAVE/ITERATE/RETURN→SqlControlStatement(可带循环标签);TRIGGER抽triggerTiming/triggerEvent/triggerTable/triggerUpdateColumns/FOR EACH/FOLLOWS|PRECEDES;EVENT抽ON SCHEDULE AT|EVERY、eventStarts/eventEnds/eventEnabled/eventComment/eventOnCompletion/eventDisableOnSlave;裸BEGIN/BEGIN WORK/START TRANSACTION→SqlStartTransactionStatement(隔离级别 / READ WRITE|ONLY / WITH CONSISTENT SNAPSHOT);COMMIT/ROLLBACK [TO SAVEPOINT]/SAVEPOINT/RELEASE SAVEPOINT→SqlTransactionControlStatement;FLUSH …→SqlFlushStatement(选项列表 / TABLES 表名);LOCK TABLES/UNLOCK TABLES→SqlLockTablesStatement;ANALYZE/VACUUM/OPTIMIZE|REPAIR|CHECK TABLE→SqlMaintenanceStatement(tables + optionsRaw);SHOW CREATE TABLE|VIEW|DATABASE/SHOW COLUMNS|INDEX|TABLES→SqlShowStatement;COMMENT ON TABLE|COLUMN|…→SqlCommentOnStatement(objectKind/name/comment);SQL ServerGO批分隔;PGCOPY … FROM|TO→SqlCopyStatement(表/列/STDIN·PROGRAM·文件 + WITH 原文);MySQLLOAD DATA [LOCAL] INFILE … INTO TABLE→SqlLoadDataStatement(文件/表/列 + FIELDS·LINES·IGNORE 原文);MySQL 表HANDLER t OPEN|READ|CLOSE→SqlTableHandlerStatement;PREPARE/EXECUTE/DEALLOCATE PREPARE/EXECUTE IMMEDIATE→SqlPrepareStatement(名 / FROM·源 / USING) - 表达式:字面量、绑定
?/:name/:0/@var、相邻字符串隐式拼接、算术比较、AND/OR/XOR/NOT、IN(含IN :name/IN ?无括号绑定列表)/BETWEEN/LIKE/ILIKE/NOT ILIKE/REGEXP、IS NULL、IS DISTINCT FROM/IS NOT DISTINCT FROM、CASE、CAST /TRY_CAST/::、函数(含USING charset)、EXISTS、子查询、函数结果字段访问f(x).y、INTERVAL '1 day'/INTERVAL 1 DAY/INTERVAL … YEAR(n) TO MONTH、CAST(… AS INTERVAL DAY TO SECOND)、X'FF'/0xFF、行构造(a,b)、JSON->->>#>#>>、数组下标arr[1]、PG 数组构造ARRAY[1,2,3](含ANY(ARRAY[...]))、= ANY/SOME/ALL (...);INTERVAL复合单位(HOUR_MINUTE/YEAR_MONTH等)与表达式值(INTERVAL 6/4 HOUR_MINUTE);字符集前缀字面量(_latin1'x'/_utf8mb4'…'/_binary'…'/_utf32 X'…');NOT REGEXP;PL/SQL 游标属性SQL%FOUND/c1%NOTFOUND;count(UNIQUE …)(等同 DISTINCT);JDBC/ODBC 转义解包({fn …}函数、{d|t|ts '…'}类型字面量、{oj …}JOIN、{call …}、{escape …});另含 PG@>/<@/~/~*、MySQLFORCE INDEX FOR …/<=>/INSERT DELAYED/BINARY、SQL ServerTOP WITH TIES、TABLESAMPLE/SAMPLE、Oracle(+)外连接后缀 /CONNECT_BY_ROOT - 注释:
--、/* */、MySQL#;仅注释/空白的输入解析为OTHER空语句(不抛 empty SQL);MySQL 可执行注释/*!40101 … */展开为内部 SQL(不整段丢弃);优化器 hint/*+ … */挂到 SELECT / 表并可 format 回写;位置游离的 hint(如 WHERE 中的/*+TDDL:MASTER*/、Trino/*+joinMethod=…*/)统一吸收并挂到 SELECT 回写 - 标识符:MySQL 裸标识符允许数字开头(如
32强国/1019使用),整段不能只是数字;32/32.5/32e1/0xFF仍为字面量;反引号形式原本即可;限定名中点号后的数字开头段可解析(t.1_id/a.32强国;前导小数.5仍为 NUMBER);点号后单引号名作引用标识符(T.'Group');SELECT 列表别名支持点号限定(AS a.b);函数与表名支持 Oracle DB Link 后缀(fn@dblink/t@dblink,SqlIdentifier.dblink) - 客户端 / 批处理:
DELIMITER xx切换parseAll终止符(见「DELIMITER」);SQL ServerGO批分隔 - 解析选项:
SqlParseOptions.keepComments(true)(默认 false)时普通注释进入SqlStatement.comments(),热路径默认仍丢弃;SqlParseOptions.pipesAsConcat(true)让 MySQL 方言下||按拼接解析(等同PIPES_AS_CONCAT);SqlParseOptions.placeholders()可配置模板占位(默认关闭,见「模板占位符」);SQL.parseAll(sql, dialect, true)容错多语句(失败占位 +parseError,供审计)
表达式另支持:SUBSTR…FROM…FOR、DECIMAL/REAL/TIME ? 等类型字面量、SIMILAR TO、CONTAINS、PG @@ tsquery、::type[] 数组类型后缀;SELECT … FOR JSON;Informix SKIP/FIRST;VALUES … UNION/ORDER/LIMIT;Hive LATERAL VIEW OUTER;AS 后可用 FULL/CROSS 等关键字作别名。
明确未做:过程体执行引擎(AST 结构化已覆盖 DECLARE/HANDLER/控制流/TRIGGER/EVENT 等,但不解释执行)、完整 Wall 规则集(SqlWallConfig 提供可配置子集,非 Druid WallFilter 全量)、MATCH_RECOGNIZE.PATTERN 的 DSL 树(仍为字符串)。CREATE TABLE 列类型/约束已进 columnDefinitions 并可 format 往返。未知函数按普通函数调用解析,不失败。
跨方言类型转换(进行中)
表结构 / SQL 跨方言转换走 Normal Form 中转(canonical 类型,避免 N² pairwise 映射)。设计见 sql-schema-converter-design.md。
当前已落地 Phase 0–1(JDK 8,不改 SqlDdlStatement.columnDefinitions() 的 List<String> 签名):
import com.alianga.jkit.sql.schema.model.CanonicalType;
import com.alianga.jkit.sql.schema.model.ColumnDefinition;
import com.alianga.jkit.sql.schema.parse.SqlColumnDefinitionParser;
import com.alianga.jkit.sql.schema.registry.SqlDataTypeRegistry;
SqlDdlStatement ddl = (SqlDdlStatement) SQL.parse(
"CREATE TABLE t (id INT NOT NULL AUTO_INCREMENT, name VARCHAR(32))",
SqlDialect.MYSQL);
List<ColumnDefinition> cols = SqlColumnDefinitionParser.fromDdl(ddl, SqlDialect.MYSQL);
// cols.get(0): name=id, type=INT, NOT NULL + AUTO_INCREMENT
SqlDataTypeRegistry types = SqlDataTypeRegistry.builtins();
types.convert("VARCHAR(100)", SqlDialect.MYSQL, SqlDialect.ORACLE); // VARCHAR2(100)
types.convert("DATETIME", SqlDialect.MYSQL, SqlDialect.POSTGRES); // TIMESTAMP
types.fromDialect("TINYINT(1)", SqlDialect.MYSQL); // BOOLEAN
types.fromDialect("NUMBER(10,2)", SqlDialect.ORACLE); // DECIMAL未声明的类型碰撞由 RegistryValidator 在内置表构建时阻断;RegistryValidationTest 进 CI。
Phase 2–3 已提供整句入口(CREATE TABLE 列类型/自增/UNSIGNED/默认值/表选项;其它语句按目标方言 format,分页复用现有适配):
String pg = SQL.convert(
"CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, flag TINYINT(1) DEFAULT 0)",
SqlDialect.MYSQL, SqlDialect.POSTGRES);
// id INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY, flag BOOLEAN DEFAULT false
ConversionResult r = SQL.convert(sql, SqlDialect.MYSQL, SqlDialect.ORACLE,
SqlSchemaConvertOptions.defaults()
.failOnSeverity(ConversionWarning.Severity.MANUAL_ACTION_REQUIRED));Oracle ≤11g 的自增默认给出 MANUAL_ACTION_REQUIRED;generateOracleSequence(true) 会附录 SEQUENCE+TRIGGER。
查询函数已改写:
IF(a,b,c)→CASE WHEN(非 MySQL);JOIN ON/DEFAULT NOW()同样走函数表NOW()/CURDATE()/CURTIME()GROUP_CONCAT↔STRING_AGG/LISTAGGIFNULL/NVL/ISNULL(二元)按目标方言改名;COALESCE为 PG/ANSICONCAT(a,b,c)在 Oracle 下改为||(OracleCONCAT只接受两参数)CAST/CONVERT(expr, type)的类型走 canonical 表- MySQL
CONVERT(expr USING charset)不会误映射成 CAST,只告警并保留原文 DATE_ADD/DATE_SUB→ 加减INTERVAL;DATEDIFF→ 日期相减;FROM_UNIXTIME→TO_TIMESTAMPSUBSTRING/LEFT/RIGHT/MID:Oracle/达梦SUBSTR;SQL Server 两参数补LEN、负起点改RIGHT;SQLite/Hive 的LEFT/RIGHT展开成SUBSTR。回写按方言:PG/MySQL 用FROM n FOR m,SQL Server/SQLite 用逗号UCASE/LCASE→UPPER/LOWER;CONCAT_WS;LPAD/RPAD;SPACE;CEIL/CEILING;POW/POWER;MOD;YEAR/MONTH/DAY/HOUR/MINUTE/SECOND;SYSDATE;LAST_DAY;CHAR/CHRDECODE/NVL2→CASE;FIND_IN_SET/SUBSTRING_INDEX无干净等价则告警并保留
目标方言不支持的 MySQL 表内 KEY/INDEX 会改成附录 CREATE INDEX;FULLTEXT/SPATIAL 去掉并 MANUAL_ACTION_REQUIRED。UNIQUE KEY 改写为可移植的 UNIQUE (...)。独立 CREATE INDEX … USING BTREE 转到非 MySQL 时去掉 USING。
SQL.convertBatch 批量转换。ALTER TABLE ADD/MODIFY/CHANGE 会转换列类型;转到 PG/H2 时改成 ALTER COLUMN … TYPE,NOT NULL/DEFAULT 进附录。
转换结果会按目标方言再 parse 一遍作为语料回归。真实建表与表达式执行验证在上级目录 tools-test(jkit 自己的 parser 比真库宽松,复解析抓不到的问题由真库执行兜底):
cd ../tools-test
mvn -Dtest=CrossDialectDdlExecutionTest test # MySQL→PG 建表;需要 Docker+本地 postgres 镜像,没有则 skip
mvn -Dtest=CrossDialectExprExecutionTest test # DDL+表达式真库执行:PG 上 DATE_ADD→INTERVAL、DATEDIFF→CAST 减法、MySQL 上 ||→CONCAT、SQLite 上 AUTOINCREMENT、NUMERIC(10,2) 不截断;PG/MySQL 走 Docker,SQLite 走内存库
mvn -Dtest=LocalDatasourceConvertTest test # 读 src/test/resources/datasource,连本机 MySQL/PG/Oracle
mvn -Dtest=LocalDatasourceFunctionRewriteTest test # 同上,真库执行 SUBSTRING/LEFT/RIGHT/LOCATE 改写结果
java -jar target/benchmarks.jar com.alianga.test.sql.jmh.SqlSchemaConvertBenchmark -f 1 -wi 1 -i 1当前 13 个一等方言(含 DAMENG / ORACLE12)见 设计文档第四节。新产品不必改 SqlDialect 枚举:实现 SqlDialectSpec(或 SqlDialectWrapper),用 typeFamily() 复用内置类型表,用 dialectId() + SPI 覆盖个别写法。SQL.convert / SQL.parse 都吃 SqlDialectSpec。
ConversionResult.sqlWithExtras() 含附录 CREATE INDEX / Oracle SEQUENCE。内置 DATE_FORMAT → TO_CHAR 走 SqlFunctionRegistry;第三方用 SqlSchemaConverterProvider.registerFunctions 追加或覆盖(返回 null 回落内置),不必改 FunctionAstRewriter。VARCHAR 超长默认提升为 TEXT/CLOB。
如何加类型别名、覆盖某方言写法、加 canonical 类型、加数据库、加函数改写,见 第九节。
实体扫描生成 DDL / DML
对标 data-set EntityScanner:扫描包下带 @SqlTable、JPA @Entity、MyBatis-Plus @TableName/@TableId 的类(不依赖 Spring / JPA / MyBatis / Hibernate 编译),再按方言生成建表与增删改查。也认 @TableField(exist=false 跳过)、JPA @Index/@Enumerated/@Embedded、任意 @Comment(按简单名,不绑包名)、Hibernate @ColumnDefault;List/Set/@OneToMany 默认不建列。createTables 按外键把被引用表排在前面。Java 类型走 canonical 类型表。
表 / 列注释:@SqlTable(comment=…)、@SqlColumn(comment=…),以及任意简单名为 Comment 的注解(标在类上=表注释,标在字段上=列注释,读 value 或 comment;jkit 注解优先)。MySQL / Hive / ClickHouse 写成列内 / 表尾 COMMENT '…';H2 列内 COMMENT,表级走 COMMENT ON TABLE;PostgreSQL / Oracle / DB2 / ANSI 走 COMMENT ON TABLE|COLUMN;SQL Server 走 sp_addextendedproperty;Presto 表级 WITH (comment=…);SQLite 无注释语法,忽略。自动建表把这些附录拆成独立变更执行。
列默认值:Hibernate @ColumnDefault 的 value 是 SQL 片段(不含 DEFAULT 关键字),原样写入列定义,例如 @ColumnDefault("0") → DEFAULT 0,@ColumnDefault("'guest'") → DEFAULT 'guest',@ColumnDefault("CURRENT_TIMESTAMP") → DEFAULT CURRENT_TIMESTAMP。columnDefinition 里已有 DEFAULT 时不再重复。
无 IDENTITY 的方言(Oracle ≤11g,枚举 ORACLE)用 CREATE SEQUENCE {table}_{column}_seq + BEFORE INSERT 触发器代替自增主键;达梦(DAMENG)列上写 IDENTITY;Oracle 12c+ 仍用 GENERATED … AS IDENTITY。SqlEntities.extraSql / sequenceSql 可单独取附录。
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.SqlGenerated;
import com.alianga.jkit.sql.entity.SqlColumn;
@SqlTable(name = "demo_user")
public class DemoUser {
@SqlId @SqlGenerated Long id;
@SqlColumn(name = "user_name", length = 32, nullable = false) String name;
Integer age;
}
List<Class<?>> entities = SqlEntities.scan("com.example.entity");
String ddl = SqlEntities.createTable(DemoUser.class, SqlDialect.POSTGRES);
// CREATE TABLE demo_user (id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ...)
String ins = SqlEntities.insert(user, SqlDialect.MYSQL);
String upd = SqlEntities.updateById(user, SqlDialect.MYSQL);
String del = SqlEntities.deleteById(DemoUser.class, 1L, SqlDialect.MYSQL);
String sel = SqlEntities.selectById(DemoUser.class, 1L, SqlDialect.MYSQL);API 一览
| 方法 | 说明 |
|---|---|
scan(String) / scan(List<String>) | 扫描包下的实体类 |
inspect(Class<?>) | 解析成 SqlEntityModel(表名、列、主键、索引) |
createTable(Class<?>, SqlDialect) | 单表 DDL,末尾同批拼 CREATE INDEX |
createTable(SqlEntityModel, SqlDialect, boolean includeIndexes) | includeIndexes=false 时只出 CREATE TABLE |
createTables(String basePackage, SqlDialect) / createTables(List<Class<?>>, SqlDialect) | 多表;按外键把被引用表排在前面 |
orderByForeignKeys(List<Class<?>>) | 只排序不生成 SQL;成环或引用外部表时保持原相对顺序 |
dropTable(Class<?>, SqlDialect) | DROP TABLE |
insert(Object, SqlDialect) / insertBatch(List<?>, SqlDialect) | 单行 / 多行 INSERT |
insertPlaceholders(Class<?>, SqlDialect) | 带 ? 的 INSERT,交给 PreparedStatement |
updateById(Object, SqlDialect) / deleteById(Class<?>, Object, SqlDialect) | 按主键改 / 删 |
selectById(Class<?>, Object, SqlDialect) / selectAll(Class<?>, SqlDialect) | 按主键查 / 全表查 |
columnSql(SqlEntityColumn, SqlDialect, boolean inlinePk) | 单列定义文本;ALTER TABLE … ADD 传 inlinePk=false |
columnTypeSql(SqlEntityColumn, SqlDialect) | 只取类型文本,供结构对比 |
createIndex(String tableName, String spec) / createIndex(..., SqlDialect) | 单独一条 CREATE INDEX;spec 为 name:col1,col2 或 col1,col2(未写名字则 {table}_{col}_idx)。带方言时按标识符长度上限截断(经典 Oracle 30 字符) |
indexName(String tableName, String spec) / indexName(..., SqlDialect) | 解析 / 生成索引名,与 createIndex 同一规则 |
extraSql(SqlEntityModel, SqlDialect, SqlSchemaConvertOptions) | 建表附录:COMMENT ON / SQL Server 扩展属性 / 无 IDENTITY 方言的 SEQUENCE |
sequenceSql(String table, SqlEntityColumn, SqlDialect) | 无 IDENTITY 时生成 SEQUENCE(+ Oracle 触发器);否则 null。序列名 / 触发器名同样按方言上限截断 |
sequenceName(String table, String column) / sequenceName(..., SqlDialect) | {table}_{column}_seq;带方言时按上限截断 |
columnSql / columnTypeSql / createIndex / extraSql / sequenceSql 给「按实体做结构对比 / 增量加列 / 附录注释与序列」用——自动建表模块靠它们拼 ALTER TABLE … ADD 和附录:
SqlEntityModel model = SqlEntities.inspect(DemoUser.class);
// 建表与索引分开控制
String ddl = SqlEntities.createTable(model, SqlDialect.POSTGRES, false);
// CREATE TABLE demo_user (id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ...)
String idx = SqlEntities.createIndex("demo_user", "idx_user_name:user_name");
// CREATE INDEX idx_user_name ON demo_user (user_name)
// 对比类型、拼增量加列
SqlEntities.columnTypeSql(model.idColumn(), SqlDialect.POSTGRES); // BIGINT
String col = SqlEntities.columnSql(model.columns().get(1), SqlDialect.MYSQL, false);
// user_name VARCHAR(32) NOT NULL
String add = "ALTER TABLE demo_user ADD " + col;
// 批量与占位符
SqlEntities.insertBatch(users, SqlDialect.MYSQL);
SqlEntities.insertPlaceholders(DemoUser.class, SqlDialect.MYSQL);
// INSERT INTO demo_user(user_name, email, age, amount) VALUES (?, ?, ?, ?)
SqlEntities.selectAll(DemoUser.class, SqlDialect.MYSQL);
// SELECT id, user_name, email, age, amount FROM demo_user
// 多表:按外键排序后一次建完
List<Class<?>> ordered = SqlEntities.orderByForeignKeys(entities);
String all = SqlEntities.createTables(ordered, SqlDialect.POSTGRES);
SqlEntities.dropTable(DemoUser.class, SqlDialect.MYSQL); // DROP TABLE demo_user有 javax.persistence / jakarta.persistence 时同样识别 @Entity @Table @Column @Id @GeneratedValue @Transient @Lob(反射按类名,无编译依赖)。任意 @Comment、Hibernate @ColumnDefault 同此。@GeneratedValue 仅在整数列且策略不是 UUID 时写成数据库自增;generator="system-uuid" / GenerationType.UUID / 字符串主键只保留 PRIMARY KEY。子类与父类(含 JPA MappedSuperclass)重复的列名只保留子类声明。
jkit-sql 只生成 SQL。启动时连库建表 / 加列见独立模块 jkit-sql-auto。
性能
手写词法 + ThreadLocal 复用 Parser。和 Druid / JSqlParser 的对比测试在上级目录 tools-test(不进本模块,以免引入第三方依赖):
cd ../tools-test
mvn -Dtest=SqlParserCompareTest testtools-test 文件语料 sql-corpus.txt(约 379 条)上 jkit 379/379(100%);内嵌 CORPUS(约 64 条)亦全绿。竞品缺口随样例变化(Druid 常见挂 DISTINCT ON / WINDOW 继承 / UNNEST;JSqlParser 常见挂 LOCK IN SHARE MODE / [dbo].[user] / WINDOW 继承)。
吞吐以 JMH 为准(tools-test 的 SqlParseBenchmark)。正式轮实测(fork=2、warmup=5、iteration=5、Cnt=10,avgt,ns/op,越小越好;2026-09-10,i9-13900HX / OpenJDK 17.0.11):
| 引擎 | SIMPLE(单表查询) | JOIN(双表连接) | WINDOW(窗口函数) |
|---|---|---|---|
| jkit-sql | 508 | 1,073 | 657 |
| Druid 1.2.23 | 1,274(2.5×) | 3,667(3.4×) | 3,023(4.6×) |
| JSqlParser 4.9 | 220,445(434×) | 252,464(235×) | 301,220(459×) |
jkit 与 Druid 同属手写解析档,且稳定快 2.5~4.6 倍;JavaCC 生成的 JSqlParser 慢两个数量级以上。复现(约 30 分钟):
cd ../tools-test
mvn -DskipTests package
java -jar target/benchmarks.jar com.alianga.test.sql.jmh.SqlParseBenchmark -f 2 -wi 5 -i 5| 解析成功率(文件语料) | 备注 | |
|---|---|---|
| jkit-sql | 379/379 (100%) | 模块内黄金集约 216 条(含往返,SqlGoldenCorpusTest 约 432 断言)+ 回写保真 118 条(SqlRoundTripFidelityTest)+ tools-test 批量保真 379 条(SqlRoundTripFidelityCorpusTest);mvn -pl jkit-sql test 1376 条 |
| Druid 1.2.23 | 低于 jkit(缺口见 target/sql-compare-fail.txt) | 对比不进本库依赖 |
| JSqlParser 4.9 | 低于 jkit | 同上 |
语料与对比
- 模块内:
SqlGoldenCorpusTest(约 216 条,含往返)、SqlRoundTripFidelityTest(118 条,回写与原文归一化后逐字比对)、CommonModelSqlCorpusTest(从icell/common-model收获,87 条可解析)、Complex100GiantsTest/SqlModelMatchDeepenTest(MODEL / MATCH_RECOGNIZE 结构化字段)。 - 与 Druid / JSqlParser 对比只在上级工程
tools-test的SqlParserCompareTest(成功率 + 表名集合差分 + JMH;不进本库依赖)。 - 回写保真批量验收:
tools-test的SqlRoundTripFidelityCorpusTest(379 条文件语料批量归一化逐字比对,硬断言;5 条有据白名单,报告在target/sql-fidelity-report.txt)。 - 外部语料批量验收(同在
tools-test,不进本库依赖):ExternalSqlCorpusTest— bird / Spider / complex100 等 jkit 解析成功率(soft-assert)ExternalSqlCorpusCompareTest— jkit vs Druid vs JSqlParser 正确率 + 速度;报告在target/sql-corpus-reports/- 语料说明见
tools-test/src/test/resources/sql-corpora/README.md(近期对比:bird/spider_ddl/dev/train* / complex100 均为 100%;spider_test ≈ 99.63%)
cd ../tools-test
mvn -Dtest=ExternalSqlCorpusTest test
mvn -Dtest=ExternalSqlCorpusCompareTest test业务场景与实践案例
jkit-sql 的能力可以落在下面 14 类业务场景里,每类配至少两个最佳实践案例。所有片段都取自 jkit-sql/src/test/java/com/alianga/jkit/sql/SqlBusinessScenarioTest.java,可直接运行回归:
mvn -pl jkit-sql test -Dtest=SqlBusinessScenarioTest| # | 业务场景 | 主要 API | 案例 |
|---|---|---|---|
| 1 | SQL 审计与依赖分析 | SQL.tables / SQL.stat / SqlStatement.isReadOnly | 依赖提取、写操作标记 |
| 2 | 读写分离路由 | SqlStatement.isReadOnly | 只读走从库、持锁 SELECT 走主库 |
| 3 | SQL 注入防护 | SQL.parameterize / SQL.exportParameterValues | 字面量收编、绑定值导出 |
| 4 | SQL 防火墙 | SQL.wall / SqlWallConfig | 危险语句拦截、按通道放行 DDL |
| 5 | 跨方言数据库迁移 | SQL.convertBatch / SqlSchemaConverter.convert | DDL 翻译闭环、批量 DML 函数改写 |
| 6 | 多方言分页 | SQL.setPage / SQL.adaptPagination / SQL.toSqlString | LIMIT/TOP/FETCH/ROWNUM、offset=0 单层包装、回写按需适配 |
| 7 | 多租户改写 | SqlRewrites.replaceTable / SqlRewrites.andWhere | 分表路由、租户条件注入 |
| 8 | 数据脱敏与列级权限 | SQL.removeSelectItem / SqlRewrites.replaceColumn | 敏感列裁剪、物理列改名映射 |
| 9 | 动态 SQL 构建 | SqlBuilder | 条件查询组装、INSERT/UPDATE |
| 10 | 实体驱动多方言建表 | SqlEntities.createTable | MySQL 内联注释、PG 的 COMMENT ON |
| 11 | SQL 格式化与规范统一 | SQL.format / SqlFormatOptions | 关键字大小写归一、pretty 多行 |
| 12 | 遗留模板占位符迁移 | SqlPlaceholders / SqlParseOptions.placeholders | @xx@、%s 两种风格 |
| 13 | 表达式预计算 | SQL.eval | 常量折叠、列引用返回 null |
| 14 | 安全改写不污染原语句 | SQL.clone / clone-then-mutate | 复用缓存原语句、分页改写不改原句 |
1. SQL 审计与依赖分析
上线前要知道一条 SQL 动了哪些表、哪些列,做影响面评估与变更评审。
案例 1:提取表/列依赖
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");案例 2:批量脚本里标记写操作
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
}
}2. 读写分离路由
代理层按语句类型把流量分流到主库/从库。
案例 1:只读句子走从库
SqlStatement q = SQL.parse("SELECT * FROM t_user WHERE id = 1");
boolean replica = q.isReadOnly(); // true案例 2:持锁 SELECT 与写语句走主库
// SELECT ... FOR UPDATE 持锁,路由到从库会导致行锁失效
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会被判为写语句——这是有意的,否则读写分离路由会把持锁查询发到从库。
3. SQL 注入防护
把外部拼接进 SQL 的字面量收编成绑定参数,交给预编译语句执行。
案例 1:一行把字面量参数化
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 = ?案例 2:导出绑定值,两步安全下发
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)
面向用户可编辑查询、开放接口、低代码平台等不可信入口做前置拦截。
案例 1:拦截典型危险模式
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,合法放行案例 2:运维通道按需放行 DDL
SQL.wall("DROP TABLE t").violations(); // [deny-ddl],默认拦截
SqlWallConfig allowDdl = SqlWallConfig.defaults().denyDdl(false);
SQL.wall("DROP TABLE t", SqlDialect.MYSQL, allowDdl).passed(); // true5. 跨方言数据库迁移
存量 MySQL 语句翻译到目标库,输出可被目标方言再解析,形成验证闭环。
案例 1:单条 DDL 翻译 + 复解析校验
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); // 目标方言能解析才算翻译成功案例 2:批量 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 t6. 多方言分页适配
同一份业务查询按目标方言生成分页语句。setPage / adaptPagination / format / toSqlString 共用同一套形态判断。
案例 1:MySQL / PostgreSQL 的 LIMIT OFFSET
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案例 2:经典 Oracle offset=0 单层包装,以及跨页双层
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 page = SQL.setPage(
SQL.parse("SELECT * FROM emp"), 2, 8, SqlDialect.ORACLE);
// 双层 RN,不含 OFFSET/FETCH
SQL.parse(SQL.toSqlString(page, SqlDialect.ORACLE), SqlDialect.ORACLE);
// 已有 MySQL LIMIT 的 AST,按目标方言回写或显式适配
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 ONLY7. 多租户改写
分表路由 + 统一注入租户过滤,防止漏加租户条件。
案例 1:按租户路由到分表
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案例 2:网关注入 tenant_id 条件
SqlStatement stmt = SQL.parse("SELECT id FROM t_order WHERE status = 1");
SqlStatement out = 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 不改代码即可适配。
案例 1:裁剪敏感列
SqlStatement stmt = SQL.parse("SELECT id, name, phone, id_card FROM t_customer WHERE id = 1");
SqlStatement out = SQL.removeSelectItem(stmt, "phone"); // 已 clone,不必再包一层
out = SQL.removeSelectItem(out, "id_card");
// SELECT id, name FROM t_customer WHERE id = 1案例 2:物理列改名映射
SqlStatement out = 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'9. 动态 SQL 构建
用链式构造器替代字符串拼接,杜绝注入与括号/逗号类语法错。
案例 1:条件查询链式组装
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); // 产物一定是合法 SQL案例 2:INSERT / UPDATE 零拼接
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 = 110. 实体驱动多方言建表
一套实体注解,按目标库产出对应 DDL,避免维护多份建表脚本。
案例 1:MySQL 内联 COMMENT
@SqlTable(name = "t_member", comment = "会员表")
class Member {
@SqlId
Long id;
@SqlColumn(comment = "昵称")
String nick;
}
String ddl = SqlEntities.createTable(Member.class, SqlDialect.MYSQL);
// CREATE TABLE t_member (...) COMMENT '会员表',列上带 COMMENT '昵称'案例 2:PostgreSQL 走独立 COMMENT ON
String ddl = SqlEntities.createTable(Member.class, SqlDialect.POSTGRES);
// COMMENT ON TABLE t_member IS '会员表';
// COMMENT ON COLUMN t_member.nick IS '昵称';11. SQL 格式化与规范统一
日志脱乱、评审 diff 收敛、关键字大小写归一。
案例 1:关键字大小写归一
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案例 2:pretty 多行 + 往返稳定
String pretty = SQL.format("select id,name,phone from t_user where status=1 and age>18 "
+ "order by id desc limit 10");
pretty.contains("\n"); // true
// 美化后再解析,语义不变
assertEquals(SQL.tables(SQL.parse(ugly)), SQL.tables(SQL.parse(pretty)));12. 遗留模板占位符迁移
老系统里的 @xx@ / %s 风格 SQL,不改写文本也能直接解析。
案例 1:@xx@ 风格
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案例 2:%s(printf)风格
SqlStatement stmt = SQL.parse("SELECT %s FROM (SELECT '20221111' AS %s) AS a",
SqlDialect.MYSQL,
SqlParseOptions.defaults().placeholders(SqlPlaceholders.create().printf()));
SQL.tables(stmt); // [],FROM 的是派生表13. 表达式预计算
规则引擎、前端预览、报表预校验里先算常量折叠,算不出来就交给下游。
案例 1:常量折叠
SqlStatement stmt = SQL.parse("SELECT 1 + 2 * 3");
Object v = SQL.eval(((SqlSelect) stmt).selectItems().get(0).expr()); // "7"案例 2:含列引用时不报错,返回 null
SqlStatement stmt = SQL.parse("SELECT price * 0.8 FROM t");
Object v = SQL.eval(((SqlSelect) stmt).selectItems().get(0).expr()); // null14. 安全改写不污染原语句
网关/代理会缓存解析结果,改写必须是 clone-then-mutate,不能动到共享的原语句。
案例 1:裁剪列后原语句仍可复用
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案例 2:分页改写不改原句
SqlStatement original = SQL.parse("SELECT id FROM users WHERE status = 1");
SQL.setPage(original, 2, 10, SqlDialect.MYSQL);
((SqlSelect) original).limit(); // null,原语句无 LIMIT新增场景的约定
新场景先在 SqlBusinessScenarioTest 里落地(每个场景至少两个案例,且输出要能被目标方言 SQL.parse 复解析),跑绿之后再补进本节,保持文档与可执行测试一一对应。