MyBatis - 高级查询

当使用 MyBatis 进行对象关系映射(ORM)时,我们经常需要处理一对一映射、一对多映射和多对多映射的关系。同时还可能遇到需要进行自定义类型映射和分页查询的场景。

注意:为了节约篇幅,这里直接给出处理方案,不再进行完整程序的演示。

1.一对一映射

一对一映射指的是两个实体之间的关系,其中一个实体与另一个实体关联,每个实体实例只能关联一个对应的实体实例。假设有两个实体类 UserAccount,每个 User 有一个 Account,即一对一的关系。

如果是基于 XML 映射文件的方式,我们可以通过 ResultMap + association 标签来实现:

<!-- UserMapper.xml -->
<select id="getUser" resultMap="userAccountMap">
  SELECT u.*, a.* FROM user u LEFT JOIN account a ON u.id = a.user_id WHERE u.id = #{id}
</select>

<resultMap id="userAccountMap" type="User">
  <id property="id" column="id"/>
  <result property="username" column="username"/>
  <result property="password" column="password"/>
  <association property="account" javaType="Account">
    <id property="id" column="account_id"/>
    <result property="userId" column="user_id"/>
  </association>
</resultMap>

这里的 <association> 标签就是一对一的映射关系,通过 javaType 属性指定类型,将查询结果映射到 Useraccount 属性上。

如果是基于注解则对应如下:

// UserMapper.java
@Select("SELECT u.*, a.* FROM user u LEFT JOIN account a ON u.id = a.user_id WHERE u.id = #{id}")
@Results({
    
    
  @Result(id = true, property = "id", column = "id"),
  @Result(property = "username", column = "username"),
  @Result(property = "password", column = "password"),
  @Result(property = "account", column = "account_id", javaType = Account.class,
          one = @One(select = "cn.javgo.mapper.AccountMapper.selectAccount"))
})
User selectUser(Integer id);

这里的 @One 注解表示一对一的映射关系,可以看到 @One 中给定的是具体获取方法的全限定类名加方法名,会根据 Useraccount_id 字段作为参数查询对应的 Account

2.一对多映射

一对多映射指的是两个实体之间的关系,其中一个实体与多个另一个实体关联,而多个另一个实体实例只能关联一个实体实例。假设有两个实体类 ClassStudent,一个 Class 有多个 Student,即一对多的关系。

如果是基于 XML 映射文件的方式,处理一对多关系,我们可以使用 ResultMap + collection 标签来实现:

<!-- ClassMapper.xml -->
<select id="getClass" resultMap="classStudentMap">
  SELECT c.*, s.* FROM class c LEFT JOIN student s ON c.id = s.class_id WHERE c.id = #{id}
</select>

<resultMap id="classStudentMap" type="Class">
  <id property="id" column="id"/>
  <result property="name" column="name"/>
  <collection property="students" ofType="Student">
    <id property="id" column="student_id"/>
    <result property="name" column="student_name"/>
  </collection>
</resultMap>

这里的 <collection> 标签就是一对多的映射关系,将查询结果映射到 Classstudents 属性上。

如果是基于注解则对应如下:

// ClassMapper.java
@Select("SELECT c.*, s.* FROM class c LEFT JOIN student s ON c.id = s.class_id WHERE c.id = #{id}")
@Results({
    
    
  @Result(id = true, property = "id", column = "id"),
  @Result(property = "name", column = "name"),
  @Result(property = "students", column = "id", javaType = List.class,
          many = @Many(select = "cn.javgo.StudentMapper.selectStudentsByClassId"))
})
Class selectClass(Integer id);

这里的 @Many 注解表示一对多的映射关系,可以看到 @Many 中给定的是具体获取方法的全限定类名加方法名,会根据 Classid 字段作为参数查询对应的所有 Student

3.多对多映射

多对多映射指的是两个实体之间的关系,其中一个实体与多个另一个实体关联,多个另一个实体实例也可以关联多个实体实例。例如,学生表(Student)和课程表(Course),一个学生可以选修多门课程,一门课程也可以被多个学生选修。这其实与上述的一对多关系本质上是一样的,因此处理方案也相同。

以下是基于 XML 的示例:

<select id="getStudent" resultMap="studentCourseResult">
    SELECT * FROM Student s LEFT JOIN StudentCourseRelation scr ON s.id = scr.student_id 
        LEFT JOIN Course c ON scr.course_id = c.id WHERE s.id = #{id}
</select>

<resultMap id="studentCourseResult" type="Student">
    <id property="id" column="id" />
    <result property="name" column="name" />
    <collection property="courses" ofType="Course">
        <id property="id" column="course_id" />
        <result property="courseName" column="course_name" />
    </collection>
</resultMap>

以下是基于注解的示例:

public interface StudentMapper {
    
    
    @Select("SELECT * FROM Student s LEFT JOIN StudentCourseRelation scr ON s.id = scr.student_id LEFT JOIN Course c ON scr.course_id = c.id WHERE s.id = #{id}")
    @Results({
    
    
        @Result(id = true, property = "id", column = "id"),
        @Result(property = "name", column = "name"),
        @Result(property = "courses", column = "student_id", javaType = List.class,
                many = @Many(select = "cn.javgo.mapper.CourseMapper.selectByStudentId"))
    })
    Student getStudent(Long id);
}

4.自定义类型映射

MyBatis 允许你在几乎任何时候都使用自定义的 TypeHandler 来处理 SQL 语句的参数绑定以及结果映射。如果你有一个特定的数据类型需要做一些特殊的处理,你可以编写自定义的 TypeHandler

4.1 枚举类型案例

首先,需要实现 TypeHandlerBaseTypeHandler 抽象类。例如,有一个枚举类型 State,包含了 ACTIVEINACTIVE 两个状态:

/**
 * 状态枚举
 */
public enum State {
    
    
    ACTIVE(1, "激活"),
    INACTIVE(0, "未激活");

    private Integer code;

    private String desc;

    State(Integer code, String desc) {
    
    
        this.code = code;
        this.desc = desc;
    }

    public Integer getCode() {
    
    
        return code;
    }

    public String getDesc() {
    
    
        return desc;
    }
    
    public static State getByCode(Integer code) {
    
    
        for (State state : State.values()) {
    
    
            if (state.getCode().equals(code)) {
    
    
                return state;
            }
        }
        return null;
    }
}

但在数据库中,我们希望它们分别保存为 1 和 0,可以定义一个自定义的类型处理器重写该类的四个方法:

/**
 * 自定义枚举类型转换器({@link State} -> {@link Integer})
 */
public class StateTypeHandler extends BaseTypeHandler<State> {
    
    
    /**
     * 用于定义设置参数时,该如何把Java类型的参数转换为对应的数据库类型( Java -> DB )
     * @param preparedStatement 用于设置参数的PreparedStatement对象
     * @param i 参数的位置
     * @param state 参数的值
     * @param jdbcType JDBC类型
     * @throws SQLException 数据库异常
     */
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, State state, JdbcType jdbcType) throws SQLException {
    
    
        preparedStatement.setInt(i, state.getCode());
    }

    /**
     * 用于定义通过字段名称获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param resultSet 结果集
     * @param s 字段名称
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public State getNullableResult(ResultSet resultSet, String s) throws SQLException {
    
    
        int code = resultSet.getInt(s);
        return State.getByCode(code);
    }

    /**
     * 用于定义通过字段索引获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param resultSet 结果集
     * @param i 字段索引
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public State getNullableResult(ResultSet resultSet, int i) throws SQLException {
    
    
        int code = resultSet.getInt(i);
        return State.getByCode(code);
    }

    /**
     * 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param callableStatement CallableStatement对象
     * @param i 字段索引
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public State getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
    
    
        int code = callableStatement.getInt(i);
        return State.getByCode(code);
    }
}

然后,在 Spring Boot 的配置文件中配置 TypeHandler 所在的包路径:

mybatis:
  type-handlers-package: cn.javgo.learningmybatis.support.handler

最后,在 mapper XML 文件中,你就可以直接使用 State 类型了:

<select id="selectByState" parameterType="cn.javgo.learningmybatis.enums.State" resultType="User">
  SELECT * FROM User WHERE state = #{state}
</select>

在注解方式中,你可以直接在 @Results 注解中使用 @Result 注解的 typeHandler 属性来指定 TypeHandler

public interface UserMapper {
    
    

    @Select("SELECT * FROM User WHERE state = #{state}")
    @Results({
    
    
        @Result(id = true, property = "id", column = "id"),
        @Result(property = "username", column = "username"),
        @Result(property = "state", column = "state", javaType = State.class, typeHandler = StateTypeHandler.class)
    })
    List<User> selectByState(@Param("state") State state);
}

这样,当查询 User 并将结果映射到 User 对象时,state 字段将使用我们自定义的 StateTypeHandler 来处理。

TIP:

MyBatis 为 Java 枚举类型的处理提供了两种方式:EnumTypeHandlerEnumOrdinalTypeHandler

  • EnumTypeHandler:默认枚举处理器,它将枚举的名称(如 ACTIVE 或 INACTIVE)保存到数据库中。
  • EnumOrdinalTypeHandler:它将枚举的顺序(ordinal)保存到数据库中,如 1 或 0。

因此,我们其实可以省略上述的操作,直接使用现成的这两个处理器就可以实现枚举相关的操作了。

4.2 货币类型案例

在实际场景中,我们只有在 MyBatis 没有提供合适的内置 TypeHandler 时,才自定义自己的类型处理器。一个常见的例子就是讲 Money 类型的属性值和 Long 类型之间的转换,因为金额一般我们都是用 Money 类来表示,但是在数据库中一般却以分为单位进行存储,在取出时则以人民币为币种还原为 Money

这里需要使用一个货币相关的类库,Joda-Money 是一个开源的 Java 库,旨在提供强大而灵活的处理货币和货币金额的功能。它是 Joda-Time 日期和时间库的姊妹项目,专门用于处理货币价值和货币操作。Joda-Money 提供了一组类和方法,使您能够进行精确的货币计算和处理。

需要使用该类库需要添加如下依赖:

<!-- Joda Money -->
<dependency>
    <groupId>org.joda</groupId>
    <artifactId>joda-money</artifactId>
    <version>1.0.1</version>   <!--请根据实际情况选择版本号-->
</dependency>

处理 Money 类型的 MoneyTypeHandler 代码如下:

/**
 * 自定义类型转换器({@link Money} -> {@link Long})
 */
public class MoneyTypeHandler extends BaseTypeHandler<Money> {
    
    

    /**
     * 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param value 数据库中的数据
     * @return 转换后的Java对象
     */
    private Money parseMoney(Long value) {
    
    
        // 创建Money对象(货币单位为分,货币类型为人民币)
        return Money.ofMinor(CurrencyUnit.of("CNY"), value);
    }

    /**
     * 用于定义设置参数时,该如何把Java类型的参数转换为对应的数据库类型( Java -> DB )
     * @param preparedStatement 用于设置参数的PreparedStatement对象
     * @param i 参数的位置
     * @param money 参数的值
     * @param jdbcType JDBC类型
     * @throws SQLException 数据库异常
     */
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, Money money, JdbcType jdbcType) throws SQLException {
    
    
        // 获取金额(分),并设置到PreparedStatement对象中
        preparedStatement.setLong(i, money.getAmountMinorLong());
    }

    /**
     * 用于定义通过字段名称获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param resultSet 结果集
     * @param s 字段名称
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public Money getNullableResult(ResultSet resultSet, String s) throws SQLException {
    
    
        return parseMoney(resultSet.getLong(s));
    }

    /**
     * 用于定义通过字段索引获取字段数据时,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param resultSet 结果集
     * @param i 字段索引
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public Money getNullableResult(ResultSet resultSet, int i) throws SQLException {
    
    
        return parseMoney(resultSet.getLong(i));
    }

    /**
     * 用定义调用存储过程后,如何把数据库类型转换为对应的Java类型( DB -> Java )
     * @param callableStatement CallableStatement对象
     * @param i 字段索引
     * @return 转换后的Java对象
     * @throws SQLException 数据库异常
     */
    @Override
    public Money getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
    
    
        return parseMoney(callableStatement.getLong(i));
    }
}

5.分页插件

PageHelper 是一个简单且易用的 MyBatis 分页插件。它的设计思想是只对紧跟在 PageHelper.startPage 方法后的第一个 MyBatis 查询方法进行分页。这就意味着如果再次调用查询方法,它就会返回所有的记录,而不是一个分页结果。PageHelper-Spring-Boot-Starter 是 PageHelper 与 Spring Boot 的集成。

PageHelper 官方 GItHub 地址:https://github.com/pagehelper/Mybatis-PageHelper

Spring Boot Starter GItHub 地址:https://github.com/pagehelper/pagehelper-spring-boot

首先,你需要在项目的 pom.xml 文件中添加 pagehelper-spring-boot-starter 的依赖:

<!--MyBatis分页插件-->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>${pagehelper-starter.version}</version>
</dependency>

注意:上面的 ${pagehelper-starter.version} 需要根据实际需求选择对应的版本。

然后在 Spring Boot 的配置文件中进行 PageHelper 相关的基本配置:

# PageHelper 分页插件配置
pagehelper:
  # 配置数据库方言
  helper-dialect: mysql
  # 配置分页合理化参数
  reasonable: true
  # 配置支持通过 Mapper 接口参数来传递分页参数
  support-methods-arguments: true
  # 配置参数映射,即从 Map 中根据指定的名字取值,用于从 Map 中取值时的 key
  params: count=countSql
  # 如果 pageSize=0 或者 RowBounds.limit = 0 就会查询出全部的结果
  page-size-zero: true

常用的配置项如下:

配置项 说明
pagehelper.helper-dialect 配置数据库的方言
pagehelper.page-size-zero 如果 pageSize=0 或者 RowBounds.limit = 0 就会查询出全部的结果
pagehelper.reasonable 配置分页合理化参数,默认值为 false。当该参数设置为 true 时,pageNum<=0 会查询第一页,pageNum>pages(超过总页数时)会查询最后一页。默认 false 时,直接根据参数进行查询。
pagehelper.support-methods-arguments 支持通过 Mapper 接口方法参数来传递分页参数,默认值false,分页插件会从查询方法的参数值中,自动根据上面 params 配置的字段中取值,查找到合适的值时就会进行分页。
pagehelper.params 为了支持 startPage(Object params) 方法,增加了该配置来配置参数映射,用于从对象中根据属性名取值(一般为 Map 中根据指定的名字取值,用于从 Map 中取值时的 key),可以配置 pageNum,pageSize,count,pageSizeZero,reasonable,不配置映射的用法可以参考 startPage(Object params) 方法的示例。

然后,在 Mapper 接口中,你可以直接进行分页查询:

@Mapper
public interface UserMapper {
    
    
    @Select("select * from user")
    List<User> selectAll();
}

在 Service 层,你可以通过调用 PageHelper.startPage 方法实现分页:

@Service
public class UserService {
    
    
    @Autowired
    private UserMapper userMapper;

    /**
     * 查询所有用户(分页)
     * @param pageNum 当前页码
     * @param pageSize 每页大小
     * @return PageInfo<User>
     */
    public PageInfo<User> selectAll(int pageNum, int pageSize) {
    
    
        // 开启分页(将会自动拦截到下面这查询sql)
        PageHelper.startPage(pageNum, pageSize);

        // 执行查询
        List<User> users = userMapper.selectAll();

        // 封装为PageInfo对象
        PageInfo<User> pageInfo = new PageInfo<>(users);

        // 返回
        return pageInfo;
    }
}

在这个示例中,我们首先调用了 PageHelper.startPage 方法,然后调用了 userMapper.selectAll 方法。PageHelper 会对这个方法进行分页,然后我们将结果包装成 PageInfo 对象并返回。

PageInfo 是一个包含了分页信息的对象,包括当前页码、每页的数量、总记录数、总页数、是否为第一页、是否为最后一页、是否有前一页、是否有下一页等。

PageHelper 底层实现分页的原理:

PageHelper 插件的实现是基于 MyBatis 的拦截器接口 Interceptor。它会对执行 SQL 操作的 StatementHandler 进行拦截。在进行 SQL 查询之前,插件会改写要执行的 SQL,加入对应数据库的分页查询语句(即 limit 条件),从而实现物理分页的效果。具体来说,使用 PageHelper 插件时,当调用 PageHelper.startPage(pageNum, pageSize) 方法后,会创建一个 Page 对象并保存到本地线程变量 ThreadLocal 中,因此操作需要在一个线程中。在执行查询之前,会取出 Page 对象的信息,并利用这些信息改写 SQL。在查询完成后,将分页信息清空。

方法源码如下:

/**
 * 分页查询
 * @param pageNum 当前页
 * @param pageSize 每页大小
 * @return 一个经过分页后的 Page 对象
 */
public static <E> Page<E> startPage(int pageNum, int pageSize) {
     
     
    // 调用下面的 startPage 方法,传入 pageNum、pageSize、DEFAULT_COUNT 参数,返回一个 Page<E> 对象
    return startPage(pageNum, pageSize, DEFAULT_COUNT);
}
/**
 * 真正的分页方法
 * @param pageNum 当前页
 * @param pageSize 每页大小
 * @param count 数据表中的记录总数,用于计算分页的页数和当前页的数据数量,以便于实现分页功能
 * @return 一个经过分页后的 Page 对象
 */
public static <E> Page<E> startPage(int pageNum, int pageSize, int count) {
     
     
    // 创建 Page 对象
    Page<E> page = new Page<>(pageNum, pageSize, count);
    // 将 Page 对象设置到 ThreadLocal 中,方便在同一线程的任意地方获得 Page 对象
    PAGE_LOCAL.set(page);
    // 返回 Page 对象
    return page;
}

当然,MyBatis 本身其实也提供了 RowBounds 对象和在 Mapper 方法参数中指定分页信息的方式进行分页。RowBounds 是 MyBatis 提供的一个用于物理分页的类,它有两个重要的属性,offsetlimit。其中 offset 表示开始读取的位置(偏移量),limit 表示读取的数量。例如,new RowBounds(10, 20) 表示从第10条记录开始,读取20条记录。

注意:当我们配置了 pagehelper.offset-as-page-num=true 后会将 offset 当成 pageNum 页码使用,第二个参数 limitpageSize 参数。

使用 RowBounds 对象的例子:

@Select("select * from user")
List<User> selectAllByRowBounds(RowBounds rowBounds);

在方法参数中指定分页信息的例子:

/**
 * 查询所有用户(分页)
 * @param pageNum 当前页码
 * @param pageSize 每页大小
 * @return PageInfo<User>
 */
public PageInfo<User> selectAllByRowBounds(int pageNum, int pageSize) {
    
    
    // 执行查询
    List<User> users = userMapper.selectAllByRowBounds(new RowBounds(pageNum, pageSize));

    // 封装为PageInfo对象
    PageInfo<User> pageInfo = new PageInfo<>(users);

    // 返回
    return pageInfo;
}

PageHelper VS RowBounds:

  1. PageHelper 是基于 MyBatis 插件实现的,只需要在查询前调用 PageHelper.startPage 方法即可实现分页,无需修改原有的 SQL。它会自动识别数据库类型,并生成对应的分页 SQL。PageHelper 只返回需要的记录,不必查询所有数据,性能较好。
  2. RowBounds 是 MyBatis 提供的一个用于分页的对象,通过查询所有数据后,再返回指定范围的记录实现的,所以在数据量较大时,性能较差。

因此,大多数情况下,推荐使用 PageHelper 插件进行分页,不仅可以减少代码的复杂度,而且更加易用。

猜你喜欢

转载自blog.csdn.net/ly1347889755/article/details/130995163