2217 字
约 7 分钟
20
S08-X09 · 动态 SQL 与 ResultMap 映射

动态 SQL 的坑几乎都是运行时才炸:<if> 条件写松了拼出空 WHERE,foreach 超参数上限直接报 ORA-01795,ResultMap 里 column 拼错一个字母,字段静默变 null——不报错、不告警,下游取空值时才被发现。

下面把九种标签的判据、where/set 的剥离逻辑、OGNL 必须背下来的写法,以及 ResultMap 的四种映射形态摊开。


1. 九个标签与选择判据

标签 解决什么 什么时候用
<if> 单条件拼接 可选筛选
<choose>/<when>/<otherwise> 多选一,首个命中即停 互斥分支(排序、状态机)
<where> 拼 WHERE 并剥掉开头多余 AND/OR 多条件动态查询首选
<set> 拼 SET 并剥掉尾部多余逗号 动态更新
<trim> 自定义前后缀与剥离字符 where/set 表达不了时
<foreach> 集合展开 IN (...)、批量 values
<bind> 表达式绑成变量 拼 like 的 %、复用计算结果
<sql>/<include> 片段复用 列清单、公共条件
<selectKey> 插入前后取主键 序列、非自增主键

选择顺序一句话:能用 <where> 就别用 <trim>,能用 <trim> 就别手拼 WHERE 1=1——后者不报错,但日志难读。

<select id="findOrders" resultMap="orderMap">
    SELECT id, user_id, status, created_at FROM orders
    <where>
        <if test="userId != null">AND user_id = #{userId}</if>
        <if test="status != null and status != ''">AND status = #{status}</if>
        <if test="begin != null">AND created_at &gt;= #{begin}</if>
        <if test="end != null">AND created_at &lt; #{end}</if>
    </where>
    ORDER BY id DESC
</select>

2. where / set 的剥离机制

<where> 内部是一个 TrimSqlNode:前缀 WHERE、前缀覆盖 AND |OR 。逻辑是只有内部有内容才加前缀,加了前缀才剥掉紧跟着的那一小段。

<set> 同理:后缀 SET、后缀覆盖 ,,只剥末尾一个逗号,SET a=1,, 依旧语法错。

<trim> 是通用形式:

<trim prefix="WHERE" prefixOverrides="AND |OR ">
    <if test="userId != null">AND user_id = #{userId}</if>
</trim>
<trim prefix="SET" suffixOverrides=",">
    <if test="name != null">name = #{name},</if>
</trim>

两个易错点:prefixOverrides 大小写敏感,写 and 匹配不到 AND;分隔符里的尾空格有意义("AND |OR " 是"AND+空格""OR+空格"两个候选),漏掉就剥不干净。


3. <if test> 的 OGNL 陷阱

test 是 OGNL 表达式,不是 Java:

写法 结果 正确写法
status != null 空串 "" 也通过,拼出 status = '' status != null and status != ''
flag == 'Y' 'Y' 被当 char,与 String 比较恒假 flag == "Y"(XML 里写 &quot;)或 .equals
a != null && b != null XML 里 & 要转义 用 and/or,或写 &amp;&amp;
type == 1 字面量是 Integer/Long,String 字段恒假 type == '1' 或先转型

单字符最阴:"Y".equals('Y') 为 false,条件恒假,SQL 里那段条件永远不出现——查询范围悄悄变大。另外 test 里不要调方法,@java.lang.Integer@parseInt(...) 这类静态调用会被 OGNL 安全限制拦掉。


4. foreach 的三属性与 IN 上限

<select id="findByIds" resultType="Order">
    SELECT * FROM orders WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
属性 作用 常见错误
collection 遍历对象 单参数没加 @Param 时靠猜 list/array
item 循环变量名 与外层 #{} 重名,取错值
separator/open/close 分隔与括号 忘写 open/close,拼出 IN 1,2,3

collection 的解析顺序:先看 @Param 名字,再看 ParamNameResolver 给单参数集合塞的 list/array/collection 键。所以 findByIds(List<Long> ids) 要写 collection="list",加了 @Param("ids") 才能写 "ids"。

IN 上限是硬约束:Oracle 列表不超过 1000 项(ORA-01795),PostgreSQL 绑定参数上限 65535,MySQL 受 max_allowed_packet 与语句长度限制。超阈值要分片查询再合并,这一步放 Service,别指望 <foreach> 兜住。


5. ResultMap:什么时候必须写

resultType 靠列名到属性的默认规则自动映射;resultMap 是显式声明。四种情况必须写:

场景 为什么 resultType 不行
列名与属性名不一致且无全局驼峰 自动映射找不到属性,字段 null
一对一/一对多关联 需要 association/collection 组织层级
需要 typeHandler 只能在 <result> 上指定
join 出同名列(两个 id、name) 自动映射互相覆盖
<resultMap id="orderMap" type="com.example.Order">
    <id column="id" property="id"/>
    <result column="user_id" property="userId"/>
    <result column="created_at" property="createdAt"
            typeHandler="com.example.InstantTypeHandler"/>
    <association property="user" javaType="User"
                 column="user_id" select="findUserById"/>
    <collection property="items" ofType="OrderItem"
                column="id" select="findItemsByOrderId"/>
</resultMap>

驼峰转换有三个配置位,优先级从高到低:<resultMap> 的显式 column(不参与转换)→ mybatis-config.xml 的 mapUnderscoreToCamelCase → Spring Boot 的 mybatis.configuration.map-underscore-to-camel-case。多模块项目最常见的坑是 A 模块配在 SqlSessionFactoryBean、B 模块用 XML,两处不一致,同一实体在不同 Mapper 里一个能映射一个不能。

column 与 property 拼错不会报错:显式声明覆盖了自动映射,属性留 null。定位靠日志——logging.level.com.example.mapper=debug 打出结果集列名再对照。


6. 嵌套映射:嵌套查询 vs 嵌套结果

方式 写法 SQL 次数 适用
嵌套查询 select="findItemsByOrderId" 1 + N 关联数据少、可复用语句、开懒加载
嵌套结果 单条 join + <collection> 内联 <result> 1 一对多、数据量可控
<resultMap id="orderWithItems" type="Order">
    <id column="order_id" property="id"/>
    <result column="order_status" property="status"/>
    <collection property="items" ofType="OrderItem">
        <id column="item_id" property="id"/>
        <result column="item_name" property="name"/>
    </collection>
</resultMap>

<collection> 里的 <id> 是分组键:DefaultResultSetHandler 靠它判断这行是否同一父对象。缺 <id> 或 join 后 id 有重复值,collection 会被合并成一个对象或数量错乱——这就是"一对多 join 后行数变多、对象数变少"的根因。

N+1 可以用 fetchType="lazy" 缓解,但懒加载在 session 关闭后抛 LazyInitializationException;真解法是改嵌套结果,或先查父 id 集合、再一次 IN 查子表在 Service 里分组。


7. discriminator 与 typeHandler

<resultMap id="vehicleMap" type="Vehicle">
    <id column="id" property="id"/>
    <discriminator javaType="string" column="vehicle_type">
        <case value="CAR" resultMap="carMap"/>
        <case value="TRUCK" resultMap="truckMap"/>
    </discriminator>
</resultMap>

discriminator 按列值切换映射,适合单表继承。限制明确:判据列必须在同一行结果集里,所以只能配嵌套结果;<case> 的 resultMap 要显式继承父映射公共字段,否则公共列丢失。

typeHandler 做 Java 与 JDBC 类型转换,继承 BaseTypeHandler,实现 setNonNullParameter 与三参的 getNullableResult:

@MappedTypes(Instant.class)
@MappedJdbcTypes(JdbcType.TIMESTAMP)
public class InstantTypeHandler extends BaseTypeHandler<Instant> {
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    Instant p, JdbcType t) throws SQLException {
        ps.setTimestamp(i, Timestamp.from(p));
    }
    @Override
    public Instant getNullableResult(ResultSet rs, String col) throws SQLException {
        Timestamp ts = rs.getTimestamp(col);
        return ts == null ? null : ts.toInstant();
    }
}

注册有三处:<typeHandlers> 全局包扫描、<result> 单个指定、#{} 里的 typeHandler=。全局注册时 javaType/jdbcType 都要能推出来,否则遇到 NULL 会落到 UnknownTypeHandler 猜错类型。


8. useGeneratedKeys 回填

<insert id="insert" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
    INSERT INTO orders (user_id, status) VALUES (#{userId}, #{status})
</insert>

keyProperty 指定生成值写回参数的哪个属性(支持 info.id 嵌套),keyColumn 只在 PostgreSQL 这类要显式指定返回列时有用。非自增主键用 <selectKey>,order="BEFORE" 先取值再插入,AFTER 则插入后取。

批量插入能否回填取决于驱动与 rewriteBatchedStatements 等参数,MySQL 可回填、Oracle 不行,按驱动版本核对。


9. 排错:六种静默失败

现象 原因 处理
WHERE 后无内容、语法错 所有 <if> 不成立,<where> 仍出前缀 加兜底条件
条件恒不生效、范围变大 status != null 放过空串 补 and status != ''
flag == 'Y' 永远为假 单引号解析成 char 双引号或 .equals
字段全 null 无异常 column 拼错、驼峰配置不一致 Mapper 包 debug 日志核对列名
一对多数量偏少 <collection> 缺 <id> 或分组键重复 补 <id>,确认 id 唯一
IN 查询报错 列表超数据库上限 分片,阈值按版本核对

10. 边界

需求 动态 SQL 更合适的位置
十几个可选筛选 是 —
排序字段拼接 否(${} 有注入面) 服务端白名单映射
多表复杂聚合 勉强 宽表或专用语句
分页 不必手写 统一交给分页插件

底线:${} 只用于结构(表名、列名、排序方向)且来自白名单,值一律 #{}。动态 SQL 也要有复杂度上限——三层以上的 <choose> 嵌套,说明该拆成多条语句在 Service 里组合。


11. 自测

  1. <where> 剥掉多余 AND 的前提条件是什么?
  2. test="status != null" 为什么会放过空字符串?
  3. test="flag == 'Y'" 恒为假的原因是什么?
  4. collection="list" 这个键名是谁放进去的?
  5. <collection> 里 <id> 缺失会导致什么后果?
  6. useGeneratedKeys 在批量插入时能否回填,取决于什么?
S08-X09 · 动态 SQL 与 ResultMap 映射
https://www.clxhxhhr.top/posts/5956/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。