ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis Plus 自定义 SQL:QueryWrapper 拼接 AND 报错?先搞懂 customSqlSegment

MyBatis Plus 自定义 SQL:QueryWrapper 拼接 AND 报错?先搞懂 customSqlSegment 先说我一个真实经历。上周同事发来一段代码让我看Mapper接口里方法写的是ListUser selectCustom(Param(Constants.WRAPPER) QueryWrapperUser wrapper)XML 里是SELECT * FROM user ${ew.customSqlSegment} and status 1。这个接口平时跑得好好的但只要前端没传任何筛选条件数据库就会抛出BadSqlGrammarException报错位置永远停在AND status 1前面。刚开始大家都怀疑是 SQL 写错了但把status 1去掉后又一切正常。折腾了半天最后才发现问题出在${ew.customSqlSegment}的“可变性”上这个片段不是普通参数它可能带着WHERE关键字也可能是一段ORDER BY还可能是空字符串我们却用固定写死的AND去衔接它不出错才怪。这其实是一个非常典型的 MyBatis Plus 自定义 SQL 组合查询问题。标题里的三个关键词——Param(Constants.WRAPPER)、QueryWrapper、${ew.customSqlSegment}——单独拿出来大家都认识组合在一起就容易踩坑。这篇文章我就把这个坑的前因后果、原理机制和几种稳定解法一次讲清楚顺便整理一份排查速查表给后面接手这类需求的同学少走点弯路。1. 问题复现一段让同事怀疑人生的SQL1.1 典型错误写法长什么样大多数人第一次接触${ew.customSqlSegment}都是被 MyBatis Plus 官方文档里那句“自定义 SQL 时可使用 QueryWrapper 自动拼接查询条件”吸引来的于是很容易照猫画虎写出下面这种 Mapper 方法public interface UserMapper extends BaseMapperUser { ListUser selectUserByStatus( Param(Constants.WRAPPER) QueryWrapperUser wrapper, Param(status) Integer status); }XML 里的写法也自以为很聪明select idselectUserByStatus resultTypeUser SELECT * FROM user ${ew.customSqlSegment} AND status #{status} /select逻辑上觉得QueryWrapper 负责动态查询条件我再用 AND 补一个固定的状态过滤条件两者拼起来就完事了。问题是${ew.customSqlSegment}不是“条件片段”而是一个“可能包含 WHERE 或 ORDER BY 的完整片段”这样粗暴拼接几个典型翻车现场在所难免。1.2 现场翻车日志与报错分析当 wrapper 里没有设置任何条件时ew.customSqlSegment是一个空字符串上面的 SQL 在解析后变成SELECT * FROM user AND status 1MySQL 直接报语法错误。实际日志类似这样### SQL: SELECT * FROM user AND status 1 ### Cause: com.mysql.cj.jdbc.exceptions.MySQLSyntaxErrorException: 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 AND status 1 at line 1而当 wrapper 里只有排序条件时又会变成SELECT * FROM user ORDER BY create_time DESC AND status 1这在 MySQL 里同样是语法错误而且报错位置在AND status 1看起来很像“排序后面不该加 AND”容易把排查方向引到排序上其实根子还是同一个customSqlSegment不是稳定可拼接的条件片段。1.3 为什么会走到这条弯路说句公道话这种写法不能全怪写代码的人。一个是 MyBatis Plus 的文档示例里customSqlSegment基本都是单独使用比如SELECT * FROM user ${ew.customSqlSegment}没人告诉过你这个片段在无条件下会是空串在有排序时会是ORDER BY ...。另一个原因是日常开发里大家习惯了用 MyBatis 的动态if拼条件潜意识里默认${ew.customSqlSegment}也是一个“可插拔的 WHERE 段”于是自然而然地用 AND 去续写。但实际上一旦 wrapper 里没有任何实体条件系统给你返回的就是个空串此时任何后缀 AND 都会成为孤立的语法错误源头。理解到这里问题的根源已经清楚接下来我们去看一看 MyBatis Plus 内部到底是怎么生成这两个片段的。2. 搞懂 Constants.WRAPPER、customSqlSegment 和 sqlSegment2.1 Param(Constants.WRAPPER) 到底是什么在 MyBatis 里Mapper 方法如果有多个参数就必须用Param给参数命名否则 XML 里没法直接用参数名取值。MyBatis Plus 定义了一个常量public interface Constants { String WRAPPER ew; // ... }所以Param(Constants.WRAPPER) QueryWrapperUser wrapper等价于Param(ew) QueryWrapperUser wrapper之所以推荐用常量而不是手写字符串主要是避免拼写错误。你写ew写顺手了还行一旦在某个 Mapper 里不小心写成weXML 里的${ew.customSqlSegment}立刻找不到参数报错信息是Parameter ew not found。我见过一个小团队四个人对这个参数名有两种写法集成测试的时候一晚上都在查这种低级问题。所以建议统一用Constants.WRAPPER这个常量从根上消灭这类失误。2.2 customSqlSegment 究竟是一段什么东西在 AbstractWrapper 内部QueryWrapper 会被拆分成多个片段普通查询条件normal、分组groupBy、having、排序orderBy等。对外暴露了几个取片段的方法其中最常用的两个是getSqlSegment()返回普通查询条件片段不带 WHERE 关键字通常以左括号开头、右括号结尾例如(name ? AND age ?)。如果没有任何查询条件返回空字符串。getCustomSqlSegment()返回一个“装饰过”的完整片段规则是如果有普通查询条件返回WHERE (name ? AND age ?)如果没有任何查询条件、但有排序条件返回ORDER BY create_time DESC如果既没有查询条件也没有排序条件返回空字符串。这里插一句很多人以为customSqlSegment是“WHERE 段”其实它是一个完整后缀并不单指 WHERE。也正因如此直接拿它去跟固定 AND 拼接才会在不同 wrapper 状态间产生截然不同的结果。2.3 两个片段的区别一张表看清楚片段属性空 wrapper只有条件只有排序条件排序${ew.sqlSegment}空串(name ? AND age ?)空串(name ? AND age ?)${ew.customSqlSegment}空串WHERE (name ? AND age ?)ORDER BY create_time DESCWHERE (name ? AND age ?) ORDER BY create_time DESC从这张表可以看出来sqlSegment适合自己控制 WHERE 关键字的场景customSqlSegment适合直接放到 SQL 末尾、什么都不做的场景。两者的用途完全不一样混用就会踩坑。2.4 MergeSegments 是如何决定返回值的想深入一点的同学可以直接看 MyBatis Plus 源码核心处理在com.baomidou.mybatisplus.core.conditions.segments.MergeSegments。当调用eq、like、orderBy等方法时条件片段会被解析并归入不同的 Segment 列表最后执行getCustomSqlSegment()或getSqlSegment()时再根据当前列表状态决定输出内容。我记忆里比较关键的逻辑是public String getCustomSqlSegment() { if (hasNormal()) { return WHERE getNormalSqlSegment(); } if (hasOrderBy()) { return ORDER BY getOrderBySqlSegment(); } return StringUtils.EMPTY; }所以才会出现“无条件时返回空串”的诡异行为。这也是为什么自定义 SQL 里只要涉及额外拼接就要先考虑这个片段可能是空串或可能是 ORDER BY 段而不能默认它一定是 WHERE 条件。3. 正确衔接附加条件四套可行方案3.1 方案一所有条件都进 Wrapper别在 SQL 里留尾巴最省心的做法是把所有过滤条件包括状态、时间、关键字等全部在 service 层塞进 wrapper然后 XML 只保留一个${ew.customSqlSegment}QueryWrapperUser wrapper new QueryWrapper(); wrapper.lambda() .eq(Objects.nonNull(status), User::getStatus, status) .like(StringUtils.isNotBlank(keyword), User::getName, keyword) .orderByDesc(User::getCreateTime); ListUser userList userMapper.selectCustom(wrapper);select idselectCustom resultTypeUser SELECT * FROM user ${ew.customSqlSegment} /select这种写法下wrapper 空就返回SELECT * FROM user有排序就返回排序有条件就返回 WHERE永远不需要在 XML 里二次拼 AND。它最大的优点是简单、稳定也最容易让后期维护的人一眼看懂。缺点是有时业务方会硬塞给我一个“固定不变”的条件比如status 1如果这个条件也被放入 wrapper那 service 层每次都要重复写eq(status, 1)代码学会有些冗余。这种情况我会建议抽一个公共方法在 service 工厂里统一处理好。3.2 方案二用 sqlSegment 配合where自己拼如果你确认自己需要额外拼接一些固定条件比如多表查询中关联表的字段也要过滤那就不该再用customSqlSegment而是要切换到sqlSegment。因为它不带 WHERE正好可以塞进where标签里让 MyBatis 替我们处理掉第一个 AND 的尴尬select idselectUserWithDept resultTypeUser SELECT u.* FROM user u LEFT JOIN dept d ON u.dept_id d.id where if teststatus ! null AND u.status #{status} /if if testew ! null and ew.sqlSegment ! null and ew.sqlSegment ! AND ${ew.sqlSegment} /if /where /select这里有个容易忽略的细节${ew.sqlSegment}里已经带上了括号比如(u.name ? AND u.age ?)所以外面用AND衔接是安全的。如果 wrapper 完全没有条件sqlSegment是空串if判断会跳过上面的 SQL 就只剩下WHERE u.status ?不会出现WHERE AND。这一套组合的关键是自己掌握 WHERE 关键字让条件片段只负责条件本身。不过它有个副作用wrapper 的排序不会跟着出现因为sqlSegment不负责排序。如果你确实要在这一条 SQL 里也支持 wrapper 传进来的排序最常见的是把排序也放到 wrapper 之外交给分页插件处理或者在 service 层用一个专门参数传排序列。我自己的习惯是这种场景的排序由前端通过分页参数来控制不塞进 QueryWrapper避免和自定义 SQL 里的排序打架。3.3 方案三固定条件前置再用 if 判断兜住空片段如果你的 SQL 结构比较死比如第一版需求就定了“必须有dept_id过滤其他条件动态”那还可以用固定 WHERE 前缀 动态判断的方式select idselectUserByDept resultTypeUser SELECT * FROM user WHERE dept_id #{deptId} if testew ! null and ew.sqlSegment ! null and ew.sqlSegment ! AND ${ew.sqlSegment} /if if testew ! null and ew.customSqlSegment ! null and ew.customSqlSegment ! and (ew.sqlSegment null or ew.sqlSegment ) ${ew.customSqlSegment} /if /select这里的第二段判断是为了把仅排序的情况兜住如果 wrapper 只有排序没有条件sqlSegment为空customSqlSegment就会是ORDER BY ...此时放在if里安全输出。但这个写法维护成本高如果你的项目里大量 SQL 都这么写后面的人一定会骂街我一般只在很少的遗留 SQL 改造里用。新代码优先选方案一或方案二。3.4 方案四Service 层兜底SQL 保持简单有时候问题不在 mapper XML而在调用方。比如一个接口要支持多种筛选组合但 mapper 已经写死成${ew.customSqlSegment}拼接那 service 层完全可以把所有条件先组装成一个干净的 wrapper再传给 mapperQueryWrapperUser wrapper Wrappers.query(); wrapper.and(StringUtils.isNotBlank(name), w - w.like(name, name)); wrapper.and(StringUtils.isNotBlank(phone), w - w.like(phone, phone)); wrapper.eq(status, 1);在 XML 里select idqueryList resultTypeUser SELECT * FROM user ${ew.customSqlSegment} /select这样的思路本质还是方案一只是强调“组装的过程放在 service 层”。如果你发现 mapper 参数里同时还要传name、phone这类零散字段那说明设计已经偏离了 QueryWrapper 的初衷。尽量把动态条件都收进 wrapper 里XML 的职责就退化成“执行这条 SQL”而不是“拼装这条 SQL”。为了帮你快速决策我把这四种方案的适用场景和坑点放一起方案核心写法排序支持适合场景主要坑点全进 WrapperXML 只留${ew.customSqlSegment}支持绝大多数新需求service 层代码有点长sqlSegment wherewhere内用${ew.sqlSegment}不支持需要拼接固定字段排序要单独处理固定前缀 if 兜底WHERE 固定条件 两段 if可兜底遗留 SQL 改造可读性差Service 层兜底本质是方案一支持团队规范统一对开发习惯有要求我个人的建议优先方案一或者方案二。后面两个更适合用来理解原理不太适合作为团队日常规范。4. 实战中还要注意的五个细节4.1 多参数时别忘了 Param上面提到过Constants.WRAPPER的常量值为ew凡是使用${ew.customSqlSegment}的 Mapper 方法参数列表里必须有一个名为ew的参数。如果你写了多参数方法却漏掉ParamMyBatis 对参数命名会退化成param1、param2此时 XML 里的ew根本找不到。报错现场长这样org.apache.ibatis.binding.BindingException: Parameter ew not found. Available parameters are [param1, param2, ...]这个我用亲身经历提醒一下即使你的方法看起来只有一个 QueryWrapper 参数也建议加上Param(Constants.WRAPPER)不要觉得自己在“多此一举”。等后面需求加一个参数进来很多人会忘记同步改注解问题会在你最没防备的时候出现。4.2 排序和分页别跟 customSqlSegment 打架当你在自定义 SQL 里用${ew.customSqlSegment}wrapper 里的orderBy是能正常拼接到句尾的。但如果你同时又在 XML 里写了固定排序比如SELECT * FROM user ${ew.customSqlSegment} ORDER BY create_time DESC而 wrapper 里也设置了orderByDesc最终 SQL 就会变成SELECT * FROM user WHERE (name ?) ORDER BY create_time DESC ORDER BY create_time DESC两条ORDER BY同时出现MySQL 直接给语法错误或者更隐蔽的是分页插件在生成 count 统计 SQL 时会尝试去掉 order by看到这种 SQL 也会解析得很辛苦。所以规矩很简单自定义 SQL 里的排序只能有一个来源。要么全部交给 wrapper要么全部在 SQL 里写死不能两边都排。用分页插件时尽量把排序交给Page对象或 wrapper让插件统一处理统计 SQL 的剔除逻辑。4.3 千万注意 SQL 注入既然${ew.customSqlSegment}是直接拼接 SQL 片段那么 wrapper 里的“条件内容”就必须谨慎。eq(status, 1)这种字段名写死在代码里没问题但如果是把用户传进来的字符串作为字段名或排序字段拼进去就危险了。比如wrapper.orderByAsc(userInput); // 危险 wrapper.apply(date_format(create_time, %Y-%m-%d) userInput); // 更危险orderByAsc(userInput)中的userInput最终会原样出现在${ew.customSqlSegment}里等于给 SQL 注入开了一扇门。正确姿势是排序字段做一个白名单映射用户传createTime代码映射成create_time自定义函数条件用apply的{0}占位符传参wrapper.apply(date_format(create_time, %Y-%m-%d) {0}, dateStr);这个坑平时不炸一旦炸就是生产事故务必防范。4.4 空 wrapper 的处理千万别忽略我见过不少同学写自定义 SQL 时只在 XML 里写${ew.customSqlSegment}觉得 wrapper 反正会传进来不会为空。但实际调用时service 层可能因为某些分支构建了一个空的new QueryWrapper()或者直接传了null。此时${ew.customSqlSegment}取到的是 null 还是空串取决于 MyBatis 对 null 参数的解析但无论哪种都可能让整条 SQL 缺斤少两。更稳妥的做法是在 XML 里加一层空判断if testew ! null and ew.customSqlSegment ! null and ew.customSqlSegment ! ${ew.customSqlSegment} /if如果你再用方案二或方案三也要习惯性地给ew.sqlSegment做同样判断。这个习惯能省掉很多“为什么传 null 就出错”的夜间排查。4.5 别忽略 MyBatis-Plus 版本差异Param(Constants.WRAPPER)和${ew.customSqlSegment}从 MyBatis Plus 3.x 开始就是稳定的能力但具体行为细节在早期和后期版本上有过微调。比如某些 3.4 之前的版本customSqlSegment对“空条件 排序”的处理可能和现在略有差异。所以遇到奇怪行为时先看一眼 pom 里 mybatis-plus 的版本再去对一下源码。不要拿网上“3.5.1 亲测有效”的结论直接套到自己 3.3.1 的项目上。我自己的团队现在统一用 3.5.x项目里抽了一个工具类专门根据晚不同的 SQL 片段情况做组合判断避免每个 mapper XML 里都写重复的if逻辑。如果你也觉得维护多可以考虑在 Wrapper 工具层做封装而不是每次都手搓。5. 高频报错与排查速查表报错或现象根本原因排查要点推荐解法Parameter ew not foundMapper 方法没加Param(Constants.WRAPPER)或参数名拼错查方法定义、XML 里${ew.*}写法统一用Constants.WRAPPER方法参数和 XML 保持一致SQLSyntaxErrorException: ... near AND status 1wrapper 无任何条件customSqlSegment为空串SQL 里却固定拼了 AND 后缀看打印 SQL确认AND前是否有 WHERE改为方案一或wheresqlSegment... near ORDER BY ... AND ...wrapper 只有排序条件customSqlSegment为ORDER BY ...后缀 AND 拼接错误看 wrapper 是否只 set 了 orderBy把附加条件放进 wrapper或改用sqlSegmentwhere AND (...)XML 里自己写了WHERE又用${ew.customSqlSegment}拼接它自带 WHERE打印 SQL通常能看到两个 WHERE换成${ew.sqlSegment}SQL 执行没问题但结果集不符合预期wrapper里有 OR 条件后续拼接的 AND 改变了运算优先级查看最终 SQL 的括号分布用wrapper.and(...)显式分组或 SQL 里加括号保护分页查询 count SQL 出错自定义 SQL 里既拼了 wrapper 的排序又写了固定排序看 count SQL 是否有多个 ORDER BY排序只保留一个来源交给分页插件处理这里多说一句我在排查这类问题时最常用的手段就是先把 MyBatis 的 SQL 日志打开看到最终执行的 SQL 长什么样很多问题一眼就能定位。比如空串导致的AND开头、双WHERE、双ORDER BY在日志里都特别明显。与其猜 wrapper 内部状态不如看拼出来的结果。6. 我的一点收官心得踩过这个坑之后我给自己的规矩其实就三条。第一自定义 SQL 里能用${ew.customSqlSegment}单独收尾的就绝不去动“再补一个 AND”的念头第二如果确实要补固定条件优先把条件挪进 wrapper让 SQL 保持简单第三实在要在 XML 里拼条件就老老实实用where配合${ew.sqlSegment}并记得处理空判断。这套写法看起来不复杂但实际项目中因为换人维护、需求变更总是会有新的代码不经意间把“旧坑”重新挖出来。我后来在团队里加了一条约定凡是自定义 SQL 用了 wrapper 参数的XML 里不允许再出现手写的AND/WHERE直接衔接 ${ew.xxxSegment}除非经过 review 确认了空片段和排序场景都处理过。规则虽然有点死板但确实帮我省下了不少排查时间。如果你也在用 MyBatis Plus 做复杂查询不妨把这几条规则复制到你团队的规范里也许明年这个时候你就不会因为一句AND status 1熬到凌晨了。
返回列表