news 2026/10/3 4:51:17

JSQLParser 4.x中IN子查询括号丢失的排查与修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSQLParser 4.x中IN子查询括号丢失的排查与修复

上周快要下班的时候,线上一个SQL改写任务突然开始批量报错。我把最终生成的SQL直接打出来看了一眼,整个人愣住了——原始SQL里明明写的是seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type = 2),经过 JSQLParser 4.x 解析再 toString 之后,居然变成了seller_id IN SELECT shop_id FROM t_shop WHERE shop_type = 2,右边的括号消失得无影无踪。

这不是简单的格式化问题。缺了这层括号,整条SQL提交到数据库直接语法报错,等于线上批量任务全部白执行。当时我第一反应是自己拼接SQL的代码写错了,排查到最后才发现,问题出在 JSQLParser 表达式树输出时的括号策略上。这篇文章就完整记录一下这个坑的排查思路、根因和三种可落地的修复方案,给正在用 JSQLParser 4.x 做SQL解析、改写、脱敏、生成的同学一个参考。

1. 现象与复现:一条SQL在解析改写后“变坏”了

1.1 线上问题:改写后的SQL语法报错

我们内部有一套SQL改写平台,核心流程很简单:接收用户SQL → 用 JSQLParser 解析成 AST → 在AST上做表名替换、条件追加、字段裁剪 → toString 输出新SQL → 交给下游执行。这套流程跑了挺久,平时都很稳。直到那天凌晨,监控突然拉响,一批改写后的SQL在 OSS 侧的数据库上执行失败。

我把失败SQL捞出来,做了个对比:

原始SQL:

SELECT user_id, user_name FROM t_order WHERE status = 1 AND seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type = 2)

改写后输出:

SELECT user_id, user_name FROM t_order WHERE status = 1 AND seller_id IN SELECT shop_id FROM t_shop WHERE shop_type = 2

肉眼就能看出来,IN后面的子查询括号丢了。这已经不只是语义变化的问题,而是直接生成了非法SQL。数据库方的报错也很直白:You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SELECT shop_id ...'。

奇怪的点在于:我们并没有手动拼接这段条件,整个SQL是原样解析再原样输出的,中间只是在AST上改了另一处表名。既然输入是合法SQL,理论上重新toString也应该输出合法SQL。谁能想到括号会在“原样往返”的过程中丢掉。

1.2 最小复现代码与版本差异

为了确认不是平台业务代码的锅,我写了一个最小复现,代码量不到十行:

import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; public class InExpressionRepro { public static void main(String[] args) throws Exception { String sql = "SELECT * FROM t WHERE id IN (SELECT id FROM t2 WHERE age > 18)"; Statement statement = CCJSqlParserUtil.parse(sql); System.out.println(statement.toString()); } }

在某个4.x小版本上,输出是:

SELECT * FROM t WHERE id IN SELECT id FROM t2 WHERE age > 18

而代码本身没有任何额外逻辑,纯粹就是 parse 之后 toString。这说明问题出在 JSQLParser 的表达式输出模块,而不是业务代码。

这里要提醒一句:这个现象不是所有4.x版本都必现。我自己在不同小版本上测试过,从4.3左右开始,IN子查询的括号输出被修正过一部分;但老版本、某些特殊写法还是会踩中。另外,如果你不是直接statement.toString(),而是用了自定义的 DeParser 或自己遍历 AST 拼SQL,风险会更大。所以看到文章标题先别急着对号入座,继续往下看完再判断。

1.3 IN表达式的几种形态,谁的括号会丢

IN表达式在SQL里有几种常见写法,我在排查时把所有形态都列了一遍,比对parse再toString的输出,方便定位规律:

IN表达式形态示例toString后括号是否正常
常量列表id IN (1, 2, 3)正常,输出仍为IN (1, 2, 3)
子查询id IN (SELECT id FROM t2 WHERE age > 18)异常,可能变为IN SELECT id FROM t2...
NOT IN 子查询id NOT IN (SELECT id FROM t2)异常,可能变为NOT IN SELECT id FROM t2
左侧为行值(a, b) IN ((1, 2), (3, 4))左侧括号存在丢失风险
冗余括号id IN ((SELECT id FROM t2))双括号可能变成单括号,甚至无括号

这个表格基本锁定了问题范围:常量列表分支是安全的,子查询分支不安全,左侧行值也不够安全。接下来要做的就是打开AST,看看InExpression到底存了什么结构,以及toString的逻辑到底在哪一步漏掉了括号。

2. 定位根因:InExpression的双通道设计与toString的括号缺口

2.1 把AST拆开,看IN节点到底存了什么

JSQLParser 解析SQL时会构建一棵表达式树(AST),IN对应的是net.sf.jsqlparser.expression.operators.relational.InExpression节点。我先写了一个简单的AST打印工具,把刚才那条id IN (SELECT id FROM t2 WHERE age > 18)的结构粗略打出来,长这样:

InExpression ├── leftExpression: Column(id) └── rightExpression: SubSelect └── selectBody: PlainSelect ├── selectItems: [Column(id)] ├── from: Table(t2) └── where: GreaterThan(Column(age), LongValue(18))

关键信息都在这个结构里:InExpression的右侧不是ExpressionList,而是一个SubSelect节点。

在 JSQLParser 4.x 中,InExpression的右侧有两种承载方式:

  • getRightItemsList():对应IN (1, 2, 3)这种常量列表,类型是ExpressionList之类的ItemsList;
  • getRightExpression():对应IN (SELECT ...)这种子查询,类型是Expression,通常是SubSelect。

也就是说,IN节点天然有两条路可以走。toString 时到底保不保括号,取决于 visitor 对这两条路分别怎么处理。

2.2 ToStringVisitor里漏掉的“右括号”

JSQLParser 的 Statement 输出最终是交给 visitor 模式完成的。表达式部分的输出逻辑在ExpressionDeParser或ToStringVisitor这类类里。以我排查时跟踪到的简化逻辑来看,visit(InExpression)大致是这样:

@Override public void visit(InExpression inExpression) { inExpression.getLeftExpression().accept(this); if (inExpression.isNot()) { builder.append(" NOT"); } builder.append(" IN "); if (inExpression.getRightExpression() != null) { // 子查询分支:直接输出右侧表达式,没有包裹括号 inExpression.getRightExpression().accept(this); } else if (inExpression.getRightItemsList() != null) { // 列表分支:显式拼接左右括号 builder.append("("); inExpression.getRightItemsList().accept(this); builder.append(")"); } }

问题已经很清晰了:子查询分支在拼接IN之后,直接把右侧的SubSelecttoString 结果贴了上来。而SubSelect自身并不负责输出外层括号,它的职责只是输出SELECT id FROM t2 WHERE age > 18这一段。于是一组合起来就成了IN SELECT id FROM t2 WHERE age > 18。

反观列表分支,因为代码里显式写了builder.append("(")和builder.append(")"),所以IN (1, 2, 3)始终是安全的。

2.3 为什么列表分支没有问题,子查询分支就出问题

这里面的本质是:在表达式树中,括号通常不是表达式的固有属性,而是父节点按语法需要“决定”是否添加的。SubSelect作为一颗子树,它只负责“说出自己是什么”,至于外面需不需要套一层括号,是父节点InExpression的责任。

你可以这样理解:SubSelect就像一个中间不带包装的商品,它自己在货架上是裸着的;IN这个货架要求商品必须带外包装才能上架。列表分支老老实实加了包装,子查询分支却忘了这一步,导致裸着就发货了。

这个原因还能解释另一个衍生问题:不只是 IN,其他需要括号包裹子查询的表达式也有类似风险,只是平时大家用得少,没有被触发而已。所以在表达式输出这条链路上,“谁负责加括号”必须理清楚,否则换一个表达式类型,坑还会再踩一遍。

3. 解决实战:三种可落地的修复思路

问题定位到这一步,剩下的就是怎么修。我实际试过三种方案,都跑通了,分别适用于不同场景,下面逐个说。

3.1 方案一:升级到已修复的4.x小版本

最省事的方案是升级依赖。JSQLParser 4.x 的小版本迭代中,确实有对InExpressiontoString 逻辑的修复。如果你们项目能控制依赖版本,直接升到较新的4.x版本,然后跑一遍回归测试,大概率问题就消失了。

我当时先试了升级,从项目原本锁定的版本升到新版本后,同样的最小复现代码输出立刻变成:

SELECT * FROM t WHERE id IN (SELECT id FROM t2 WHERE age > 18)

括号回来了,不需要改任何业务代码。

但升级不是无脑操作,需要评估风险:

  • JSQLParser 4.x 各小版本之间 API 有变动,比如InExpression的部分 setter 在旧版本是setRightItemsList(...),新版本推荐用setRightExpression(...),编译阶段就会暴露一部分问题;
  • 解析行为可能变化,同一段SQL在不同版本的AST结构可能不同,如果你们有自定义 visitor 或深度依赖AST结构,要重点回归;
  • 输出格式可能有调整,导致线上存的SQL指纹、审计字段发生变化。

所以升级前建议把项目的测试用例拉起来完整跑一遍,特别是SQL解析、改写相关的。如果团队没有现成的SQL回归样本库,可以顺手参考我后面第4节的做法,一次性补齐。

3.2 方案二:自定义ExpressionDeParser补括号

如果依赖版本被其他系统锁死,暂时升不动,那就只能自己接管输出。JSQLParser 提供了 visitor 扩展点,常见做法是继承ExpressionDeParser,重写visit(InExpression),在子查询分支补上括号。

核心逻辑如下(以你具体使用的4.x源码为基准微调):

import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.expression.operators.relational.ExpressionDeParser; import net.sf.jsqlparser.statement.StatementDeParser; public class SafeInExpressionDeParser extends ExpressionDeParser { @Override public void visit(InExpression inExpression) { // 先输出左侧表达式 inExpression.getLeftExpression().accept(this); // 处理 NOT if (inExpression.isNot()) { append(" NOT"); } append(" IN "); if (inExpression.getRightExpression() != null) { append("("); inExpression.getRightExpression().accept(this); append(")"); } else if (inExpression.getRightItemsList() != null) { append("("); inExpression.getRightItemsList().accept(this); append(")"); } } }

然后在输出Statement时,把自定义的ExpressionDeParser注入进去:

SafeInExpressionDeParser expressionDeParser = new SafeInExpressionDeParser(); StatementDeParser statementDeParser = new StatementDeParser(new StringBuilder(), expressionDeParser); statement.accept(statementDeParser); String result = statementDeParser.getBuffer().toString();

这里有几个细节需要强调:

  • 如果右侧表达式本身已经是Parenthesis(比如原SQL写的是IN ((SELECT ...))),你再包一层会输出IN ((SELECT ...)),虽然语法合法,但看起来不干净。可以在包裹前判断一下类型,只在右侧是SubSelect时补括号;
  • 如果你们的代码里已经自定义过一整套 DeParser,重写时最好把原有逻辑复制进来改,而不是继承默认实现再覆盖,避免默认实现里其他表达式的输出策略被破坏;
  • 这个方案本质是“接管输出”,后续如果升级JSQLParser,自定义部分的兼容性要专门测试。

3.3 方案三:构建AST时用Parenthesis显式包裹

如果问题不是出在“解析后再toString”,而是你们正在手动构建SQL、需要动态生成IN (子查询)条件,那方案三更合适:在构造AST时,直接用Parenthesis节点把子查询包一层。

Parenthesis是 JSQLParser 提供的专门表示括号的表达式包装类。手动构建IN表达式可以这样写:

import net.sf.jsqlparser.expression.Parenthesis; import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.statement.select.SubSelect; SubSelect subSelect = new SubSelect(); subSelect.setSelectBody(plainSelect); // 假设已经构建好子查询主体 InExpression inExpression = new InExpression(); inExpression.setLeftExpression(new Column("t", "id")); inExpression.setRightExpression(new Parenthesis(subSelect));

这样输出的SQL就是:

WHERE t.id IN (SELECT ...)

这个方案思路最直接:既然父节点忘了加括号,那在AST层面就自己带上括号,相当于把“加括号”的责任提前到构建阶段完成。

不过要注意,如果未来升级了JSQLParser,而新版本在visit(InExpression)子查询分支已经自动加括号,那么你手动包的Parenthesis会带来双重括号IN ((SELECT ...))。双重括号合法,但不美观,而且可能影响SQL指纹一致性。我建议在代码里加一行注释,标明这是针对某个旧版本括号丢失问题的补偿,升级后需要review是否移除。

3.4 三种方案怎么选:一张表看清取舍

我把三种方案放到一起对比过,整理成下面这张表:

维度方案一:升级版本方案二:自定义DeParser方案三:Parenthesis包裹
适用场景能控制依赖,希望根治依赖版本锁死,需要兜底手动构建AST,改动点可控
改动量依赖文件一行需要新增类并调整输出入口每个构建点增加包装
风险点版本升级带来的API/行为变化自定义输出逻辑的长期维护成本新版本自动加括号后可能双括号
是否推荐长期保留推荐推荐作为过渡方案推荐配合升级计划使用

从我个人的角度,如果条件允许,首选方案一;如果暂时升不了级,方案二和方案三可以组合:手动构建场景用方案三,解析再toString场景用方案二。三者在代码里并不冲突。

4. 验证与回归:把“括号不丢”变成自动化防线

修复完只是第一步。SQL改写这类系统最怕的不是出一次bug,而是同一个方向的问题换个马甲再出现。所以我在这次修复之后做了一套回归防线,核心思路是:维护一份SQL黄金样本,把parse→toString后的输出和期望输出做比对,并辅以二次解析校验。

4.1 黄金样本:覆盖IN的常见形态

我整理了一个样本清单,基本覆盖了IN表达式能遇到的各种形态:

用例编号输入SQL(片段)期望的输出要求
1id IN (1, 2, 3)输出包含IN (1, 2, 3)
2id IN (SELECT id FROM t2 WHERE age > 18)输出包含IN (SELECT ...)
3id NOT IN (SELECT id FROM t2)输出包含NOT IN (SELECT ...)
4(a, b) IN ((1, 2), (3, 4))左侧输出包含(a, b),右侧保持IN ((1, 2)...
5id IN ((SELECT id FROM t2))输出保持双括号或至少有一层括号
6多层嵌套:id IN (SELECT id FROM t2 WHERE x IN (1, 2))内外层IN都保留括号

这份清单不需要很长,但一定要覆盖“列表”和“子查询”两条分支,否则不能有效防止同类问题回归。

4.2 二次解析+字符串断言双保险

每次执行完SQL改写后,我会做两件事。第一件事是字符串断言,直接校验结果里是否存在关键括号:

String output = statement.toString(); assertTrue("IN表达式缺少左括号", output.contains("IN (")); assertTrue("NOT IN表达式缺少左括号", output.contains("NOT IN ("));

字符串断言比较粗暴,但很适合做第一层防线,成本极低。

第二件事是二次解析。把生成的SQL再次交给CCJSqlParserUtil.parse,如果能正常解析,说明至少在语法层面没有产生破坏:

Statement reparsed = CCJSqlParserUtil.parse(output); assertTrue(reparsed instanceof Select);

二次解析的意义在于:很多输出错误(比如IN SELECT)会导致语法解析直接抛异常。只要二次解析能通过,至少排除了最严重的非法SQL问题。字符串断言和二次解析两个条件同时满足,我才认为这条用例通过。

4.3 CI中的SQL改写回归用例怎么设计

这两件事落地到CI,我建议设计成参数化测试:

  • 把黄金样本放在一个JSON或YAML文件里,每条记录至少包含inputSql、assertContains、illegalKeywords三个字段;
  • assertContains是一个字符串列表,比如["IN (", "NOT IN ("],测试框架循环校验输出必须全部包含;
  • illegalKeywords用于检查反面条件,比如["IN SELECT", "NOT IN SELECT"],只要输出里出现就算失败;
  • 每次代码提交、依赖升级、visitor改动,都自动跑一遍这套用例。

我实际跑下来的感受是:这套回归的价值很快就能体现出来。就在修复后的第二周,一个同事调整了自定义visitor里另一处表达式的输出顺序,差点又把括号问题带进来,CI在第一时间拦截住了。如果没有这套防线,这种问题大概率又会在线上炸一次。

5. 避坑地图:IN表达式周边还有哪些括号陷阱

这次排查虽然只针对IN子查询括号丢失,但顺着这个问题,我把IN表达式周边几个容易踩的坑也一并梳理了,印象很深,这里一并分享。

5.1 左侧行值表达式的括号也很容易丢

很多人只盯着IN右侧,忽略了左侧。JSQLParser 的InExpression左侧不一定是一个简单列,也可以是一个ExpressionList,对应SQL里的行值比较,比如:

WHERE (user_id, user_type) IN ((101, 1), (102, 2))

如果左侧在AST中是一个ExpressionList,但visitor输出时没考虑它需要括号,toString后可能变成:

WHERE user_id, user_type IN ((101, 1), (102, 2))

这在MySQL里直接语法错误。修复思路和右侧子查询一样:如果左侧是ExpressionList,输出时要用Parenthesis包裹,或者在DeParser里对InExpression的leftExpression做类型判断,判断为ExpressionList时补括号。

5.2 NOT IN与子查询组合时别漏了括号

NOT IN (SELECT ...)和IN (SELECT ...)是同一个问题,但更容易被忽略。因为排查时你会优先盯IN,而NOT IN的输出里多了个NOT,字符串搜索的时候如果只搜IN (,可能会漏掉NOT IN (开头的情况。

我的回归用例里把NOT IN (SELECT ...)单独列了一条,并且在字符串断言里同时校验IN (和NOT IN (,就是这个原因。

5.3 4.x版本间API差异带来的隐性坑

JSQLParser 4.x 内部的InExpressionAPI有过调整。早期版本更习惯用setRightItemsList(...)来设置子查询,而4.x推荐用setRightExpression(...)。如果你在旧文档或旧博客的指引下混用了API,可能出现:

  • 设置了rightExpression,但通过getRightItemsList()拿不到值;
  • 同时设置了两个字段,toString时走了错误分支;
  • 自定义visitor里只处理了rightItemsList,导致rightExpression分支被原样拼接。

我建议团队里统一规范:子查询一律用setRightExpression,常量列表一律用setRightItemsList,不要混用。并且把这类规范写进代码review的checklist里。

5.4 DML场景同样要纳入验证

还有一个容易遗漏的点:IN表达式不只出现在SELECT语句里。DELETE、UPDATE、甚至JOIN ON条件里都可能出现。我们的业务里有一次就是在UPDATE的WHERE子句里踩中了同样的问题:

UPDATE t_order SET status = 0 WHERE seller_id IN (SELECT shop_id FROM t_shop WHERE shop_type = 2)

改写后同样可能变成IN SELECT ...。所以黄金样本不能只覆盖SELECT,建议至少补充一条UPDATE、一条DELETE的用例。这类语句在线上系统的风险比SELECT更大,一旦输出错误,直接影响数据变更。

这次排查到最后的感受是:JSQLParser 这类表达式树解析器,本身把很多括号输出细节做在了各个节点的toString逻辑里,但是不同节点、不同版本之间并不总是保持一致。遇到IN子查询丢括号这种问题,不用慌,先确认AST结构,再定位是哪个输出分支漏了括号,然后按团队实际情况选择升级、自定义DeParser或显式包Parenthesis。最后,把回归样本和CI检测补上,让这种问题没机会第二次在线上出现。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 4:49:14

AI技术博文写作的底层逻辑与内容构建原则

我理解您的要求,但需要坦诚说明:当前输入内容中,项目标题为 "Thoughts on AI",其余字段(项目正文、关键词、摘要描述)全部为空,且未提供任何实质性背景、领域指向、技术细节或具体场景…

作者头像 李华
网站建设 2026/10/3 4:48:53

OpenShell实战:一体化开源终端环境,高效管理多标签与远程会话

说实话,把“终端”这件事从头折腾到位,我踩过的坑比写业务代码多多了。以前用系统自带的终端,开两个窗口切来切去,日志一多就想骂人,配色刺眼到加班半小时眼睛就酸。后来试着用 OpenShell 这样的一体化开源终端环境&am…

作者头像 李华
网站建设 2026/10/3 4:48:50

用Neo4j构建教育知识图谱:从知识点建模到学习路径生成

做教育场景的知识图谱,我们手上有教材、课件、练习、考试数据、学生的掌握情况,这些东西如果不用图把它串起来,就永远是一盘散沙。我去年用 Neo4j 把这套数据真正落到了一个“能跑”的图谱上:从知识点建模开始,到知识点…

作者头像 李华
网站建设 2026/10/3 4:48:15

MATLAB高升力螺旋桨参数化设计与气动性能闭环验证

简介:本资源是一套基于MATLAB的高升力螺旋桨参数化设计与性能仿真工具包,面向航空工程、电子信息及数学类专业的高校学生与科研工程师,解决螺旋桨气动建模、几何参数调整与推力/升力性能快速评估等核心工程问题。压缩包共16个文件&#xff0c…

作者头像 李华
网站建设 2026/10/3 4:48:12

游戏开发面试题:对象池设计详解与性能优化实战

1. 面试题背后的真实意图拆解“设计一个对象池”——这道题在游戏开发岗面试里出现的频率极高,尤其是Unity、Unreal方向的客户端岗位。很多人第一反应是“这不就是个池子吗,拿的时候取一个,不用的时候还回去”,然后就开始写代码。…

作者头像 李华
网站建设 2026/10/3 4:47:16

Unity资产库自动化:整包导入、URP转换、植被布置与DCC联动

1. 从“素材地狱”到“资产库”:我为什么要自己造轮子做 Unity 项目超过三年的人,大概率都经历过这样一个阶段:硬盘里躺着几十个 G 的模型、贴图、材质、音频,文件夹名字从Assets_Final到Assets_Final_New再到Assets_真的最终版&a…

作者头像 李华