从零开始 Spring Boot 25:MyBatis II

springboot mybatisplus 自定义类型转换器_SQL


在从零开始 Spring Boot 23:MyBatis - 红茶的个人站点 (icexmoon.cn)中我介绍了MyBatis的一些基本功能和用法,下文介绍遗漏的部分。

本文的示例代码都基于从零开始 Spring Boot 24:处理时间 - 红茶的个人站点 (icexmoon.cn)的最终示例修改而来,可以通过learn_spring_boot/ch24 (github.com)获取相应的代码。

TypeHandler

在之前的文章中,已经介绍过类型处理器(type handler)的基本用法,可以通过在resultMap中对特定字段映射添加typeHandler的方式来实现读取特定字段的特殊处理:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.icexmoon.books2.book.mapper.CouponMapper">
    <select id="getCouponById" resultMap="couponResult">
        SELECT * FROM
            coupon
        WHERE id=#{id} AND del_flag=0
    </select>
    <resultMap id="couponResult" type="cn.icexmoon.books2.book.entity.Coupon">
        <result property="type" column="type" typeHandler="cn.icexmoon.books2.book.entity.typehandler.CouponTypeHandler"></result>
        <discriminator javaType="int" column="type">
            <case value="1" resultType="cn.icexmoon.books2.book.bo.FreeCoupon">
            </case>
            <case value="2" resultType="cn.icexmoon.books2.book.bo.EnoughCoupon">
            </case>
        </discriminator>
    </resultMap>
    <insert id="addCoupon" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO coupon(add_user_id,add_time,amount,expire_time,enough_amount,`type`,del_flag)
        VALUES (#{coupon.addUserId},#{coupon.addTime},#{coupon.amount,javaType=double,jdbcType=NUMERIC,numericScale=2},#{coupon.expireTime},#{coupon.enoughAmount},#{coupon.type,typeHandler=cn.icexmoon.books2.book.entity.typehandler.CouponTypeHandler},0)
    </insert>
</mapper>

当然,写入的时候也一样,上边的示例同样说明了这一点。

之所以这里需要这么做,是因为对于枚举类型,MyBatis默认会使用EnumOrdinalTypeHandler或EnumTypeHandler处理器来进行处理,前者会让枚举转化为枚举在enum中的定义顺序,后者会将枚举转换为枚举的字面量。

可以从mybatis – MyBatis 3 | 配置页面查看MyBatis所有默认的类型处理器列表。

更常见的是,我们会采用以下方式定义的枚举:

package cn.icexmoon.books2.book.entity.enums;
// ...
public enum CouponType implements IDesc<Integer> {
    FREE_COUPON(1, "无门槛券"), ENOUGH_COUPON(2, "满减券");
    @JsonValue
    private Integer value;
    private String desc;

    public Integer getValue() {
        return value;
    }

    public String getDesc() {
        return desc;
    }

    CouponType(Integer value, String desc) {
        this.value = value;
        this.desc = desc;
    }

    public static CouponType match(Integer value) {
        for (CouponType ct : CouponType.values()) {
            if (ct.getValue().equals(value)) {
                return ct;
            }
        }
        return null;
    }
}

这里的IDesc是一个为了方便获取自定义枚举值和中文名称而定义的接口:

package cn.icexmoon.books2.system;
// ...
public interface IDesc<T> {
    T getValue();

    String getDesc();
}

所以这里我们希望MyBatis能默认将实现了IDesc接口的枚举转换为IDesc.getValue返回的值存储,或者以相反的方式读取。

就像之前一样,我们可以创建一个类型处理器来完成这件事:

package cn.icexmoon.books2.system.typehandler;
// ...
@MappedTypes(IDesc.class)
public class IDescEnumTypeHandler<E extends Enum<E> & IDesc<Integer>> extends BaseTypeHandler<E> {
    private Class<E> type;

    public IDescEnumTypeHandler(Class<E> type) {
        this.type = type;
    }


    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, E parameter, JdbcType jdbcType) throws SQLException {
        if (jdbcType == null) {
            ps.setInt(i, parameter.getValue());
        } else {
            ps.setObject(i, parameter.getValue(), jdbcType.TYPE_CODE);
        }

    }

    @Override
    public E getNullableResult(ResultSet rs, String columnName) throws SQLException {
        int i = rs.getInt(columnName);
        return MyEnumUtil.getIEnumInstance(type, i);
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        int i = rs.getInt(columnIndex);
        return MyEnumUtil.getIEnumInstance(type, i);
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        int i = cs.getInt(columnIndex);
        return MyEnumUtil.getIEnumInstance(type, i);
    }
}

不同的是,这里使用了泛型,表明这是一个能够处理实现了IDesc接口的枚举类型的类型处理器:

IDescEnumTypeHandler<E extends Enum<E> & IDesc<Integer>> extends BaseTypeHandler<E>

实际上应当使用IDesc<?>而非IDesc<Integer>,但前者需要在具体实现中处理类型转换的问题,暂时没想到如何处理,所以只能特异化采用后者。

为了让MyBatis能够在需要的时候自动使用这个处理器,而不是手动指定,需要通过@MappedTypes注解添加该处理器能够处理的类型。

此外,还需要通过配置文件将处理器所在包加入MyBatis相关配置,以添加到MyBatis的默认处理器中:

mybatis.type-handlers-package=cn.icexmoon.books2.system.typehandler

现在重启项目后MyBatis就可以自动处理实现了IDesc接口的自定义枚举类型,而不需要在XML中手动指定typeHandler属性了。

MyBatisPlus实现了上述功能,如果集成了MyBatisPlus,可以通过实现IEnum接口来实现自定义枚举,不需要自己实现类型处理器。

动态SQL

这里所谓的动态SQL实际上就是MyBatis拼接SQL时的流程控制语句,其用途和写法和编程语言中的控制语句相似。

if

if标签常用于在SQL中拼接查询条件:

<select id="pageBooks" resultMap="bookResult3">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        WHERE b.del_flag=0
          AND u.del_flag=0
          <if test="dto.userName != null and dto.userName != ''">
            AND u.name LIKE CONCAT('%',#{dto.userName},'%')
          </if>
          <if test="dto.bookName != null and dto.bookName != ''">
            AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
          </if>
          <if test="dto.desc != null and dto.desc != ''">
            AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
          </if>
          <if test="dto.type != null">
              AND b.type = #{dto.type.value}
          </if>
            LIMIT #{start},#{limit};
    </select>

这里dto对应以下类型:

package cn.icexmoon.books2.book.entity.dto;
// ...
@Data
@NoArgsConstructor
public class BookQueryDTO {
    private String userName;
    private String bookName;
    private String desc;
    private CouponType type;
}

BookQueryDTO表示一个查询书籍的条件,包含书籍名称、录入书籍人员姓名等,显然这些条件是可选的,在查询时可能有一个或没有,也可能都存在。所以在XML中使用if来判断条件是否存在,进而完成查询语句的拼接。

需要注意的是,在iftest属性中,如果存在多个条件,使用andor进行逻辑运算,而不是Java常见的&&||,这是因为&是XML的保留字符,需要进行转义,所以最好不要使用。

choose

choose标签的用途类似于编程语言中的switch语句,可以从众多执行块中匹配一个条件符合的执行:

<select id="pageBooks" resultMap="bookResult3">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        WHERE b.del_flag=0
          AND u.del_flag=0
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <if test="dto.type != null">
                      AND b.type = #{dto.type.value}
                  </if>
              </otherwise>
          </choose>
            LIMIT #{start},#{limit};
    </select>

在这个修改后的示例中,添加了一个新的查询条件bookId,并且通过引入choose标签,实现了如果查询条件中有有效的bookId,则无视其他查询条件,按照id查询图书信息并返回

这里when标签的作用类似于case,用于匹配一个条件。otherwise标签的用途类似于default

当然,就像switch...case那样,你可以添加多个when标签,这里仅有一个whenotherwise的情况其实起到了if...else的用途。

where

如果你已经很熟悉在代码中拼接SQL的写法,那肯定对在WHERE语句开头保留至少一个固定条件的必要性有深刻认识,比如上边示例中的:

WHERE b.del_flag=0
          AND u.del_flag=0

如果所有条件都是动态生成的,比如:

WHERE 
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <if test="dto.type != null">
                      AND b.type = #{dto.type.value}
                  </if>
              </otherwise>
          </choose>

当所有条件为空时,就会产生一个类似SELECT * FROM book WHERE这样的语句,显然是不符合SQL语法的,会无法执行。或者因为第一个条件为空,而产生SELECT * FROM book WHERE AND xxx这样的语句,同样无法执行。

如果你的表设计中有逻辑删除字段,可以放在WHERE后避免这一点,就像之前的示例中那样。如果没有,也可以添加一个1=1

WHERE 1 = 1
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <if test="dto.type != null">
                      AND b.type = #{dto.type.value}
                  </if>
              </otherwise>
          </choose>

新手可能会对这种写法很困惑,但其实是一种很实际的写法。

如果你不愿意这么做,MyBatis提供一个额外的选择——where标签:

<select id="pageBooks" resultMap="bookResult3">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        <where>
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <if test="dto.type != null">
                      AND b.type = #{dto.type.value}
                  </if>
              </otherwise>
          </choose>
          AND b.del_flag=0
          AND u.del_flag=0
        </where>
        LIMIT #{start},#{limit};
    </select>

where标签可以在所有条件为空第一个条件为空这两种情况下都拼接出正常的SQL。

trim

where标签可以看做是特异化的trim标签,其等效于:

<trim prefix="WHERE" prefixOverrides="AND |OR ">
  ...
</trim>

trim标签的用途是将包裹内容中以prefixOverrides值开头的内容用prefix中的值代替,当然如果包裹的内容为空,就不处理。

所以上边的示例也可以用trim标签替代:

<select id="pageBooks" resultMap="bookResult3">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        <trim prefix="WHERE" prefixOverrides="AND |OR ">
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <if test="dto.type != null">
                      AND b.type = #{dto.type.value}
                  </if>
              </otherwise>
          </choose>
        </trim>
        LIMIT #{start},#{limit};
    </select>

set

UPDATE语句中,同样存在上边类似的问题:

<update id="updateBookById" parameterType="cn.icexmoon.books2.book.entity.Book">
        UPDATE book
        SET
        <if test="name != null">name=#{name},</if>
        <if test="description != null">description=#{description},</if>
        <if test="userId != null">user_id=#{userId},</if>
        <if test="type != null">type=#{type.value},</if>
        <if test="extra != null">extra=#{extra},</if>
        <if test="delFlag != null">del_flag=#{delFlag}</if>
        WHERE id=#{id} AND del_flag=0
    </update>

只要映射器入参的delFlag属性为空,就会出现类似UPDATE xxx SET xxx=xxx, WHERE这样的语句出现,会报错。这个问题比之前更棘手,不能简单添加1=1之类的去规避,好在MyBatisPlus提供一个set标签:

<update id="updateBookById" parameterType="cn.icexmoon.books2.book.entity.Book">
        UPDATE book
        <set>
            <if test="name != null">name=#{name},</if>
            <if test="description != null">description=#{description},</if>
            <if test="userId != null">user_id=#{userId},</if>
            <if test="type != null">type=#{type.value},</if>
            <if test="extra != null">extra=#{extra},</if>
            <if test="delFlag != null">del_flag=#{delFlag}</if>
        </set>
        WHERE id=#{id} AND del_flag=0
    </update>

同样的,set标签可以看做是特异化的trim标签,和下边的trim标签等效:

<trim prefix="SET" suffixOverrides=",">
  ...
</trim>

需要注意,因为是要特殊处理最后一个,,所以这里是suffixOverrides属性而非prefixOverrides

foreach

foreach标签类似于Java中的for语句,常用于生成SQL中的IN语句。

假设我们的查询条件要求能够一次性查询出多种类型的书籍:

package cn.icexmoon.books2.book.entity.dto;
// ...
@Data
@NoArgsConstructor
public class BookQueryDTO {
    private Integer bookId;
    private String userName;
    private String bookName;
    private String desc;
    private List<BookType> types;
}

对应的XML:

<select id="pageBooks" resultMap="bookResult3">
        SELECT b.*,u.id AS u_id,u.name AS u_name,u.password AS u_password,u.open_id AS u_open_id,u.real_name AS u_real_name,u.icon AS u_icon,u.dept_id AS u_dept_id,u.del_flag AS u_del_flag
        FROM book AS b
                 LEFT JOIN `user` AS u
                           ON b.user_id=u.id
        <trim prefix="WHERE" prefixOverrides="AND |OR ">
          <choose>
              <when test="dto.bookId != null and dto.bookId > 0">
                  AND b.id = #{dto.bookId}
              </when>
              <otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    AND b.name LIKE CONCAT('%',#{dto.bookName},'%')
                  </if>
                  <if test="dto.userName != null and dto.userName != ''">
                    AND u.name LIKE CONCAT('%',#{dto.userName},'%')
                  </if>
                  <if test="dto.desc != null and dto.desc != ''">
                    AND b.desc LIKE CONCAT('%',#{dto.desc},'%')
                  </if>
                  <foreach collection="dto.types" item="type" nullable="true"
                           open=" AND type IN (" separator="," close=")">
                    #{type.value}
                  </foreach>
              </otherwise>
          </choose>
        </trim>
        LIMIT #{start},#{limit};
    </select>

只要指定相关拼接字符,foreach就可以自动生成合适的IN语句。

需要注意的是,常见的下边的写法是有问题的:

AND type IN
                  <foreach collection="dto.types" item="type" nullable="true"
                           open="(" separator="," close=")">
                    #{type.value}
                  </foreach>

如果dto.types不为空,可以正常执行。但如果为空列表,就会导致生成WHERE XXX=xxx AND type IN LIMIT ?,?这样的SQL。

bind

bind标签可以创建一个变量,并将其绑定到当前的上下文:

<otherwise>
                  <if test="dto.bookName != null and dto.bookName != ''">
                    <bind name="bookNameLike" value="'%'+dto.bookName+'%'"/>
                    AND b.name LIKE #{bookNameLike}
                  </if>
                  <!-- ... -->
              </otherwise>

前边说过,${}实际上是调用JDBC的SQL参数预处理进行参数替换,所以我们不能直接写LIKE %#{dto.bookName}%这样的语句,必须使用MySQL的字符串连接函数CONCAT。除此之外,还可以像上面那样使用bind标签,这里相当于用Java生成了一个bookNameLike变量,该变量的值是'%'+dto.bookName+'%'语句执行后的结果。然后可以将该变量用于参数替换LIKE #{bookNameLike}

当然这个示例并不比直接使用CONCAT函数方便和直观,但可以看到bind标签的强大,我们甚至可以在value属性中调用其它函数获取一个值来绑定。

谢谢阅读。