Spring Data JPA多表查询的几种方法

2019-06-285778
刘振杰
Java后台

Spring Data JPA多表查询的几种方法

前言

公司目前在ORM框架上选型只有两个选择

MyBatis-Plus

Spring Data JPA

相信很多人会选择MyBatis-Plus(MyBatis-Plus(简称 MP)是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生),主要因为JPA对多表复杂查询很不友好,特别是现在建表很多不用外键,所以很多人使用JPA查询就很不方便,但如果有人说:不行,我一定要用JPA,怎么办呢?所以本文就简单说明一下自己所知道的几种JPA的多表复杂查询方法。

讲解前先创建两张表一张student(学生)表和一张teacher(教师)表,对应两个实体类:

@Entity
@Data
@Accessors(chain = true)
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @ApiModelProperty("学生ID")
    @Column(insertable = false, updatable =false,columnDefinition = "INT UNSIGNED COMMENT '学生ID'")
    private Integer studentId;

    @ApiModelProperty("学生名")
    @Column(length = 50, columnDefinition = "VARCHAR(255) NOT NULL COMMENT '学生名'")
    private String studentName;

}
@Entity
@Data
@Accessors(chain = true)
public class Teacher {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @ApiModelProperty("教师ID")
    @Column(insertable = false, updatable = false, columnDefinition = "INT UNSIGNED COMMENT '教师ID'")
    private Integer teacherId;

    @ApiModelProperty("教师名")
    @Column(length = 50, columnDefinition = "VARCHAR(255) NOT NULL COMMENT '教师名'")
    private String teacherName;

}

两张表目前是没有什么关联的

一、单纯使用注解

相信大家去百度或者谷歌第一种推荐的就是使用自带的注解:

  • @OneToOne
单向一对一

如果一个学生对应一个老师,那么就需要在教师表或者学生表添加对应关联的ID,而使用注解就只需在学生表添加

@OneToOne
@JoinColumn(name = "teacher_id", referencedColumnName = "teacherId")
//设置生成的外键名称为teacher_id,对应Teacher类中的teacherId
private Teacher teacher;

自动创建表你会发现student表中会增加teacher_id的外键,如果不想自动创建外键可以在@JoinColumn中加入 foreignKey = @ForeignKey(name = "none", value = ConstraintMode.NO_CONSTRAINT),表示不创建外键。这时查询单独查Student也会查询到Teacher。

双向一对一

有时我想根据Teacher查询到对应的Student,那么需要在Teacher类中添加

@OneToOne(mappedBy = "teacher")//对应Student中的Teacher对象的名字
private Student student;

这样就可以两边都查询到对应的信息。

  • @OneToMany
  • @ManyToOne

一对多和多对一和一对一其实基本一样,只不过把对象换成List,查询时就能获取对应的集合,这里不多描述。

  • @ManyToMany

多对多其实和上面的也基本一样,只不过这是不能使用单个字段,需要创建一张中间表进行映射,需要加入把Student类中的teacher修改:

@ManyToMany
@JoinTable(name = "student_teacher_inner", //创建中间表student_teacher_inner
joinColumns = {@JoinColumn(name = "student_id", referencedColumnName = "studentId")},
inverseJoinColumns = {@JoinColumn(name = "teacher_id",referencedColumnName = "teacherId")})
private List<Teacher> teacher;

@JoinTable

joinColumns元素描述关系所有方在中间表的连接列,inverseJoinColumns元素指定了反方在中间表的连接列。

对于这种注解相信大家都很熟悉了,我这边就不仔细讲解了,主要注意的点有:

  • 自动建立外键

如果用这种方法查询就避免不了建外键,而外键主要是保证数据库数据的完整性和一致性,对于使用外键有好处也有坏处,主要坏处是操作数据方面有了很多的限制,增加了维护成本,需要看你怎么取舍,但很多项目都是业务进行控制数据库的完整性和一致性,不需要建立外键。

  • 不能分页查询

如果是一对多,多对多,那么只能一次查询全部对应的集合,不能使用分页。

  • 双向关联会出现死循环

例如你查询Student对象,哪里Student里有Teacher,而Teacher里又有Student,所以会一直循环下去,如果用toString方法或转成JSON那么是必然会造成死循环的,所以利用sping boot等框架将后台数据返回给前台也是同理,需要Student对象的teacher变量上加上注解:

@JsonIgnoreProperties(value = {"student"})

表明忽略字段名称student,同理Teacher对象也需要加上对应的注解

  • 运行时报错

No serializer found for class org.hibernate.proxy.pojo.javassist.JavassistLazyInitializer and no properties discovered to create BeanSerializer

需要在对象上加上

@JsonIgnoreProperties(value = {"handler", "hibernateLazyInitializer", "fieldHandler"})
  • 复杂查询需要额外使用Specification

如果两张表都是双向多对多关系,那么我想根据教师查出学生名字等于“学生3”的Student集合,这时就需要

Specification<Teacher> specification = Specification.where((root, query, cb) -> {
    Join<Teacher, student> join = root.join("student", JoinType.LEFT);
    return cb.equal(join.get("studentName"), "学生3");
});
List<Teacher> all = teacherDao.findAll(specification);

这里只写了实现代码,具体解析就不详细说明

二、使用@Query注解

如果觉得上面的方法很难用,而且没有分页查询,那么可以使用@Query注解查询,需要说明一下@Query默认使用hsql语言进行查询,如果不知道hsql是什么可以百度一下,两者最大的区别就是sql是使用表的字段名,而hsql是使用表的字段名,其他语法略有不同。

  • 现在学生教师一对一,在学生表手动创建一个teacher_id字段用来对应教师表,通过学生获取对应的教师
  • 创建一个StudentVO展示获取到的数据
@Data
@Accessors(chain = true)
public class StudentVO {

    @ApiModelProperty("学生ID")
    private Integer studentId;

    @ApiModelProperty("学生名")
    private String studentName;

    @ApiModelProperty("教师ID")
    private Integer teacherId;

    @ApiModelProperty("教师名称")
    private String teacherName;

    public StudentVO(Integer studentId, String studentName, Integer teacherId,        String teacherName) {
        this.studentId = studentId;
        this.studentName = studentName;
        this.teacherId = teacherId;
        this.teacherName = teacherName;
    }

    public StudentVO() {
    }

}
  • 通过在Dao编写方法获取到对应的值
//没有构造函数会报Unable to locate appropriate constructor on class
@Query(value = "select new com.luwei.model.student.StudentVO(s.studentId,s.studentName ,t.teacherId ,t.teacherName) " +
"from Student s left join Teacher t  on s.teacherId=t.teacherId")
Page<StudentVO> findCustom(Pageable pageable);

这样很简单就能进行联合查询,如果里想用传统的sql语句可以把nativeQuery 改为true表示使用传统sql语句,需要注意的是分页sql语句需要自己实现

//不用驼峰,不然会报找不到列名
@Query(value = "select s.student_id ,s.student_name,t.teacher_id " +
"from tb_student s left join tb_teacher t  on s.teacher_id = t.teacher_id ",
countQuery = "SELECT COUNT(*) FROM tb_student", nativeQuery = true)
Page<Student> findCustom(Pageable pageable);

运用@Query也很容易根据自己的sql语句查询到对应的字段,但有唯一的缺点,不能动态查询,也是致命的缺点,不能像mybatis哪有可以根据你的参数是否为空来进行动态添加,对于参数不确定时需要编写不同的方法,所以如果需要动态查询的不适用使用该注解。那么有没有一种可以动态查询的方法了?答案是有的。

三、使用EntityManager

EntityManager是JPA中用于增删改查的接口,它的作用相当于一座桥梁,连接内存中的java对象和数据库的数据存储。那么如何获得EntityManager对象呢?这取决于你的EntityManger对象的托管方式,主要有以下两种方式:

  • 容器托管的EntityManager对象

容器托管的EntityManager对象最为简单,编程人员不需要考虑EntityManger的连接,释放以及复杂的事务问题等等,所有这些都交给容器来完成。

  • 应用托管的EntityManager对象

应用托管的EntityManager对象,编程人员需要手动地控制它的释放和连接、手动地控制事务等。

我这边使用容器托管,获取EntityManager对象如下:

@PersistenceContext
private EntityManager entityManager;

现在我获取学生名字带有小明的StudentVO的信息就需要这样编写代码:

//需要无参构造方法,编写sql语句
StringBuilder sql = new StringBuilder("select s.student_id StudentId,s.student_name StudentName,t.teacher_id teacherId,t.teacher_name teacherName " +
"from tb_student s left join tb_teacher t  on s.teacher_id = t.teacher_id where 1=1 ");
//查询参数
String studentName = "小明";
if (!TextUtils.isEmpty(studentName)) {
    sql.append(" and s.student_name = ? ");
}

//添加普通sql查询
NativeQueryImpl sqlQuery = entityManager.createNativeQuery(sql).unwrap(NativeQueryImpl.class);

//映射返回类
Query query = sqlQuery.setResultTransformer(Transformers.aliasToBean(StudentVO.class));
//添加查询参数
query.setParameter(1, studentName);

//执行
List<StudentVO> list = query.getResultList();

这样就可以实现查询,如果有动态条件只需拼接好sql就可以了,但这个方法还有一个缺点,就是不能自动帮我们分页,需要我们自己拼接分页条件。有的人就可能会说了,这样每次都需要拼接是不是很麻烦啊,但如果你把所有逻辑抽成一个方法,就会发现其实感觉还可以。

示例

下面示范下分页查询如果学生名字带有小明的学生和教师的信息,返回依然时StudentVO。准备一下抽出来的方法:

  • 把基本查询sql语句转为查询总数的sql语句的方法

其实我们用page,看日志就会发现,每次分页查询都是执行两条sql语句,一条是查询总数的sql,一条原来的sql,是因为框架帮我们把原来的sql语句转成可以查询总数的sql语句,我们也写一个类似的方法

/**
 * 转为查询总数的sql语句
 *
 * @param sql 原来的sql语句
*/
private static String getCountSql(String sql) {
    String substring = null, replace = null;
    if (sql.toLowerCase().startsWith("select")) {
        int from = sql.lastIndexOf("FROM");
        if (from != -1) {
            substring = sql.substring(6, from);

        } else if ((from = sql.lastIndexOf("from")) != -1) {
            substring = sql.substring(6, from);
        }
    }
    if (substring != null) {
        replace = sql.replace(substring, " count(*) ");
    }
    return replace;
}

这方法只是简单的转换,找到select后把后面的参数都换为count(*),这其实并不严谨,仅供参考

  • 拼接分页条件方法

原来的sql语句也需要拼接limt 关键字,

/**
 * 拼接分页字符串
 *
 * @param stringBuilder
 * @param page 多少页
 * @param size 每页多少个
 */
public static StringBuilder createLimit(StringBuilder stringBuilder, Integer page, Integer size) {
    return stringBuilder.append(" LIMIT ").append(page * size).append(" , ").append(size);
}
  • 获取总页数的方法
/**
 * @param total 总数
 * @param size  每页多少个
 * @return 一共有多少页
 */
public static int getTotalPages(int total, int size) {
    return (total + size) / size;
}
  • 查询方法

最后还需把上面这些方法抽成一个查询方法中

 /***
     *
     * @param sql 查询的原sql语句
     * @param page 分页
     * @param clazz 返回的对象
     * @param conditionList where条件集合
     * @param <T>
     * @return 返回page
     */
    private <T> Page<T> findPage(StringBuilder sql, Pageable page, T clazz, List<String> conditionList) {
        //原sql转换查询总数sql语句
        String countSql = getCountSql(sql.toString());
        Query countQuery = entityManager.createNativeQuery(countSql);
        //原sql语句添加limit
        StringBuilder limit = createLimit(sql, page.getPageNumber(), page.getPageSize());
        NativeQueryImpl sqlQuery = entityManager.createNativeQuery(limit.toString()).unwrap(NativeQueryImpl.class);
        Query nativeQuery = sqlQuery.setResultTransformer(Transformers.aliasToBean(clazz.getClass()));

        //添加where条件
        for (int i = 1; i <= conditionList.size(); i++) {
            countQuery.setParameter(i, conditionList.get(i - 1));
            nativeQuery.setParameter(i, conditionList.get(i - 1));
        }
        //查询总数
        int total = ((BigInteger) countQuery.getResultList().get(0)).intValue();
        //原sql查询到内容
        List<T> list = nativeQuery.getResultList();
        //设置分页信息
        return new PageImpl<>(list, page, getTotalPages(total, page.getPageSize()));
    }

最后调用findPage查询方法,就能获取到对应的参数了

//分页参数
Pageable pageRequest = PageRequest.of(1, 2);
//查询的sql语句
StringBuilder sql = new StringBuilder("select s.student_id StudentId,s.student_name StudentName,t.teacher_id teacherId,t.teacher_name teacherName " +
        "from tb_student s left join tb_teacher t  on s.teacher_id = t.teacher_id where 1=1 ");
String studentName = "小明";
//where条件的list
List<String> conditionList = new ArrayList<>();
//模拟是否需要添加条件
if (!TextUtils.isEmpty(studentName)) {
    sql.append(" and s.student_name = ? ");
    conditionList.add(studentName);
}
Page<StudentVO> page = findPage(sql, pageRequest, new StudentVO(), conditionList);

四、总结

总的来说查询有三种方法:

  • 单纯使用注解

比较简单,一般需要创建外键,如果不是多对多也可以不创,不能分页查询,如果只是简单一对一可以使用,其他情况复杂不推荐。

  • 使用@Query注解

也是比较简单,可以帮你实现分页逻辑,但不能动态条件参数查询,不适合查询条件参数不确定的情况,如果查询条件确定,那么是推荐使用这种。

  • 使用EntityManager

用起来相当复杂,需要自己拼接查询条件和分页条件,但如果抽成方法用起来还是蛮舒服的,所以最终还是推荐使用EntityManager进行多表复杂查询。

分享
点赞6
打赏
上一篇:Docker常用命令笔记(一)
下一篇:if(condition){} else{}太low了,别人都用策略模式