Skip to content

SQL 解析模块

com.alianga:jkit-sql 是零依赖的 SQL 解析器:手写词法(char[] + 关键字开地址哈希),递归下降生成 AST,支持格式化、表/列统计和改写。

设计上对标:

  • Druid SQL Parser:手写解析、线程内复用 Parser、SchemaStatVisitor 式抽表列、生产环境吞吐
  • JSqlParser:AST + Visitor、TablesNamesFinder、pretty/compact 回写

不执行 SQL,不引 JDBC 驱动。

引入

xml
<dependency>
    <groupId>com.alianga</groupId>
    <artifactId>jkit-sql</artifactId>
    <version>2.0.1</version>
</dependency>

依赖 com.alianga:jkit(日志等),无其它第三方库。JDK 8+。

解析

java
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 走同一套)。一等方言枚举:

java
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):

java
// MySQL + ANSI_QUOTES:双引号是标识符不是字符串
SqlDialectSpec ansiQuotes = new SqlDialectWrapper(SqlDialect.MYSQL) {
    @Override
    public boolean doubleQuoteIsString() {
        return false;
    }
};
SQL.parse("SELECT \"id\" FROM t", ansiQuotes);  // "id" 按标识符解析

可覆写的能力覆盖解析到改写全链路:引号(identQuoteOpenidentQuoteClose / quoteIdent 是派生,只改开引号为 [ 时闭引号自动变 ])、|| 语义(pipesAsOrpipesAreConcat 派生)、反斜杠转义(backslashEscapes)、方括号标识符(bracketIdentifiers)、~ 正则(supportsTildeRegex)、# 注释(hashLineComment)、分页形态(supportsLimitOffset/Top/FetchFirst/Rownum/CommaLimitOffset)、标识符长度(maxIdentifierLengthfitIdentifier 派生,超长保留前缀并追加 4 位散列)。preferredLimitStyle() 是查询用派生值,驱动 setPage。直接实现 SqlDialectSpec 时未覆写的方法按 ANSI 基线取默认值。

非法 SQL 抛 SqlParseException,带行号、列号和附近原文,不返回半棵树。

模板占位符(可选)

common-model 一类模板 SQL会用 @age@%s<sheet><-sheet-> 等占位槽。默认解析关闭占位符(保持严格);需要时通过 SqlParseOptions.placeholders() 显式打开:

java
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 不会覆盖内建解析):

java
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 影响。

java
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 兼容)。

java
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 直接返回原语句):

java
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 函数式接口:收当前语句、返回继续传递的语句(就地修改返回原对象、或整体替换均可;返回 nullIllegalArgumentException)。 内建适配器与 SqlRewriter 对应静态方法等价(addLimit / setLimit / setOffset / setPage / andWhere / replaceTable / replaceColumn / addSelectItem / removeSelectItem / adaptPagination),就地作用于链上语句。 只改一条时直接用 SQL 门面即可,不必进链。

跨方言分页

改写与回写都以 SqlDialect 的能力方法为准:supportsLimitOffset / supportsTop / supportsFetchFirst / supportsRownum / supportsCommaLimitOffsetpreferredLimitStyle() 只描述「只补行数」时的首选形态(TOP / ROWNUM / LIMIT),单独驱动 setPage

方言标识符||# 行注释分页形态
MYSQL反引号ORLIMIT n / LIMIT offset, n
POSTGRES / H2 / ANSI双引号拼接H2 是LIMIT / OFFSET(兼认 FETCH)
DAMENG双引号拼接LIMIT / OFFSET
SQLITE / PRESTO双引号拼接LIMIT / OFFSET,无 FETCH FIRST
HIVE反引号(双引号当字符串)拼接LIMITsupportsLimitOffset=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.ORACLEsupportsRownum && !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 / 求值

java
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 JOINLEFTSTRAIGHT_JOINSTRAIGHT)会直接抓出,不会静默通过。同一方法在 tools-testSqlRoundTripFidelityCorpusTest 批量应用到全部 379 条文件语料(另加语义等价写法归一与 5 条有据白名单,硬断言)。

java
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 默认解析出的 OROR)。

回写是 pretty-print,不保证注释和空白 round-trip

方言差异

完整分页形态见「跨方言分页」。这里只列解析/回写最容易踩的点:

MYSQLHIVESQLSERVER其余(PG / Oracle / 达梦 / ANSI / H2 / DB2 / SQLite / Presto)
标识符反引号 `反引号[]双引号
双引号默认当字符串当字符串当标识符当标识符
||逻辑 OR(SqlParseOptions.pipesAsConcat(true) 可改为拼接)拼接拼接拼接
# 行注释仅 H2 是
分页能力supportsLimitOffset + 逗号风格supportsLimitOffsetsupportsTop + FETCH见上一节

ClickHouse 与 MySQL 一样用反引号,但双引号也是标识符,且分页支持逗号 LIMIT

快速构建(SqlBuilder)

java
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)、ODPS FORCE PARTITION / SIGNED INTEGER/IGNORE NULLS/LIMIT BY/ jsonb? 'pt' / FORCE ALL PARTITIONS、FROM(含 MySQL PARTITION (p0,p1) 表分区限定;Oracle PARTITION BY (expr) 分区外连接;别名后亦可 FORCE/USE/IGNORE INDEX)、JOIN(INNER/LEFT/RIGHT/FULL/CROSS/STRAIGHT/逗号;LEFT|RIGHT ANTI|SEMI JOINNATURAL 与连接类型正交)、SQL Server 表提示 WITH (NOLOCK) / WITH (INDEX(ix))(别名前后均可,原文保留)、CROSS APPLY / OUTER APPLYLATERAL 子查询/表函数、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/Snowflake QUALIFY 窗口过滤、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(含 Oracle SEARCH DEPTH|BREADTH FIRST BY … SET / CYCLE …);Hive UDTF 多列别名 fn(...) AS (c0,c1)
  • Oracle / 时态:MODELSqlModelClause(PARTITION/DIMENSION/MEASURES/RULES;RULES UPSERT SEQUENTIAL ORDER;MEASURES 字面量/AS 别名;可位于 WHERE 后;RULESSqlModelRulecellDims/cellDimExprs,失败保留 raw);MATCH_RECOGNIZESqlMatchRecognizePARTITION BY/ORDER BY/MEASURES/PATTERN 字符串/DEFINE/SUBSET/WITHINROWS PER MATCH/AFTER MATCH 字段;PATTERN 未建 DSL 树);表级 AS OF TIMESTAMP|SCNVERSIONS BETWEEN TIMESTAMP|SCN … AND …、SQL Server FOR 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 ...)、Spark OVER (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、PG ON CONFLICTDO NOTHING / DO UPDATE / ON CONSTRAINT)、RETURNING* 或多列列表)、SQL Server OUTPUT / OUTPUT … INTO、Oracle INSERT ALL / INSERT FIRST;MySQL LOW_PRIORITY / DELAYED / HIGH_PRIORITY / IGNORE 可叠加并回写(REPLACE 亦支持 DELAYED);Hive INSERT OVERWRITE [TABLE] t [PARTITION (...)] SELECT …SqlInsert.overwrite/tableKeyword/partitionRaw);ODPS UPDATE/DELETE FORCE PARTITION …
  • UPDATE / DELETE:JOIN、WHERE、ORDER BY、LIMIT、PG UPDATE … FROM、PG/MySQL DELETE … USINGRETURNING(多列)、SQL Server OUTPUT / OUTPUT … INTO(表 / @var / #tmp,进 tables());MySQL LOW_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 WHEREINSERT … VALUES … WHEREOUTPUT / OUTPUT … INTO
  • DDL:CREATE/DROP/ALTER TABLE|VIEW|INDEX|DATABASE|PROCEDURE|FUNCTION|TRIGGER|EVENT|USER(抽对象名;CREATE OR REPLACE;MySQL ALGORITHM/DEFINER/SQL SECURITY;VIEW/CTAS 的 AS query;过程/函数参数 → SqlRoutineParamFUNCTION RETURNSreturnsType,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 … ENGINETRUNCATE … PURGE SNAPSHOT LOG 尾段原文;MySQL 8 函数索引 ADD KEY idx ((expr));CTAS 尾缀 WITH [NO] DATA
  • EXPLAIN/DESCRIBESqlExplainStatement(ANALYZE/FORMAT/BUFFERS 等选项 + 嵌套 statement)、SETSqlSetStatement(多赋值 / NAMES / CHARACTER SET / SESSION|GLOBAL)、USE、SHOW、CALL(实参进 AST)、TRUNCATE、GRANT / REVOKE(权限 + ON 对象名;收件人 user@host 紧凑回写;REVOKE 用 FROM)
  • 过程块 / 维护 / 事务:BEGIN … END / 顶层匿名 DECLARE … BEGIN … ENDSqlBlockStatement(支持 EXCEPTION WHEN、标签 lab: BEGIN…END lab);PostgreSQL DO $$…$$ / DO $tag$…$tag$IF…ELSIF/ELSEIF…END IF;会话式 DECLARE x INT(OTHER);过程体内 DECLARE/CURSOR FORSqlDeclareStatementCONTINUE|EXIT|UNDO HANDLERSqlHandlerStatementIF/WHILE/LOOP/REPEAT/CASE…END CASE/LEAVE/ITERATE/RETURNSqlControlStatement(可带循环标签);TRIGGERtriggerTiming/triggerEvent/triggerTable/triggerUpdateColumns/FOR EACH/FOLLOWS|PRECEDESEVENTON SCHEDULE AT|EVERYeventStarts/eventEnds/eventEnabled/eventComment/eventOnCompletion/eventDisableOnSlave;裸 BEGIN / BEGIN WORK / START TRANSACTIONSqlStartTransactionStatement(隔离级别 / READ WRITE|ONLY / WITH CONSISTENT SNAPSHOT);COMMIT / ROLLBACK [TO SAVEPOINT] / SAVEPOINT / RELEASE SAVEPOINTSqlTransactionControlStatementFLUSH …SqlFlushStatement(选项列表 / TABLES 表名);LOCK TABLES/UNLOCK TABLESSqlLockTablesStatementANALYZE / VACUUM / OPTIMIZE|REPAIR|CHECK TABLESqlMaintenanceStatement(tables + optionsRaw);SHOW CREATE TABLE|VIEW|DATABASE / SHOW COLUMNS|INDEX|TABLESSqlShowStatementCOMMENT ON TABLE|COLUMN|…SqlCommentOnStatement(objectKind/name/comment);SQL Server GO 批分隔;PG COPY … FROM|TOSqlCopyStatement(表/列/STDIN·PROGRAM·文件 + WITH 原文);MySQL LOAD DATA [LOCAL] INFILE … INTO TABLESqlLoadDataStatement(文件/表/列 + FIELDS·LINES·IGNORE 原文);MySQL 表 HANDLER t OPEN|READ|CLOSESqlTableHandlerStatementPREPARE / EXECUTE / DEALLOCATE PREPARE / EXECUTE IMMEDIATESqlPrepareStatement(名 / 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).yINTERVAL '1 day' / INTERVAL 1 DAY / INTERVAL … YEAR(n) TO MONTHCAST(… 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%NOTFOUNDcount(UNIQUE …)(等同 DISTINCT);JDBC/ODBC 转义解包({fn …} 函数、{d|t|ts '…'} 类型字面量、{oj …} JOIN、{call …}{escape …});另含 PG @>/<@/~/~*、MySQL FORCE INDEX FOR …/<=>/INSERT DELAYED/BINARY、SQL Server TOP WITH TIESTABLESAMPLE/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@dblinkSqlIdentifier.dblink
  • 客户端 / 批处理:DELIMITER xx 切换 parseAll 终止符(见「DELIMITER」);SQL Server GO 批分隔
  • 解析选项:SqlParseOptions.keepComments(true)(默认 false)时普通注释进入 SqlStatement.comments(),热路径默认仍丢弃;SqlParseOptions.pipesAsConcat(true) 让 MySQL 方言下 || 按拼接解析(等同 PIPES_AS_CONCAT);SqlParseOptions.placeholders() 可配置模板占位(默认关闭,见「模板占位符」);SQL.parseAll(sql, dialect, true) 容错多语句(失败占位 + parseError,供审计)

表达式另支持:SUBSTR…FROM…FORDECIMAL/REAL/TIME ? 等类型字面量、SIMILAR TOCONTAINS、PG @@ tsquery、::type[] 数组类型后缀;SELECT … FOR JSON;Informix SKIP/FIRSTVALUES … UNION/ORDER/LIMIT;Hive LATERAL VIEW OUTERAS 后可用 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> 签名):

java
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,分页复用现有适配):

java
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_REQUIREDgenerateOracleSequence(true) 会附录 SEQUENCE+TRIGGER。

查询函数已改写:

  • IF(a,b,c)CASE WHEN(非 MySQL);JOIN ON / DEFAULT NOW() 同样走函数表
  • NOW() / CURDATE() / CURTIME()
  • GROUP_CONCATSTRING_AGG / LISTAGG
  • IFNULL / NVL / ISNULL(二元)按目标方言改名;COALESCE 为 PG/ANSI
  • CONCAT(a,b,c) 在 Oracle 下改为 ||(Oracle CONCAT 只接受两参数)
  • CAST / CONVERT(expr, type) 的类型走 canonical 表
  • MySQL CONVERT(expr USING charset) 不会误映射成 CAST,只告警并保留原文
  • DATE_ADD/DATE_SUB → 加减 INTERVALDATEDIFF → 日期相减;FROM_UNIXTIMETO_TIMESTAMP
  • SUBSTRING/LEFT/RIGHT/MID:Oracle/达梦 SUBSTR;SQL Server 两参数补 LEN、负起点改 RIGHT;SQLite/Hive 的 LEFT/RIGHT 展开成 SUBSTR。回写按方言:PG/MySQL 用 FROM n FOR m,SQL Server/SQLite 用逗号
  • UCASE/LCASEUPPER/LOWERCONCAT_WSLPAD/RPADSPACECEIL/CEILINGPOW/POWERMODYEAR/MONTH/DAY/HOUR/MINUTE/SECONDSYSDATELAST_DAYCHAR/CHR
  • DECODE/NVL2CASEFIND_IN_SET/SUBSTRING_INDEX 无干净等价则告警并保留

目标方言不支持的 MySQL 表内 KEY/INDEX 会改成附录 CREATE INDEXFULLTEXT/SPATIAL 去掉并 MANUAL_ACTION_REQUIREDUNIQUE KEY 改写为可移植的 UNIQUE (...)。独立 CREATE INDEX … USING BTREE 转到非 MySQL 时去掉 USING

SQL.convertBatch 批量转换。ALTER TABLE ADD/MODIFY/CHANGE 会转换列类型;转到 PG/H2 时改成 ALTER COLUMN … TYPENOT NULL/DEFAULT 进附录。

转换结果会按目标方言再 parse 一遍作为语料回归。真实建表与表达式执行验证在上级目录 tools-test(jkit 自己的 parser 比真库宽松,复解析抓不到的问题由真库执行兜底):

text
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_FORMATTO_CHARSqlFunctionRegistry;第三方用 SqlSchemaConverterProvider.registerFunctions 追加或覆盖(返回 null 回落内置),不必改 FunctionAstRewriterVARCHAR 超长默认提升为 TEXT/CLOB。

如何加类型别名、覆盖某方言写法、加 canonical 类型、加数据库、加函数改写,见 第九节

实体扫描生成 DDL / DML

对标 data-set EntityScanner:扫描包下带 @SqlTable、JPA @Entity、MyBatis-Plus @TableName/@TableId 的类(不依赖 Spring / JPA / MyBatis / Hibernate 编译),再按方言生成建表与增删改查。也认 @TableFieldexist=false 跳过)、JPA @Index/@Enumerated/@Embedded、任意 @Comment(按简单名,不绑包名)、Hibernate @ColumnDefaultList/Set/@OneToMany 默认不建列。createTables 按外键把被引用表排在前面。Java 类型走 canonical 类型表。

表 / 列注释:@SqlTable(comment=…)@SqlColumn(comment=…),以及任意简单名为 Comment 的注解(标在类上=表注释,标在字段上=列注释,读 valuecomment;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 @ColumnDefaultvalueSQL 片段(不含 DEFAULT 关键字),原样写入列定义,例如 @ColumnDefault("0")DEFAULT 0@ColumnDefault("'guest'")DEFAULT 'guest'@ColumnDefault("CURRENT_TIMESTAMP")DEFAULT CURRENT_TIMESTAMPcolumnDefinition 里已有 DEFAULT 时不再重复。

无 IDENTITY 的方言(Oracle ≤11g,枚举 ORACLE)用 CREATE SEQUENCE {table}_{column}_seq + BEFORE INSERT 触发器代替自增主键;达梦(DAMENG)列上写 IDENTITY;Oracle 12c+ 仍用 GENERATED … AS IDENTITYSqlEntities.extraSql / sequenceSql 可单独取附录。

java
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 … ADDinlinePk=false
columnTypeSql(SqlEntityColumn, SqlDialect)只取类型文本,供结构对比
createIndex(String tableName, String spec) / createIndex(..., SqlDialect)单独一条 CREATE INDEXspecname:col1,col2col1,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 和附录:

java
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(不进本模块,以免引入第三方依赖):

text
cd ../tools-test
mvn -Dtest=SqlParserCompareTest test

tools-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-testSqlParseBenchmark)。正式轮实测(fork=2、warmup=5、iteration=5、Cnt=10,avgt,ns/op,越小越好;2026-09-10,i9-13900HX / OpenJDK 17.0.11):

引擎SIMPLE(单表查询)JOIN(双表连接)WINDOW(窗口函数)
jkit-sql5081,073657
Druid 1.2.231,274(2.5×)3,667(3.4×)3,023(4.6×)
JSqlParser 4.9220,445(434×)252,464(235×)301,220(459×)

jkit 与 Druid 同属手写解析档,且稳定快 2.5~4.6 倍;JavaCC 生成的 JSqlParser 慢两个数量级以上。复现(约 30 分钟):

text
cd ../tools-test
mvn -DskipTests package
java -jar target/benchmarks.jar com.alianga.test.sql.jmh.SqlParseBenchmark -f 2 -wi 5 -i 5
解析成功率(文件语料)备注
jkit-sql379/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 条,含往返)、SqlRoundTripFidelityTest118 条,回写与原文归一化后逐字比对)、CommonModelSqlCorpusTest(从 icell/common-model 收获,87 条可解析)、Complex100GiantsTest / SqlModelMatchDeepenTest(MODEL / MATCH_RECOGNIZE 结构化字段)。
  • 与 Druid / JSqlParser 对比只在上级工程 tools-testSqlParserCompareTest(成功率 + 表名集合差分 + JMH;不进本库依赖)。
  • 回写保真批量验收:tools-testSqlRoundTripFidelityCorpusTest(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%)
text
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,可直接运行回归:

text
mvn -pl jkit-sql test -Dtest=SqlBusinessScenarioTest
#业务场景主要 API案例
1SQL 审计与依赖分析SQL.tables / SQL.stat / SqlStatement.isReadOnly依赖提取、写操作标记
2读写分离路由SqlStatement.isReadOnly只读走从库、持锁 SELECT 走主库
3SQL 注入防护SQL.parameterize / SQL.exportParameterValues字面量收编、绑定值导出
4SQL 防火墙SQL.wall / SqlWallConfig危险语句拦截、按通道放行 DDL
5跨方言数据库迁移SQL.convertBatch / SqlSchemaConverter.convertDDL 翻译闭环、批量 DML 函数改写
6多方言分页SQL.setPage / SQL.adaptPagination / SQL.toSqlStringLIMIT/TOP/FETCH/ROWNUM、offset=0 单层包装、回写按需适配
7多租户改写SqlRewrites.replaceTable / SqlRewrites.andWhere分表路由、租户条件注入
8数据脱敏与列级权限SQL.removeSelectItem / SqlRewrites.replaceColumn敏感列裁剪、物理列改名映射
9动态 SQL 构建SqlBuilder条件查询组装、INSERT/UPDATE
10实体驱动多方言建表SqlEntities.createTableMySQL 内联注释、PG 的 COMMENT ON
11SQL 格式化与规范统一SQL.format / SqlFormatOptions关键字大小写归一、pretty 多行
12遗留模板占位符迁移SqlPlaceholders / SqlParseOptions.placeholders@xx@%s 两种风格
13表达式预计算SQL.eval常量折叠、列引用返回 null
14安全改写不污染原语句SQL.clone / clone-then-mutate复用缓存原语句、分页改写不改原句

1. SQL 审计与依赖分析

上线前要知道一条 SQL 动了哪些表、哪些列,做影响面评估与变更评审。

案例 1:提取表/列依赖

java
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:批量脚本里标记写操作

java
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:只读句子走从库

java
SqlStatement q = SQL.parse("SELECT * FROM t_user WHERE id = 1");
boolean replica = q.isReadOnly();                        // true

案例 2:持锁 SELECT 与写语句走主库

java
// 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:一行把字面量参数化

java
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:导出绑定值,两步安全下发

java
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:拦截典型危险模式

java
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

java
SQL.wall("DROP TABLE t").violations();                          // [deny-ddl],默认拦截
SqlWallConfig allowDdl = SqlWallConfig.defaults().denyDdl(false);
SQL.wall("DROP TABLE t", SqlDialect.MYSQL, allowDdl).passed();   // true

5. 跨方言数据库迁移

存量 MySQL 语句翻译到目标库,输出可被目标方言再解析,形成验证闭环。

案例 1:单条 DDL 翻译 + 复解析校验

java
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 翻译,顺带改写方言函数

java
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. 多方言分页适配

同一份业务查询按目标方言生成分页语句。setPage / adaptPagination / format / toSqlString 共用同一套形态判断。

案例 1:MySQL / PostgreSQL 的 LIMIT OFFSET

java
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 单层包装,以及跨页双层

java
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 ONLY

7. 多租户改写

分表路由 + 统一注入租户过滤,防止漏加租户条件。

案例 1:按租户路由到分表

java
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 条件

java
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:裁剪敏感列

java
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:物理列改名映射

java
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:条件查询链式组装

java
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 零拼接

java
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. 实体驱动多方言建表

一套实体注解,按目标库产出对应 DDL,避免维护多份建表脚本。

案例 1:MySQL 内联 COMMENT

java
@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

java
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:关键字大小写归一

java
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 多行 + 往返稳定

java
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@ 风格

java
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)风格

java
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:常量折叠

java
SqlStatement stmt = SQL.parse("SELECT 1 + 2 * 3");
Object v = SQL.eval(((SqlSelect) stmt).selectItems().get(0).expr());  // "7"

案例 2:含列引用时不报错,返回 null

java
SqlStatement stmt = SQL.parse("SELECT price * 0.8 FROM t");
Object v = SQL.eval(((SqlSelect) stmt).selectItems().get(0).expr());  // null

14. 安全改写不污染原语句

网关/代理会缓存解析结果,改写必须是 clone-then-mutate,不能动到共享的原语句。

案例 1:裁剪列后原语句仍可复用

java
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:分页改写不改原句

java
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 复解析),跑绿之后再补进本节,保持文档与可执行测试一一对应。

基于 Apache License 2.0 发布