2414 字
约 8 分钟
15
S08-X03 · 动态 SQL 的可维护性:if、choose 与 SQL 片段复用

一个 <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 无关:

字符 实体 典型位置
< &lt; created_at &lt; #{endTime}
<= &lt;= 时间区间上界
> &gt; &gt;= 可裸写,统一转义更省心
& &amp; && 必须写 &amp;&amp;
" &quot; 属性值内嵌双引号字符串时
<![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 里 < 未转义 &lt; 或 CDATA
test="status == 1" 永不生效 字段是 String 与数字比较 改字符串字面量或枚举

9. 边界

场景 更合适的选择 理由
条件只有 1–2 个 写两条独立 <select> 分支比 <if> 直观,且能各自优化
报表式几十个可选维度 查询对象 + 片段,或换 Criteria XML 会膨胀到不可维护
动态表名/列名 ${} + 白名单 <property> 只能传字面量

一句话分工:<if> 管可选、<choose> 管互斥、<foreach> 管列表、<trim> 家族管裁剪、<sql> 管复用。五个职责混在一起,XML 就长成没人敢改的样子。


10. 自测

  1. <where> 会裁掉几个前导连接词?两个条件都以 and 开头时生成的 SQL 是什么?
  2. <choose> 与连续三个 <if> 在"三个条件全命中"时生成的 SQL 有何不同?
  3. <foreach> 的 open/close 执行一次还是每次循环一次?separator 会出现在最后一个元素后面吗?
  4. <set> 展开成 <trim> 后,prefix 与 suffixOverrides 各填什么?
  5. <include> 的 <property> 替换的是 ${} 还是 #{}?为什么它不能承接用户输入?
  6. test="type == 'A'" 在 type 为 String 时恒 false,原因是什么?两种修法各是什么?
S08-X03 · 动态 SQL 的可维护性:if、choose 与 SQL 片段复用
https://www.clxhxhhr.top/posts/5802/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。