上周快要下班的时候,线上一个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(片段) | 期望的输出要求 |
|---|---|---|
| 1 | id IN (1, 2, 3) | 输出包含IN (1, 2, 3) |
| 2 | id IN (SELECT id FROM t2 WHERE age > 18) | 输出包含IN (SELECT ...) |
| 3 | id NOT IN (SELECT id FROM t2) | 输出包含NOT IN (SELECT ...) |
| 4 | (a, b) IN ((1, 2), (3, 4)) | 左侧输出包含(a, b),右侧保持IN ((1, 2)... |
| 5 | id 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检测补上,让这种问题没机会第二次在线上出现。