搞懂JSqlParser版本差异,搞定SQL解析高频面试题
版本升级后 API 全变了,这是无数 Java 后端在维护老项目或面试时踩过的最大坑。很多人盯着 JSqlParser 的源码看半天,还是写不出一个能稳定运行的 SQL 解析器。
其实,JSqlParser 并不是什么高不可攀的黑科技,它本质上就是一个基于 JavaCC 生成的 AST(抽象语法树)构建器。但在实际工程中,尤其是面对 MySQL 特有语法、复杂子查询或动态 SQL 时,版本间的 API 变动直接导致代码不可用。今天我们就从零搭建一个基于 JSqlParser 的 SQL 解析实战项目,把那些高频面试题里关于 SQL 改写、字段提取、性能优化的底层逻辑彻底吃透。
项目目标:从字符串到可执行逻辑
我们要解决的问题很具体:给定一段复杂的 SQL 字符串,比如包含 JOIN、SUBQUERY、HAVING 的查询,我们需要解析出所有的表名、别名、WHERE 条件中的字段,甚至能够自动改写 SQL(比如添加分页参数或替换表名)。
为什么不用正则表达式?因为 SQL 是嵌套的上下文相关语言,正则只能处理扁平结构。一旦遇到 SELECT * FROM (SELECT id FROM t1 WHERE id IN (SELECT max_id FROM t2)) AS t3,正则就抓瞎了。JSqlParser 的优势在于它构建了完整的 AST,你可以像操作 DOM 树一样遍历和操作 SQL 结构。
本项目的核心目标有三个:
- 结构解析:将 SQL 字符串转换为
CCJSqlParserUtil.parse返回的Statement对象。 - 节点遍历:递归遍历 AST,提取所有
Table、Column和Where节点。 - 动态改写:基于 AST 修改节点属性,最后通过
toString()还原为修改后的 SQL 字符串。
目录结构:工程化思维落地
一个可维护的解析工具类,不能把所有逻辑堆在一个方法里。我们采用标准的 Maven 结构,职责分离是关键。
sql-parser-demo/
├── pom.xml
├── src/
│ └── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ └── parser/
│ │ ├── SqlParserEngine.java # 核心解析引擎
│ │ ├── AstVisitor.java # 节点访问者模式
│ │ ├── Model/
│ │ │ ├── TableInfo.java # 表信息模型
│ │ │ └── ColumnInfo.java # 字段信息模型
│ │ └── Util/
│ │ └── SqlFormatter.java # SQL 格式化输出
│ └── resources/
│ └── test-sql.txt # 测试用例库
└── target/
pom.xml 依赖配置:
注意,这里我们锁定一个稳定版本。JSqlParser 4.x 和 5.x 的 API 差异巨大,尤其是 Expression 接口的变化。建议生产环境使用 4.9 或 5.1 并严格固定版本。
<dependency><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId><version>4.9</version> <!-- 稳定版本,避免API突变 -->
</dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.9.3</version><scope>test</scope>
</dependency>
核心代码实现:AST 遍历的艺术
1. 基础解析入口
CCJSqlParserUtil.parse 是 JSqlParser 的入口,但它返回的是 Statement,我们需要向下转型为 Select 或 SetOperationList。
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.Select;public class SqlParserEngine {/*** 解析 SQL 字符串为 Statement 对象* @param sql 原始 SQL* @return 解析后的 AST 根节点* @throws Exception 解析失败抛出异常*/public Statement parseSql(String sql) throws Exception {// 关键步骤1:预检查,去除末尾分号,JSqlParser 对分号敏感String cleanSql = sql.trim();if (cleanSql.endsWith(";")) {cleanSql = cleanSql.substring(0, cleanSql.length() - 1);}// 关键步骤2:执行解析// 注意:这里如果 SQL 语法错误,会直接抛 JSqlParserExceptionreturn CCJSqlParserUtil.parse(cleanSql);}/*** 提取所有涉及的表名* @param statement 解析后的语句* @return 表名列表(去重)*/public List<String> extractTableNames(Statement statement) {List<String> tables = new ArrayList<>();if (statement instanceof Select) {Select select = (Select) statement;// 遍历 Select 下的所有 PlainSelect 或 SetOperationListvisitSelect(select.getSelectBody(), tables);}return tables.stream().distinct().collect(Collectors.toList());}
}
2. 递归遍历 SelectBody
这是最核心的部分。SelectBody 是一个接口,它可能是 PlainSelect(普通查询)、SetOperationList(UNION/INTERSECT)或 WithItem。我们需要递归处理。
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.schema.Table;
import java.util.List;
import java.util.ArrayList;private void visitSelect(SelectBody selectBody, List<String> tableList) {if (selectBody == null) return;// 情况1:普通 SELECTif (selectBody instanceof PlainSelect) {PlainSelect plainSelect = (PlainSelect) selectBody;extractTablesFromPlainSelect(plainSelect, tableList);// 递归处理 FROM 子句中的子查询if (plainSelect.getFromItem() instanceof Select) {visitSelect(((Select) plainSelect.getFromItem()).getSelectBody(), tableList);}// 递归处理 JOIN 中的子查询List<Join> joins = plainSelect.getJoins();if (joins != null) {for (Join join : joins) {if (join.getRightItem() instanceof Select) {visitSelect(((Select) join.getRightItem()).getSelectBody(), tableList);}}}} // 情况2:UNION / INTERSECT 等集合操作else if (selectBody instanceof SetOperationList) {SetOperationList setOps = (SetOperationList) selectBody;List<SelectBody> selectBodies = setOps.getSelects();for (SelectBody body : selectBodies) {visitSelect(body, tableList);}}
}private void extractTablesFromPlainSelect(PlainSelect plainSelect, List<String> tableList) {// 提取主表if (plainSelect.getFromItem() instanceof Table) {Table table = (Table) plainSelect.getFromItem();tableList.add(table.getName());}// 提取 JOIN 表List<Join> joins = plainSelect.getJoins();if (joins != null) {for (Join join : joins) {if (join.getRightItem() instanceof Table) {Table table = (Table) join.getRightItem();tableList.add(table.getName());}}}
}
逐行讲解关键点:
instanceof检查:JSqlParser 的 AST 节点类型极其细分,必须通过类型判断来获取具体属性。- 子查询陷阱:
getFromItem()返回的是FromItem接口,它既可以是Table,也可以是SubSelect。如果忽略SubSelect,你就漏掉了子查询中的表,这在面试题中是典型的“漏判”错误。 - JOIN 处理:
getJoins()返回列表,每个Join对象包含左右两个FromItem。通常右项是被 JOIN 的表,但要注意 CROSS JOIN 和 LEFT JOIN 的区别,不过对于提取表名来说,逻辑一致。
3. 高级功能:WHERE 条件分析与 SQL 改写
面试中常问:“如何判断 SQL 是否走了索引?”或者“如何给所有查询自动加上 LIMIT?”这需要对 Where 节点进行操作。
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.schema.Column;/*** 提取 WHERE 中所有等于查询的字段名(常用于判断索引命中可能性)*/
public List<String> extractWhereColumns(PlainSelect plainSelect) {List<String> columns = new ArrayList<>();Expression where = plainSelect.getWhere();if (where != null) {visitExpression(where, columns);}return columns;
}private void visitExpression(Expression expr, List<String> columns) {if (expr instanceof EqualsTo) {EqualsTo eq = (EqualsTo) expr;if (eq.getLeftExpression() instanceof Column) {columns.add(((Column) eq.getLeftExpression()).getColumnName());}// 右边也可能是字段,虽然少见,但严谨起见也要检查if (eq.getRightExpression() instanceof Column) {columns.add(((Column) eq.getRightExpression()).getColumnName());}} else if (expr instanceof AndExpression) {AndExpression and = (AndExpression) expr;visitExpression(and.getLeftExpression(), columns);visitExpression(and.getRightExpression(), columns);} else if (expr instanceof OrExpression) {OrExpression or = (OrExpression) expr;visitExpression(or.getLeftExpression(), columns);visitExpression(or.getRightExpression(), columns);}// 其他类型表达式可继续扩展
}/*** 自动添加 LIMIT 子句(如果不存在)*/
public String addLimitIfAbsent(Statement statement, int limit) {if (statement instanceof Select) {Select select = (Select) statement;SelectBody body = select.getSelectBody();if (body instanceof PlainSelect) {PlainSelect ps = (PlainSelect) body;if (ps.getLimit() == null) {Limit newLimit = new Limit();newLimit.setRowCount(new LongValue(limit));ps.setLimit(newLimit);}}}return statement.toString(); // AST 自动序列化为字符串
}
运行与测试:验证解析的正确性
光写代码不测试等于白写。SQL 解析器最怕的是边界情况:空值、非法字符、复杂嵌套。
测试用例 1:基础 JOIN 查询
SELECT u.name, o.amount FROM user u JOIN order o ON u.id = o.user_id WHERE u.status = 1
预期结果:表名 [user, order],WHERE 字段 [u.status]。
测试用例 2:复杂子查询
SELECT * FROM (SELECT id FROM t1 WHERE id IN (SELECT max_id FROM t2)) AS tmp WHERE tmp.id > 10
预期结果:表名 [t1, t2]。如果只提取到 [t1],说明子查询递归逻辑失败。
JUnit 测试代码:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;public class SqlParserEngineTest {private SqlParserEngine engine = new SqlParserEngine();@Testpublic void testExtractTables() throws Exception {String sql = "SELECT * FROM t1 JOIN t2 ON t1.id = t2.t1_id";Statement stmt = engine.parseSql(sql);List<String> tables = engine.extractTableNames(stmt);assertTrue(tables.contains("t1"));assertTrue(tables.contains("t2"));assertEquals(2, tables.size());}@Testpublic void testAddLimit() throws Exception {String sql = "SELECT id FROM user";Statement stmt = engine.parseSql(sql);String newSql = engine.addLimitIfAbsent(stmt, 10);assertTrue(newSql.contains("LIMIT 10"));}
}
常见报错排查:
JSqlParserException: Encountered unexpected token:通常是 SQL 语法错误,或者使用了 JSqlParser 不支持的方言(如 MySQL 的FORCE INDEX在某些旧版本不支持)。NullPointerException:通常是getWhere()返回 null,未做空值判断。
优化扩展:生产环境的避坑指南
1. 版本兼容性陷阱
我在 CSDN 上看到很多博主抱怨 JSqlParser 升级后 getSelectBody() 被废弃。这是因为 JSqlParser 在 5.0 版本重构了 Select 类,将 SelectBody 内聚。
解决方案:如果必须使用 5.x,请改用 select.getPlainSelect() 或遍历 select.getSelectItems()。如果项目稳定在 4.x,请勿随意升级,除非你重写所有解析逻辑。
2. 性能优化
JSqlParser 的解析速度比正则慢,但对于 SQL 这种短文本,耗时通常在毫秒级。
- 缓存机制:对于高频执行的相同 SQL,不要每次都 parse。可以使用
ConcurrentHashMap<String, Statement>缓存 AST 对象。 - 线程安全:
Statement对象本身是不可变的(一旦解析完成,结构不变),但如果你修改了 AST 节点,必须创建副本,避免并发修改异常。
3. 支持 MySQL 特有语法
JSqlParser 是通用 SQL 解析器,对 MySQL 的 LIMIT offset, count 支持良好,但对 REGEXP 或 JSON_EXTRACT 支持有限。
技巧:在解析前,先用正则替换掉 JSqlParser 不认识的函数调用,解析后再还原。例如:
String backupSql = sql;
sql = sql.replaceAll("JSON_EXTRACT\\([^)]+\\)", "dummy_func");
// 解析...
// 还原...
小结
JSqlParser 是 Java 生态中处理 SQL 字符串最可靠的工具之一,但它不是万能的。
核心要点回顾:
- AST 是核心:不要试图用字符串操作 SQL,要用对象图遍历。
- 递归是关键:子查询、UNION、JOIN 都需要递归处理
SelectBody和FromItem。 - 版本锁死:JSqlParser 4.x 和 5.x 的 API 不兼容,升级前务必查阅 Release Notes。
- 空值防御:
getWhere()、getJoins()都可能为 null,务必判空。
这个实战项目涵盖了从基础解析到高级改写的完整链路。你可以基于此代码,扩展出 SQL 审计工具、慢查询自动优化建议器或数据脱敏中间件。
你公司项目里是怎么处理 SQL 解析的?是直接用 JSqlParser,还是自己写了正则,或者用了其他库?欢迎在评论区分享你的踩坑经验,特别是版本升级后 API 变化的解决方案,我们一起交流。