JSqlParser核心方法getSelectBody:SQL解析与改写的关键入口

发布时间:2026/10/16 12:49:11
JSqlParser核心方法getSelectBody:SQL解析与改写的关键入口
说个真实经历。之前做内部数据平台的权限管理业务方提了一个需求所有查询语句在执行前必须按登录人所属组织自动带上数据范围条件。我最初想用正则硬拆 SQL结果遇到带嵌套子查询、UNION 和复杂 JOIN 的语句直接崩盘。后来切到 JSqlParser核心入口就是标题里这个 API——Select.getSelectBody()。如果你也在做 SQL 解析、查询改写、数据权限控制、分页优化这类事这个方法是完全绕不开的关口。它的定位一句话就能说清JSqlParser 把一条 SELECT 语句解析成 Java 对象树之后Select对象只是最外层的壳真正承载查询体的是SelectBody而getSelectBody()就是专门把这块核心结构取出来的入口。拿到它你才能安全地遍历查询字段、表、条件、排序和分页信息。这篇文章我直接把思路、代码、坑全部摊开讲。1. 先把它拆开看getSelectBody() 到底返回了什么1.1 SELECT 在解析器里不是一个字符串而是一棵对象树对没接触过 AST抽象语法树的人来说第一反应是用字符串处理 SQL先找到 SELECT 关键字再找 FROM 关键字中间截取一段。这种思路在简单语句上能跑通但到了真实业务里SQL 是层峦叠嶂的——子查询嵌套、表达式里带括号、别名里有逗号、字符串常量里包含关键字任何一个细节都可能导致正则失控。JSqlParser 的思路完全不同。它把整条 SQL 拆解成一颗由 Java 对象构成的结构树树的根节点是Statement接口往下分叉出Select、Insert、Update、Delete等不同类型的语句节点。其中Select节点就是专门描述“这是一条 SELECT 查询”的顶层对象。而Select对象内部并不是扁平地存放字段列表、表名和条件它只持有一个SelectBody属性。这个属性才是真正的查询主体普通查询对应PlainSelect集合操作对应SetOperationListCTE 对应WithItem。如果你用 Debug 模式看过这个对象会看到它内部全是FromItem、Join、Where、GroupBy、SelectItem这类子对象没有一处是字符串。这个设计决定了以后做任何 SQL 改写都得在对象树上操作而不是拿着原始文本做拼接。我头一次调试这类代码时的感受就是输出结果跟输入 SQL 长得不太一样第一反应怀疑解析器坏了。其实不是。JSqlParser 有自己的规范化输出策略它会根据对象树重新生成 SQL原始 SQL 里的注释、特殊空白和部分冗余括号会被丢掉。所以如果你在意原始文本必须在解析前先单独存一份字符串而不是依赖toString()。1.2 方法签名与真实返回值的类型分支直接看方法定义Select类上的关键方法如下public SelectBody getSelectBody()它的返回类型是SelectBody接口。这个接口下有两个最常见的实现类加一个特殊变体实现类对应 SQL 形态典型示例PlainSelect普通单条查询SELECT id, name FROM users WHERE age 18SetOperationList集合运算查询SELECT ... UNION SELECT ...INTERSECT、EXCEPT 同理WithItem公共表表达式CTEWITH t AS (SELECT ...) SELECT * FROM t很多教程只演示PlainSelect分支直接强转。这在实际项目里非常危险。只要 SQL 里出现 UNION或者以 WITH 开头这种行为 100% 会抛ClassCastException。我至少踩过三次痛感很深。正确做法是先判断类型再走分支基本骨架长这样SelectBody body select.getSelectBody(); if (body instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) body; // 处理普通查询FromItem、Where、GroupBy、OrderBy、Limit... } else if (body instanceof SetOperationList) { SetOperationList setOpList (SetOperationList) body; // 分别处理每条 SELECT 分支 for (Select selectItem : setOpList.getSelects()) { // ... } } else if (body instanceof WithItem) { WithItem withItem (WithItem) body; // 处理 WITH xx AS (...) 前缀 }还有一个细节SelectBody接口本身带一个accept(SelectVisitor)方法。如果你要处理嵌套子查询手写一堆instanceof分支会非常痛苦。用访问者模式写一个SelectVisitor实现类遍历过程会舒服很多。我最初觉得多此一举直到碰上一个三层嵌套子查询的需求才发现这个设计有多好用。1.3 为什么非要单独抽出一层 SelectBody有人会问直接让Select持有PlainSelect不就行了搞一层接口不是增加复杂度吗这个问题恰恰是 JSqlParser 设计里的聪明之处。SQL 语法是分层的。SELECT关键字在最外层后面既可以跟一个普通查询体也可以跟 UNION 连接的多个查询体还可以跟 WITH 前缀。如果把这些全部平铺在Select类里每增加一种语法结构就得加字段而且各种组合会让类的可维护性急剧下降。SelectBody接口把“SELECT 后面可以是什么”定义成一个可扩展的契约。Select只关心自己持有一个SelectBody具体是普通查询还是集合操作由实现类自己消化。对调用方来说getSelectBody()永远返回同一个接口你只要对返回对象做类型分支即可。用一个生活化类比你不必关心听筒对面是谁你只要知道接起电话一定有声音是谁在说话由电话系统决定。2. 什么场景下你绕不开 getSelectBody()2.1 数据权限条件自动注入后台管理系统最常见的权限模型是用户只能看自己组织下的数据。实现方式通常是在 SQL 执行前由统一拦截器给原语句追加条件比如AND org_id 10086。这里的问题在于如果只是简单往 SQL 末尾拼接遇到带ORDER BY或LIMIT的语句会被拼到错误位置条件加在ORDER BY后面直接语法错误。正确做法是解析成对象树调用getSelectBody()拿到PlainSelect后调用setWhere(...)把原有条件和权限条件用AndExpression组合起来再输出新 SQL。整个过程绕不开getSelectBody()因为只有它才能让你安全地操作 Where 节点。2.2 分页改造与 COUNT 查询优化很多数据中台接的是 MySQL但有些场景需要把底层 SQL 迁移到其他数据库这时分页语法往往不一样。与其改一堆业务代码不如在中间层写一个 SQL 改写器解析出PlainSelect把原Limit信息取出来再按目标方言重新生成分页语法。另外就是 COUNT 优化。做列表页时经常需要SELECT COUNT(*) FROM ...统计总条数。如果直接套一层SELECT COUNT(*) FROM (原SQL) temp性能可能会差。更优的做法是解析原 SQL如果查询体结构不复杂没有GROUP BY、没有DISTINCT、没有HAVING就直接把原查询的SelectItems替换成COUNT(*)去掉ORDER BY和LIMIT。这种操作一定要经过getSelectBody()才能拿到那个可控的主体对象。2.3 表名提取与血缘分析数据治理类项目经常要统计一条 SQL 到底涉及哪些表这是数据血缘的基础能力。实现思路很直接从PlainSelect里取FromItem再遍历Joins列表取每一张关联表。遇到子查询时得递归往下继续解析。如果不经过getSelectBody()你拿到的只是一个抽象的 Statement 壳子根本不知道该从哪个节点开始遍历。基于这个 API 往上做才能准确地把“查询了哪张核心表、关联了哪几张扩展表”这种信息摘出来形成表级依赖图。2.4 SQL 审计、脱敏与方言转换做 SQL 审计时需要把某条查询涉及字段的访问记录打点比如谁查了用户手机号。解析出SelectItem列表后逐个检查命中敏感字段就记录日志。脱敏则更直接解析出要查询的字段如果发现手机号字段且当前用户没有查看明文权限就把该字段替换成CONCAT(LEFT(phone, 3), ****, RIGHT(phone, 4))这种脱敏表达式。所有这些都需要先定位到查询主体节点再往下翻字段列表。方言转换就更好理解了把 A 数据库的专用函数改成 B 数据库的或把分页语法从一种改成另一种。操作对象同样是SelectBody下面的各个子节点。2.5 工具选型这个场景必须用 JSqlParser 吗Java 生态里做 SQL 解析的不止 JSqlParser像 Apache Calcite、Druid 内置的 SQL Parser 都能干类似的事。但它们的学习成本和依赖重量不同。如果你的项目只需要解析 SELECT 并做有限改写用 JSqlParser 是性价比最高的选择依赖小、API 直观上手快。要是你的需求涉及完整的查询规划、优化器、多数据源联邦查询那才需要上重武器 Calcite。我自己的原则是能用轻量级解决就不上重型框架毕竟后期维护成本差一个量级。3. 完整实操从一条 SQL 到改写后的 SQL3.1 依赖引入与基础解析以 Maven 为例在pom.xml里加上dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.6/version /dependency注意不同大版本 API 差异明显。4.x 已经是com.github.jsqlparser包名3.x 及更早的包名是net.sf.jsqlparser。也就是说标题里的net.sf.jsqlparser.statement.select.Select是老版本路径。如果你用的是 4.x代码里的 import 要改成com.github.jsqlparser.statement.select.Select。解析入口很简单String sql SELECT id, name, org_id FROM users WHERE status 1 ORDER BY created_at DESC LIMIT 10; CCJSqlParserUtil parser new CCJSqlParserUtil(); Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { Select select (Select) statement; // 下一步就是调用 getSelectBody() }CCJSqlParserUtil.parse()接受 SQL 字符串返回Statement。拿到Select之后getSelectBody()才隆重登场。3.2 从 Select 对象到 SelectBody 的三种形态判断解析后先不要急着强转用一个分支把三种情况处理干净SelectBody body select.getSelectBody(); if (body instanceof PlainSelect) { PlainSelect ps (PlainSelect) body; // TODO 处理普通查询 } else if (body instanceof SetOperationList) { SetOperationList sol (SetOperationList) body; ListSelect selects sol.getSelects(); for (Select s : selects) { handleSelectBody(s.getSelectBody()); // 递归处理 } } else if (body instanceof WithItem) { WithItem wi (WithItem) body; // CTE 的查询体在 wi.getSelectBody() 里 SelectBody cteBody wi.getSelectBody(); // 之后还得处理外层真正的查询体这里只取第一层示例 }实际项目里WithItem和PlainSelect经常一起出现。比如WITH t AS (SELECT ...) SELECT * FROM t最外层的SelectBody解析后其实是PlainSelect只是它的FromItem指向了WithItem中定义的别名。所以处理时不能只盯着一层要理解整个对象树的结构。3.3 拿到 PlainSelect 之后的高频操作这是整个实操里最常用的部分。PlainSelect提供了一组直白的 Getter/SetterPlainSelect ps (PlainSelect) body; // 1. 取查询字段列表 ListSelectItem? selectItems ps.getSelectItems(); for (SelectItem? item : selectItems) { System.out.println(item.getExpression()); // 可能是 Column、Function 等 } // 2. 取主表 FromItem fromItem ps.getFromItem(); System.out.println(fromItem); // 通常是 Table也可能是子查询 SubSelect // 3. 取关联表 ListJoin joins ps.getJoins(); if (joins ! null) { for (Join join : joins) { FromItem rightItem join.getRightItem(); // 这里能拿到 JOIN 的右表 } } // 4. 取 where 条件 Expression where ps.getWhere(); if (where ! null) { System.out.println(where.toString()); } // 5. 去掉排序 ps.setOrderByElements(null); // 6. 去掉分页 ps.setLimit(null); // 7. 重新输出 SQL System.out.println(ps.toString());getSelectItems()返回的是SelectItem?泛型列表遍历时通常拿getExpression()就能看到字段表达式。要特别注意如果查询字段是COUNT(*)、DISTINCT name这类特殊形式表达式内部的表达式类型并不相同处理时要做好类型判断。3.4 实战案例给 SELECT 自动注入行级权限条件场景描述某后台系统要求所有查询按当前用户所属组织过滤且不能影响业务自己写的条件。预期效果如下-- 原 SQL SELECT id, name, org_id FROM users WHERE status 1 -- 改写后 SQL SELECT id, name, org_id FROM users WHERE status 1 AND org_id 10086完整代码实现import com.github.jsqlparser.JSQLParserException; import com.github.jsqlparser.expression.operators.relational.EqualsTo; import com.github.jsqlparser.expression.operators.relational.Expression; import com.github.jsqlparser.expression.operators.relational.AndExpression; import com.github.jsqlparser.parser.CCJSqlParserUtil; import com.github.jsqlparser.schema.Column; import com.github.jsqlparser.schema.Table; import com.github.jsqlparser.statement.Statement; import com.github.jsqlparser.statement.select.PlainSelect; import com.github.jsqlparser.statement.select.Select; import com.github.jsqlparser.statement.select.SelectBody; import com.github.jsqlparser.statement.select.SetOperationList; import com.github.jsqlparser.statement.select.WithItem; public class PermissionSqlRewriter { public String rewrite(String sql, Long userId) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(sql); if (!(statement instanceof Select)) { return sql; // 非 SELECT 语句不做处理交给其他拦截器 } Select select (Select) statement; SelectBody body select.getSelectBody(); if (body instanceof PlainSelect) { PlainSelect ps (PlainSelect) body; injectOrgCondition(ps, userId); } else if (body instanceof SetOperationList) { SetOperationList sol (SetOperationList) body; for (Select s : sol.getSelects()) { SelectBody subBody s.getSelectBody(); if (subBody instanceof PlainSelect) { injectOrgCondition((PlainSelect) subBody, userId); } } } else if (body instanceof WithItem) { // CTE 场景递归处理外层主体这里先用最简方式 WithItem wi (WithItem) body; SelectBody wiBody wi.getSelectBody(); if (wiBody instanceof PlainSelect) { injectOrgCondition((PlainSelect) wiBody, userId); } } return select.toString(); } private void injectOrgCondition(PlainSelect ps, Long userId) { // 从 fromItem 里推断组织字段归属简单实现用 users.org_id EqualsTo permissionCond new EqualsTo(); permissionCond.setLeftExpression(new Column(org_id)); permissionCond.setRightExpression(new com.github.jsqlparser.expression.LongValue(userId)); if (ps.getWhere() null) { ps.setWhere(permissionCond); } else { AndExpression and new AndExpression(); and.setLeftExpression(ps.getWhere()); and.setRightExpression(permissionCond); ps.setWhere(and); } } }这个例子虽然简单但已经覆盖了核心路径解析 → 判断类型 → 改写条件 → 重新输出。真实项目里要注意几个点new Column(org_id)看起来没问题但如果 SQL 里的表加了别名比如FROM users u那么条件里的org_id也必须带上别名否则新生成的 SQL 很可能报“字段不存在”。处理方式是从FromItem对象里解析出别名再拼到Column的Table上。// 带别名的写法 Table table (Table) ps.getFromItem(); String tableAlias table.getAlias() ! null ? table.getAlias().getName() : null; if (tableAlias ! null) { Column col new Column(tableAlias .org_id); // 不能手动拼字符串正确方式 // col.setTable(new Table(tableAlias)); // col.setColumnName(org_id); }如果是子查询作为FromItem情况会更复杂需要递归进入子查询的PlainSelect去注入条件。总之这个 API 只是入口真正的功力在于你对整棵 AST 结构有多熟悉。4. 坎坷实录常见的坑和排查思路4.1 一强转就爆 ClassCastException现象解析一条 SQL 后执行(PlainSelect) select.getSelectBody()直接抛出类型转换异常。排查思路先用类型判断输出实际类名。System.out.println(select.getSelectBody().getClass().getName());大概率看到SetOperationList。原因就是 SQL 里有 UNION或者以括号开头组合查询。解决方式在 3.2 里已经展示先判断再分支处理。这个坑出现频率极高而且只在遇到特定 SQL 时才触发如果不提前覆盖测试环境一片平静上线第一天报警就来了。4.2 SQL 里带着注释时解析后注释消失这不是getSelectBody()的问题而是整个 JSqlParser 的特有行为。解析器关注的是语法结构注释属于词法层面的信息默认不会被保留到对象树中。所以任何基于toString()的 Log 打印都会丢注释。如果业务上必须保留注释比如做 SQL 审批时需要在原位置显示注释内容通常的做法是解析前用正则把注释单独提取出来跟解析后的结构化 SQL 分开存储。不要指望在对象树上找回注释。4.3PlainSelect里可能拿不到 Limit很多新手查分页信息直接调ps.getLimit()结果返回null。原因有两种一是 SQL 本身没有LIMIT二是getLimit()返回的是Limit对象但 SQL 写的是LIMIT 10, 20这种形式。解析器会把它解析为Limit对象不过内部 offset/rowCount 的获取方式在不同版本里有差异。排查时先用ps.toString()输出完整 SQL确认 Limit 确实存在再检查有没有LIMIT ALL这类特殊写法。还有一种情况是旧版解析器对LIMIT 10 OFFSET 20的支持跟你想的不一样打印对象属性看就行。4.4 带 WITH 的语句处理完主体容易漏掉 CTEWITH t AS (SELECT ...) SELECT * FROM t这种 SQLSelectBody解析结果可能是一个PlainSelect。很多人在PlainSelect分支里处理完FromItem、Where就结束了没意识到FromItem指向的是t这个别名而实际的t定义在WithItem里。如果此时你想给t子查询注入权限条件只在主查询层面操作是无效的。处理方式是从PlainSelect的FromItem识别出表名t然后在Select对象的WithItemsList中找到对应WithItem进入它的SelectBody做递归处理。这个流程确实繁琐但这是 CTE 场景下绕不开的路。4.5 解析不可信 SQL 时必须防住性能攻击写内部工具时我经常把 SQL 解析服务暴露给其他团队调用后来发现一个问题恶意构造的超长嵌套 SQL 会导致 CPU 飙升甚至内存溢出。JSqlParser 本质上是 JavaCC 生成的递归下降解析器遇到特别深的嵌套会产生很深的调用栈。排查时明确两个方向一是控制输入长度比如超过 5000 字符的 SQL 直接拒绝解析二是捕获ParseException和JSQLParserException后做降级处理不要让异常直接打到调用方。还有一个技巧是复用解析器实例不如每次新建来得安全因为解析过程中存在线程共享状态问题建议每次都实例化新的解析对象。4.6 版本升级后 import 路径和 API 签名变化标题里是net.sf.jsqlparser.statement.select.Select这是 3.x 之前的包名。4.x 之后改成了com.github.jsqlparser.statement.select.Select。如果你从旧版本升到 4.x光是 import 就得全局替换一批文件。更麻烦的是部分方法签名也变了。比如 3.x 里getSelectItems()返回ListSelectItem4.x 变成了ListSelectItem?有些方法从getXXX()变成了getXXX()或setXXX参数类型有变化。升级时别指望编译通过就行还要用全量 SQL 样本集跑一遍回归对比改写前后的 SQL 语义是否一致。5. 实操中沉淀的几个习惯用这个 API 做 SQL 改写时我逐步摸出了一些经验分享出来供参考。写任何针对SelectBody的逻辑第一件事就是打日志确认对象形态。调试代码时直接输出body.getClass()和body.toString()别靠猜。类型分支一定要覆盖PlainSelect、SetOperationList、WithItem即使当前需求只说处理普通查询也至少要有一个兜底分支记录日志方便后续排查。改写条件时优先用对象组合的方式比如AndExpression、EqualsTo、Column这些构造器尽量避免手动拼接字符串。对象方式生成的 SQL 更规整而且不容易因为字符串拼接引入转义错误。需要处理表别名时优先从FromItem对象里取别名不要拿原始 SQL 做正则解析。解析器的版本升级一定要谨慎。升级后跑一遍全量 SQL 回归测试尤其是带 UNION、WITH、特殊函数和注释的语句看看改写结果是不是跟预期一致。JSqlParser 的 bug 不算少但胜在社区活跃、修得快只要测试样本够全风险可控。最后说一个经常被忽略的体验SQL 解析这个方向入门门槛不高但做好不容易。getSelectBody()只是握手的第一步真正拉开差距的是你对每种 AST 节点形态的理解深度。愿意花时间把常见 SQL 形态的解析结果打印出来看一遍比背十个 API 文档都有用。