JSqlParser getSelectBody() 方法详解:SQL解析与改写的核心入口
做 Java 后端的人只要碰过 SQL 解析、SQL 改写、数据权限拦截这类需求大概率会撞见这样一个方法net.sf.jsqlparser.statement.select.Select.getSelectBody()。它不像 parse 方法那样站在入口处被所有人看到却是所有 SELECT 语句后续操作的咽喉。简单说getSelectBody()把解析出来的Select对象里的“查询体”暴露给了调用方拿到这个查询体你才能继续拆 WHERE、拼条件、换列、改表、处理 UNION。初学 SQL 解析或者正在做 SQL 改写、审计、脱敏的同学可以把它当成 AST 操作路上绕不开的第一道门。如果你只是在项目里见过NoSuchMethodError异常栈里出现这行也能直接跳到第 4 节看排查方法。1. getSelectBody() 到底是干什么的1.1 Select 和 SelectBody 的分工先说一个容易绕晕的点。Select不等于“SELECT 语句的全部内容”SelectBody才是那条 SELECT 真正的查询结构。用 jsqlparser 做解析时入口通常是这样Statement statement CCJSqlParserUtil.parse(select id, name from user where age 18);这条语句返回的是Statement。如果 SQL 是 SELECT向上转型之后得到SelectSelect select (Select) statement;此刻select代表“这是一条 SELECT 语句”这个外壳而getSelectBody()返回的SelectBody才是里面具体的选择体查哪些列、从哪张表查、JOIN 了谁、WHERE 条件是什么、有没有 GROUP BY、ORDER BY、LIMIT全在这个 body 下挂着。打个不太严谨但好记的比方Select是驾驶证SelectBody是驾驶技能本身真正要扣分、查细节都得看后者。需要说明的是SelectBody是接口而不是具体类。所以拿到返回值之后不要急着直接强转成某个实现类先搞清楚它到底是哪种形态再说。传统字符串处理总想用正则去抓“括号里的内容”而 AST 的思路完全不同SQL 被拆成树你要找的信息不是“匹配出来的片段”而是树里某个确定的节点。getSelectBody()就是这个节点体系的入口。1.2 一行代码把查询体拿出来实际调用起来一点不复杂SelectBody selectBody select.getSelectBody();如果只是要打印调试selectBody.toString()就能把查询体还原成 SQL。比如开头那句 SQL这个 body 的 toString 大体上就是SELECT id, name FROM user WHERE age 18差别在于你在对象树里拿到的 WHERE 不是一个字符串而是一棵表达式树。你可以取PlainSelect.getWhere()去判断age 18这个条件也可以直接把它替换成别的表达式。字符串替换做不到的事情在这里都能做。完整示例try { Statement statement CCJSqlParserUtil.parse(select id, name from user where age 18); if (statement instanceof Select) { Select select (Select) statement; SelectBody body select.getSelectBody(); System.out.println(body); } } catch (JSQLParserException e) { e.printStackTrace(); }这就算是入了门。真正麻烦的从来不是这一行代码而是后续对SelectBody的“拆解改写”。1.3 它解决什么问题以及一个版本坑这套 API 解决的核心问题是让程序不再靠正则表达式去理解 SQL。SQL 里括号嵌套、字符串里带有where、大小写混用、条件顺序变化靠正则硬拆很容易翻车。jsqlparser 把 SQL 变成可编程访问的 ASTgetSelectBody()就是进入 SELECT AST 的钥匙。但这里有个非常常见的版本坑。项目里如果同时引了多个组件它们各自带了一份不同版本的 jsqlparser运行时可能加载到旧 jar。此时常见表现是编译能过一运行就抛NoSuchMethodError异常栈第一行就是net.sf.jsqlparser.statement.select.Select.getSelectBody()你检查当前 pom 里的版本是最新的却怎么都查不出原因。这是因为 JVM 按类加载路径找类你的代码在编译期关联的是新版本方法执行期却撞上了旧版本的 jar旧类里没有这个签名方法于是报错。别急着改业务代码先做依赖排查第 4 节会专门讲。2. 返回值不是只有一个形态SelectBody 的三种常见类型2.1 最常遇见的 PlainSelect业务里绝大多数普通 SELECT 语句解析后得到的SelectBody是PlainSelect。它的名字已经说明了一切没有 UNION、没有集合运算的普通选择查询。if (body instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) body; FromItem fromItem plainSelect.getFromItem(); Expression where plainSelect.getWhere(); ListSelectItem selectItems plainSelect.getSelectItems(); }getFromItem()返回的是 from 后面的主体通常是一个Table对象getWhere()返回 WHERE 条件表达式getSelectItems()是 select 子句后面的所有列定义。这三个要素是你在做表名过滤、条件注入、列替换时最常碰到的节点。需要注意getFromItem()不总是Table。如果 FROM 后面跟了一个子查询它会是SubSelect如果写了括号表现也可能不同。写通用逻辑时不要只做instanceof Table然后强转。还有个容易被忽略的点表别名。Table对象里有getAlias()可以拿别名权限过滤时不要误把别名当成真实表名但改写后的 SQL 里别名通常要保留。比如from user u你收集表名要拿到的是user不是u可 toString 之后u还会在。搞清楚哪个是“业务表”哪个是“展示名”是工具类设计时就要想清楚的边界。2.2 集合查询 SetOperationList 不可忽略一旦 SQL 里出现 UNION、INTERSECT、EXCEPTgetSelectBody()返回的对象就不再是PlainSelect而是SetOperationList。很多人第一次在这个方法上踩坑就是把返回值直接无脑转成PlainSelect结果一条select ... union select ...直接 ClassCastException。SetOperationList的核心特征是它内部维护了一个子查询体列表if (body instanceof SetOperationList) { SetOperationList setOperationList (SetOperationList) body; for (SelectBody child : setOperationList.getSelects()) { // 子查询体可能还是 PlainSelect也可能继续是 SetOperationList } }为了稳妥遍历时不能假设子项是PlainSelect要继续递归进去。SQL 解析工具如果不支持递归遇到 UNION 就会漏掉部分表或条件做权限过滤时这一点尤其危险。我见过一个线上事故某权限模块只处理最外层PlainSelectSQL 里一旦出现 UNION另一个分支的表名就收集不到导致权限条件漏加。这种问题平时用简单 SQL 测试根本看不出来只有到复杂报表场景才会爆发。2.3 别把“查询体”和“语句”划等号getSelectBody()名字里带着Select所以它只管 SELECT 查询体。INSERT ... SELECT、UPDATE、DELETE都不会走到这里。尤其是做数据权限或者 SQL 改写时常常有人只处理了Select语句结果UPDATE和DELETE漏了条件造成不必要的损失。正确做法是先判断Statement的真实类型再分别处理if (statement instanceof Select) { SelectBody body ((Select) statement).getSelectBody(); handleSelectBody(body); } else if (statement instanceof Update) { // 处理 Update 节点 }这个意识比多写几十行代码重要得多。抽象的SelectBody接口也是为整棵语法树服务的别因为方法名带Select就只写死一条分支。另外CTE 场景也要注意。WITH xxx AS (...)这种写法在对象树里同样有对应的 CTE 节点主查询体还是通过getSelectBody()拿到但 CTE 定义里的 SELECT 也需要遍历不能只盯着主查询体。做表名收集和条件注入时CTE 漏掉同样会造成权限绕过。3. 拿到 SelectBody 之后的实战从读取到改写3.1 读取核心要素从 from、join、where 里找信息读 SQL 结构最简单也最常用的目标是收集表名。表名在一个 SELECT 里可能出现在三处FROM、JOIN、子查询。写工具方法时这三个位置都要覆盖。先看 FROM 和 JOIN 这两层ListString tableNames new ArrayList(); if (body instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) body; collectTableFromItem(plainSelect.getFromItem(), tableNames); for (Join join : plainSelect.getJoins()) { collectTableFromItem(join.getRightItem(), tableNames); } }collectTableFromItem里需要区分 Table 和 SubSelectprivate static void collectTableFromItem(FromItem fromItem, ListString tableNames) { if (fromItem null) { return; } if (fromItem instanceof Table) { Table table (Table) fromItem; tableNames.add(table.getFullyQualifiedName()); } else if (fromItem instanceof SubSelect) { SubSelect subSelect (SubSelect) fromItem; if (subSelect.getSelectBody() ! null) { collectFromBody(subSelect.getSelectBody(), tableNames); } } }这里推荐用getFullyQualifiedName()它会把 schema 前缀一并带出来比getName()信息更完整。如果业务只关心表名不带 schema再手动截掉前缀也不迟。很多 SQL 改写工具都会顺手把 JOIN 里的表也纳入权限范围这个逻辑没有错但一定要记住JOIN 表不一定是业务真正要过滤的对象。要不要连 JOIN 一起处理取决于产品规则不能拍脑袋一刀切。3.2 典型改写数据权限条件自动拼接读取只是热身真正体现getSelectBody()价值的是改写。最常见的需求是给已有 SQL 追加过滤条件。例如select id, name from user where age 18要变成select id, name from user where age 18 and tenant_id 20240312正确做法是操作表达式树而不是对 SQL 文本做替换PlainSelect plainSelect (PlainSelect) body; Expression originWhere plainSelect.getWhere(); Expression tenantCondition CCJSqlParserUtil.parseExpression(tenant_id 20240312); if (originWhere null) { plainSelect.setWhere(tenantCondition); } else { plainSelect.setWhere(new AndExpression(originWhere, tenantCondition)); } String newSql select.toString();几个细节值得说。第一原 WHERE 可能为 null。SELECT ... WHERE缺失时getWhere()返回 null不能直接拿它拼接。第二拼接时必须用AndExpression不要图省事把表达式转成字符串再拼否则换行、括号、布尔运算优先级都会出问题。第三条件如果用CCJSqlParserUtil.parseExpression解析出来就不用自己手工 new 一堆节点方便且不容易漏。这里还要提醒一个安全问题。不要把用户的直接输入塞进conditionSql再 parse否则等于把字符串拼 SQL 的锅换了个马甲。条件如果是参数应该用StringValue、LongValue这类值节点手动构造或者对输入做严格白名单校验。AST 解决的是结构解析问题不是天然的防注入方案。改写完成后select.toString()输出的 SQL 就是完整的新 SQL。这个输出可以直接被提交给 JDBC 执行也可以继续交给下一步处理。3.3 小心 SQL 里的子查询和 JOIN上面的例子只处理了最外层 WHERE。一旦 SQL 变成select id, name from user where dept_id in (select dept_id from department where status 1)如果你只在getSelectBody()拿到的外层PlainSelect上追加条件子查询里的逻辑是完全触碰不到的。子查询会以SubSelect节点的形式挂在 FROM、JOIN、WHERE、SELECT 列表等位置。做统一过滤时应当对整棵语法树做递归遍历。我个人的做法是写一个统一的递归入口遇到PlainSelect就处理它的 FROM、JOIN、WHERE遇到SetOperationList就把每个子 body 递归进去遇到SubSelect就把subSelect.getSelectBody()再递归。这样至少能覆盖 90% 的业务 SQL。需要注意递归时不要对同一个子查询做重复处理。某些 SQL 的同一个子查询会既出现在 WHERE 表达式里又被封装成 FROM 子查询遍历路径重叠后可能重复追加条件。加一个 visited set按对象引用去重能省掉后面一堆麻烦。3.4 表达式里的子查询也不能漏FROM 和 JOIN 里的子查询好找但藏在表达式里的子查询才是最大暗坑。比如where id in (select ...)这个SubSelect不是 FROM 节点而是 WHERE 表达式树里的一个子节点。如果你只递归 FromItem这个子查询同样会被漏掉。要处理表达式内部通常要再写一个表达式遍历。常用思路是借助ExpressionVisitorAdapter把表达式树里出现的SubSelect节点也收集出来再递归处理它内部的SelectBody。如果不借助 visitor手写遍历各种表达式会非常痛苦光是BinaryExpression的左右子树就够你写一长串。从成本角度看简单的权限注入可以只处理最外层 WHERE但完整可靠的 SQL 过滤必须把表达式里的子查询也拉进来。这个深度取决于业务风险容忍度。做审计、做租户隔离我强烈建议一步到位别给漏过滤留机会。4. 高频报错与排查特别是 NoSuchMethodError4.1 为什么会出现 getSelectBody 相关 NoSuchMethodError很多开发第一次真正去搜net.sf.jsqlparser.statement.select.Select.getSelectBody()往往不是因为精读源码而是因为线上日志打出了一段 NoSuchMethodError。这行方法被 JVM 标记为“找不到”大概率不是代码逻辑问题而是依赖冲突。举个例子项目引入组件 AA 升级时悄悄带进了 jsqlparser 3.x项目自己又显式依赖了 jsqlparser 4.x。Maven 或 Gradle 在解析时保留了旧版坐标类加载器加载到的Select.class就来自旧版 jar。新版Select里有getSelectBody()的某个签名旧版没有运行时自然 NoSuchMethodError。环境变量、类加载顺序有时候也会造成同样的现象但在普通 Spring Boot 多层依赖的项目里依赖冲突的占比最高。这个错误不会在编译期爆出来往往上线之后才出现所以排查时要有耐心。4.2 用依赖树揪出旧版 jarMaven 项目先用一条命令看依赖树mvn dependency:tree -Dincludescom.github.jsqlparserGradle 项目可以用gradle dependencies --configuration runtimeClasspath | grep jsqlparser找出所有出现 jsqlparser 的位置确认哪一个版本是最终生效版本。如果发现版本不一致处理方式就是在引入旧版的那个依赖上排除掉传递依赖dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.3/version /dependency如果其他组件带的是旧版坐标靠 Maven 的 dependencyManagement 统一版本更省事。核心思路是全项目只能有一份 jsqlparser且这份版本要大于等于你编译时使用的版本。写完 pom 后重新清一下本地缓存再测试。有些 IDE 会缓存旧的 class 文件只mvn clean install不够必要时mvn clean install -U强制刷新。4.3 常见问题速查表现象可能原因处理建议NoSuchMethodError 指向 getSelectBody多个 jsqlparser jar 冲突加载到旧版依赖树排查统一版本ClassCastException 转 PlainSelectSQL 含 UNION 等集合运算判断 SetOperationList 并递归取表名为 nullFROM 是子查询或函数形式判断 FromItem 类型别直接 Table 强转改写后 SQL 丢注释AST 默认不保留注释改写前先做好注释剥离预期SELECT 1 这类无表 SQL 空指针fromItem 为 null处理前判空UPDATE/DELETE 没有追加条件只处理了 Select 类型对不同 Statement 类型分别处理这张表是我在实际项目中踩过或见过的高频问题并不完整但覆盖面足够广。遇到其他异常先回到对象树结构上去想比盯着异常文本猜有效得多。5. 可以直接抄作业的工具类5.1 通用解析入口把上一节的思路合并成一个小的工具入口后续不管做 SQL 审计还是权限改写都能直接复用public final class SqlBodyParser { private SqlBodyParser() { } public static SelectBody parseSelectBody(String sql) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { return ((Select) statement).getSelectBody(); } throw new IllegalArgumentException(SQL 不是 SELECT 语句: sql); } }这个方法的价值在于把“合法性判断”集中在一起。调用方拿到的一定是SelectBody不用每次都在业务代码里做instanceof Select判断。业务代码只需要关心SelectBody的实现类型。5.2 递归提取表名下面这段是一个可用的表名提取实现。它不依赖具体表别名也能覆盖子查询和 UNIONpublic static SetString collectTableNames(SelectBody body) { SetString tableNames new HashSet(); collectTableNames(body, tableNames, new HashSet()); return tableNames; } private static void collectTableNames(SelectBody body, SetString tableNames, SetSelectBody visited) { if (body null || !visited.add(body)) { return; } if (body instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) body; collectFromItem(plainSelect.getFromItem(), tableNames, visited); for (Join join : plainSelect.getJoins()) { collectFromItem(join.getRightItem(), tableNames, visited); } } else if (body instanceof SetOperationList) { SetOperationList setOperationList (SetOperationList) body; for (SelectBody child : setOperationList.getSelects()) { collectTableNames(child, tableNames, visited); } } } private static void collectFromItem(FromItem fromItem, SetString tableNames, SetSelectBody visited) { if (fromItem null) { return; } if (fromItem instanceof Table) { Table table (Table) fromItem; tableNames.add(table.getFullyQualifiedName()); } else if (fromItem instanceof SubSelect) { SubSelect subSelect (SubSelect) fromItem; if (subSelect.getSelectBody() ! null) { collectTableNames(subSelect.getSelectBody(), tableNames, visited); } } }这段代码可能因为 jsqlparser 版本不同出现细小差异比如 getJoins 的返回类型但整体结构是稳定的。真遇到编译报错看下当前版本的 javadoc 调一下类型即可。5.3 条件注入模板最后给一个最常用的条件注入模板同时覆盖最外层 WHERE 为空的情况public static void appendCondition(Select select, String conditionSql, boolean forceParentheses) throws JSQLParserException { SelectBody body select.getSelectBody(); if (!(body instanceof PlainSelect)) { throw new IllegalArgumentException(仅支持普通 SELECT 语句); } PlainSelect plainSelect (PlainSelect) body; Expression condition CCJSqlParserUtil.parseExpression(conditionSql); Expression originWhere plainSelect.getWhere(); if (originWhere null) { plainSelect.setWhere(condition); } else { plainSelect.setWhere(new AndExpression(originWhere, condition)); } }forceParentheses这个参数是我自己惯用的一个保险开关。如果条件 SQL 是一个比较复杂的表达式强制加一层括号再拼进去比依赖 jsqlparser 自动处理优先级更踏实。这个模板可以直接用到数据权限、租户隔离、软删除过滤等场景中。最后再分享一个小技巧所有对SelectBody的改动做完之后一定随手执行一次select.toString()再把输出用CCJSqlParserUtil.parse重新解析一遍。能解析成功且结构和预期一致才说明节点没有拼错。别小看这个动作它救过我不少次尤其是处理嵌套子查询和 UNION 的时候。