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进行多表复杂查询。