一个 <select> 长到三百行、二十几个 <if>、改一次需求要通读半小时 XML——病灶不在 MyBatis,而在于把条件拼接当成了字符串拼接的延伸。
动态 SQL 的标签本质是对 SQL 文本做结构化裁剪:<where> 裁前导连接词,<set> 裁尾逗号,<trim> 把这两件事参数化,<choose> 把累积拼接换成互斥选择。
1. where 1=1 与 <where> 的自动裁剪
<select id="search" resultMap="UserMap">
select id, name, status, created_at from t_user
where 1=1
<if test="name != null and name != ''">
and name like concat('%', #{name}, '%')
</if>
<if test="status != null"> and status = #{status} </if>
</select>
1=1 的唯一作用是让后面每个 <if> 都能无脑以 and 开头,代价是恒真谓词进了 SQL 文本:执行计划多一个无用谓词、慢日志多一截噪音。
<where> 只干两件事:内部有内容才输出 WHERE;裁掉紧跟其后的第一个 AND/OR(连带空白)。
| 做法 | 两条件命中时 | 无条件命中时 | 问题 |
|---|---|---|---|
where 1=1 |
where 1=1 and name like ? and status = ? |
where 1=1 |
语义噪音进文本 |
<where> |
where name like ? and status = ? |
无 where 子句 |
只裁第一个连接词 |
注意"只裁第一个":两个 <if> 分别以 and/or 开头且都命中时,得到 col_a = ? or col_b = ?——已是"或"语义。标签不替你补括号和逻辑,互斥关系要用 <choose>。
2. <choose>/<when>/<otherwise>:互斥分支
| 标签 | 语义 | 多条件命中时 | 用途 |
|---|---|---|---|
<if> |
累积 | 全部拼接 | 可选过滤 |
<choose>/<when> |
短路 | 只拼第一个 | 互斥筛选/排序策略 |
<otherwise> |
兜底 | 全不命中才拼 | 默认分支 |
<select id="listByKeyword" resultMap="UserMap">
select id, name, phone, status from t_user
<where>
<choose>
<when test="keyword != null and keyword != ''">
and (name like concat('%',#{keyword},'%') or phone like concat('%',#{keyword},'%'))
</when>
<when test="userId != null"> and user_id = #{userId} </when>
<otherwise> and status = 1 </otherwise>
</choose>
</where>
</select>
<otherwise> 是静默兜底:全不命中时它塞进一个没预期的过滤条件(查不到数据还以为是数据没了),不写它则放开成全表。写之前先回答:该"默认只查有效数据"还是"什么都不查"?后者应让方法返回空列表,而不是让 SQL 扫全表。
3. <foreach> 的六个属性
| 属性 | 必填 | 含义 | 易错点 |
|---|---|---|---|
collection |
是 | 遍历对象 | 单参数固定写 list/array;多参数必须 @Param 后用别名 |
item |
是 | 元素变量名 | 要与 #{} 里一致 |
index |
否 | list 下标 / map 的 key | 一般省略 |
open |
否 | 循环前的串 | 只拼一次 |
separator |
否 | 元素间分隔符 | 末尾不加,别手写逗号 |
close |
否 | 循环后的串 | 与 open 成对 |
用法一:in 列表。
<select id="listByIds" resultMap="UserMap">
select id, name from t_user where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
用法二:批量插入,item 是对象引用,#{u.name} 走 getter:
<insert id="batchInsert">
insert into t_user (name, status, created_at) values
<foreach collection="list" item="u" separator=",">
(#{u.name}, #{u.status}, #{u.createdAt})
</foreach>
</insert>
空集合是最经典的事故点:生成 where id in (),MySQL 报语法错误,Oracle 报 ORA-00936。收口方式是 Java 侧判空返回 Collections.emptyList(),而不是在 XML 里堆 1 = 0 分支。
4. <set> 去尾逗号,<trim> 是它的通用形式
<update id="updateSelective">
update t_user
<set>
<if test="name != null">name = #{name},</if>
<if test="status != null">status = #{status},</if>
<if test="updatedAt != null">updated_at = #{updatedAt},</if>
</set>
where id = #{id}
</update>
<set> 输出 SET 并裁掉末尾多余逗号,所以每个 <if> 里都写逗号是正确姿势。全部为 null 时输出空,得到 update t_user where id = ?——语法错误,是调用方给了全空对象;服务层判断"无字段变更就不发 SQL"比在 XML 里堆判断可靠。
<set> 与 <where> 都是 <trim> 的语法糖:
| 语法糖 | 等价 <trim> |
裁剪动作 |
|---|---|---|
<where> |
<trim prefix="WHERE" prefixOverrides="AND |OR "> |
前导连接词 |
<set> |
<trim prefix="SET" suffixOverrides=","> |
尾部逗号 |
| 自定义包括号 | <trim prefix="(" suffix=")" suffixOverrides=","> |
尾逗号 + 括号 |
prefixOverrides/suffixOverrides 是以 | 分隔、区分大小写且含空格的字符串:写 AND 匹配不到小写 and,这是"配了却不生效"的头号原因,覆盖两种写法要写 AND |OR |and |or 。
5. <sql> + <include>:抽什么、不抽什么
<sql id="Base_Column_List">id, name, status, created_at, updated_at</sql>
<sql id="Common_Filter">
<where>
<if test="status != null">and status = #{status}</if>
<if test="name != null and name != ''">and name like concat('%',#{name},'%')</if>
</where>
</sql>
<select id="search" resultMap="UserMap">
select <include refid="Base_Column_List"/> from t_user
<include refid="Common_Filter"/> order by id desc
</select>
<include> 的 <property> 替换片段里的 ${}(不是 #{}):
<sql id="Order_By">order by ${column} ${direction}</sql>
<include refid="Order_By">
<property name="column" value="created_at"/>
<property name="direction" value="desc"/>
</include>
${} 是文本替换,value 只能放字面量:把请求参数塞进 value 就是注入,值应是代码里写死的枚举常量。
| 该抽 | 不该抽 | 理由 |
|---|---|---|
| 列清单 | 单条语句独有的 join 子句 | 列清单变更频率高且需全局一致 |
| 稳定公共过滤(软删除、租户隔离) | 只用一次的 3 行条件 | 抽了要跨文件跳转才看得懂 |
| 数据权限范围条件 | 语义强绑定的 order by |
排序随接口变,抽了要传一堆 property |
| 分页尾 | 被拆成 5 个片段的一条 where | 粒度小于"一次能读完"就没有复用价值 |
最后一行是重点:片段抽得太碎,可读性反而下降。一条 where 拆成 where_part1..3,读时要在三处拼脑内 SQL;阈值是"这段 SQL 能否独立命名",能就抽,不能就别抽。
6. <bind> 与 XML 转义
<select id="searchByKeyword" resultMap="UserMap">
<bind name="pattern" value="'%' + keyword + '%'"/>
select id, name from t_user
<where>
<if test="keyword != null and keyword != ''">and name like #{pattern}</if>
</where>
</select>
<bind> 的价值是消掉方言差异:MySQL 用 concat('%', ?, '%'),Oracle 用 '%'||?||'%'。代价是它在 <if> 之外也执行,keyword 为 null 时得到 %null%,所以要放进 <if> 内部或确保下游有条件保护。
XML 错误,与 MyBatis 无关:
| 字符 | 实体 | 典型位置 |
|---|---|---|
< |
< |
created_at < #{endTime} |
<= |
<= |
时间区间上界 |
> |
> |
>= 可裸写,统一转义更省心 |
& |
& |
&& 必须写 && |
" |
" |
属性值内嵌双引号字符串时 |
<![CDATA[ and created_at < #{endTime} ]]>
CDATA 把整段原样交给解析器,但不能嵌套,也不能用在属性值里——<if test="<![CDATA[...]]>"> 无效。习惯是"SQL 片段用 CDATA,表达式用实体"。
7. <if> 判断失效的四种原因
test 走 OGNL,规则与 Java 有出入。
| 原因 | 现象 | 正确写法 |
|---|---|---|
| 只判 null 不判空串 | 传 "" 仍拼出 like '%%',命中全表 |
name != null and name != '' |
| 字符串用单引号字面量 | test="type == 'A'" 恒 false(OGNL 的 'A' 是 char) |
test='type == "A"' |
| 数字与字符串混比 | status 是 String 时 test="status == 1" 恒 false |
字符串字面量,或字段改枚举 |
| 参数名对不上 | There is no getter for property named 'xxx' |
见下 |
第二条要展开:OGNL 的 == 对两个对象走 equals 语义,所以 'published' == status 两边都是 String 时成立;但单引号字面量是 char,char 比 String 恒不灵。第三条更隐蔽:库表是 varchar、实体是 String,条件永不拼,筛了跟没筛一样,且零报错。
参数名的三种形态:
int updateByExample(User user); // 单参数对象 → test="name != null"
List<User> list(String name); // 单参数 String → test="_parameter != null"
List<User> list(@Param("dto") User dto); // 多参数 → test="dto.name != null"
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
There is no getter for property named 'name' in 'class java.lang.String' |
单参数是 String 却写了属性名 | 改 _parameter,或套一个 DTO |
syntax error ... near 'where' |
<set> 全空,生成无赋值的 update |
服务层判无变更直接返回 |
in () / ORA-00936 |
<foreach> 收到空集合 |
Java 侧拦空集合 |
The content of elements must consist of well-formed character data |
SQL 里 < 未转义 |
< 或 CDATA |
test="status == 1" 永不生效 |
字段是 String 与数字比较 | 改字符串字面量或枚举 |
9. 边界
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 条件只有 1–2 个 | 写两条独立 <select> |
分支比 <if> 直观,且能各自优化 |
| 报表式几十个可选维度 | 查询对象 + 片段,或换 Criteria | XML 会膨胀到不可维护 |
| 动态表名/列名 | ${} + 白名单 |
<property> 只能传字面量 |
一句话分工:<if> 管可选、<choose> 管互斥、<foreach> 管列表、<trim> 家族管裁剪、<sql> 管复用。五个职责混在一起,XML 就长成没人敢改的样子。
10. 自测
<where>会裁掉几个前导连接词?两个条件都以and开头时生成的 SQL 是什么?<choose>与连续三个<if>在"三个条件全命中"时生成的 SQL 有何不同?<foreach>的open/close执行一次还是每次循环一次?separator会出现在最后一个元素后面吗?<set>展开成<trim>后,prefix与suffixOverrides各填什么?<include>的<property>替换的是${}还是#{}?为什么它不能承接用户输入?test="type == 'A'"在type为String时恒 false,原因是什么?两种修法各是什么?