从零到一:Java实现SQL解析的完整实战案例与核心原理剖析
目录导读
- 为什么你需要自己解析SQL?
- 主流Java SQL解析库横评:JSqlParser vs Druid vs Calcite
- 手写一个轻量级SQL解析器:词法分析(Token化)
- 语法分析实战:构建AST(抽象语法树)
- 完整案例:解析
SELECT语句并提取表名与过滤条件 - 进阶问答:如何处理复杂JOIN与子查询?
- 性能优化与注意事项
- 拥抱或规避,你的选择
为什么你需要自己解析SQL?
在业务系统开发中,我们常遇到以下痛点:

- 数据权限管控:需要动态拦截用户SQL,强制追加
WHERE tenant_id = ?。 - 血缘分析:解析SQL以追踪数据流向,用于治理或推荐。
- 多方言支持:统一不同数据库(MySQL、Oracle、PG)的SQL语法差异。
直接使用正则表达式解析SQL是“灾难”,因为SQL语法复杂且存在嵌套层级。健壮的做法是构建AST(抽象语法树)——将SQL拆解为结构化树形节点,便于遍历和修改。
主流Java SQL解析库横评
| 库名称 | 特点 | 适用场景 |
|---|---|---|
| JSqlParser | 轻量、易用,基于JavaCC生成解析器 | 快速提取表名、列名,改写简单条件 |
| Apache Calcite | 重量级,支持查询优化、多数据源联邦 | 大数据平台(如Flink、Hive)的SQL层 |
| Druid SQL Parser | 阿里开源,兼容多种方言,性能极佳 | 中间件(如MyCat)的SQL路由与改写 |
选型建议:若仅需解析单条SQL,优先选JSqlParser;若需深度优化与SQL标准支持,选Calcite,本文案例使用JSqlParser演示(因为其API最直观),但会讲解其内部原理。
手写一个轻量级SQL解析器:词法分析(Token化)
任务:将字符串SELECT id, name FROM users WHERE age > 18拆分为Token序列。
public enum TokenType { KEYWORD, IDENTIFIER, NUMBER, OPERATOR, COMMA, DOT, PAREN_OPEN, PAREN_CLOSE }
public class Token {
TokenType type;
String value;
}
public List<Token> tokenize(String sql) {
List<Token> tokens = new ArrayList<>();
// 定义正则:匹配关键词(SELECT/FROM/WHERE)、标识符(字母/下划线开头)、数字、比较运算符
// 省略具体正则实现,核心是逐字符扫描并匹配规则
return tokens;
}
输出:[SELECT(KEYWORD), id(IDENTIFIER), COMMA, name(IDENTIFIER), FROM(KEYWORD), users(IDENTIFIER), WHERE(KEYWORD), age(IDENTIFIER), >(OPERATOR), 18(NUMBER)]
语法分析实战:构建AST
语法分析依据语法规则(产生式)将Token序列组合成树。
SELECT <列列表> FROM <表名> WHERE <条件>对应一个SelectStatement节点。<条件>进一步分解为ComparisonExpression(左值、操作符、右值)。
JSqlParser的AST节点示例(通过其API获取):
Statement statement = CCJSqlParserUtil.parse(sql);
if (statement instanceof Select) {
PlainSelect plainSelect = (PlainSelect) ((Select) statement).getSelectBody();
List<String> columns = plainSelect.getSelectItems().stream()
.map(item -> item.toString()).collect(Collectors.toList());
String tableName = plainSelect.getFromItem().toString();
Expression where = plainSelect.getWhere();
}
完整案例:解析SELECT语句并提取表名与过滤条件
需求:给定SQL,输出:表名 = users,过滤条件 = {age > 18},并支持追加AND deleted = 0。
代码实现(JSqlParser):
private static void parseAndModify(String sql) throws JSQLParserException {
Statement stmt = CCJSqlParserUtil.parse(sql);
if (stmt instanceof Select) {
PlainSelect select = (PlainSelect) ((Select) stmt).getSelectBody();
// 提取表名
String table = select.getFromItem().toString();
System.out.println("原始表名: " + table);
// 提取WHERE条件
Expression where = select.getWhere();
System.out.println("原始条件: " + where);
// 追加条件 (使用EqualsTo拼接)
EqualsTo newCondition = new EqualsTo();
newCondition.setLeftExpression(new Column("deleted"));
newCondition.setRightExpression(new LongValue(0));
if (where == null) {
select.setWhere(newCondition);
} else {
AndExpression andExpr = new AndExpression(where, newCondition);
select.setWhere(andExpr);
}
System.out.println("修改后SQL: " + select.toString());
}
}
运行结果:
原始表名: users
原始条件: age > 18
修改后SQL: SELECT id, name FROM users WHERE age > 18 AND deleted = 0
进阶问答:如何处理复杂JOIN与子查询?
Q1:如何获取JOIN语句的表名?
A:使用Join对象列表。PlainSelect.getJoins()返回List<Join>,每个Join对象有getRightItem()(表名)和getOnExpression()(连接条件)。
Q2:子查询嵌套如何遍历?
A:JSqlParser将子查询作为SubSelect节点,需要递归遍历:
if (fromItem instanceof SubSelect) {
PlainSelect subSelect = (PlainSelect) ((SubSelect) fromItem).getSelectBody();
// 递归处理
}
Q3:能否解析INSERT/UPDATE/DELETE?
A:可以。CCJSqlParserUtil.parse()返回的Statement可能是Update、Insert等类型,结构略有不同,但思路一致。
性能优化与注意事项
- 缓存解析结果:SQL语法树解析较耗CPU(毫秒级),对于高频重复SQL(如固定报表),建议使用
ConcurrentHashMap缓存AST。 - 版本兼容:JSqlParser不同版本的API略有差异,如
getSelectBody()在4.x版本才稳定,建议固定版本。 - SQL注入防御:若用于解析用户输入,务必校验Token类型,防止恶意构造的Token(如多行注释)绕过过滤。
- 方言差异:JSqlParser支持部分方言,但复杂函数(如MySQL的
GROUP_CONCAT)可能解析失败,需自定义Function节点。
拥抱或规避,你的选择
SQL解析是数据中台、低代码平台、数据库工具开发的基石,通过本文案例,你已掌握:
- 词法分析与语法分析的基本原理。
- 使用JSqlParser快速解析和改写SQL。
- 应对JOIN、子查询等复杂结构的策略。
行动建议:
- 如果只是简单提取表名,直接引入JSqlParser依赖。
- 如果你需要深度优化(如谓词下推),则必须学习Apache Calcite的
RelNode体系。 - 别忘了测试极端情况(如字符串中的
WHERE关键字、嵌套注释)。
问题思考:当SQL中出现ALTER TABLE语句时,如何通过AST判断操作类型(添加列 vs 删除列)?欢迎在评论区给出你的思路。