[spring] Spring JPA - Hibernate 多表联查
之前在 [spring] spring jpa - hibernate 名词解释&配置 和 [spring] spring jpa - hibernate CRUD 简单的学习了一下怎么使用 Hibernate 实现 CRUD 操作,不过涉及到的部分都是逻辑上比较简单的实现——只在一张表上进行操作
关于多表关联的部分,只是在关键字中提到了有提到过 Relationship 的注解,之后这两章内容就会对 Relationship 进行学习
基础概念
这里回顾一下数据库的基础概念,具体的使用方式在后面的笔记中会被提到,所以这里也就不 cv 代码了
-
主键(Primary Key)& 外键(Foreign Key)
主键是一个表中的一个或多个列,能够表示当前数据在当前表中的唯一性,每个表只能有一个主键
比较常见用一个列作为主键的情况有学生 id,课程 id,书籍 id,图书馆 id 等
用多个列作为主键的情况,一般代表单独的一个列无法完整表达对应信息,如学生选修的课程,这种情况下主键可以使用学生 id+课程 id+年份,或者图书的出借记录,可以使用学生 id+书籍 id+出借日期等
外键用于引用另一张表的主键,如上面多个列作为主键的情况中,学生 id、书籍 id 都是外键
-
级联(Cascade)
Cascade 是一种为了维护数据库完整性的机制,这里不会过多的设计到数据库的 Cascade,而是提一下 hibernate 中的 Cascade 类型,也就是
CascadeType
:-
PERSIST
代表当持久化一个实体时,其关联的实体也会被实体化 -
MERGE
代表当更新一个实体时,其关联的实体也会被更新准确的说是
merge
操作,不过大多数情况下merge
操作被用来当做更新 -
REMOVE
代表当删除一个实体时,其关联的实体也会被删除 -
REFRESH
代表当更新一个实体时,其关联的实体也会被更新 -
DETACH
代表当更新一个分离时,其关联的实体也会被分离这个主要代表当前的 entity 不再被持久层所管理
-
ALL
代表上述所有的操作都会被执行
默认情况下,hibernate 不会 cascade 任何操作
-
-
数据加载——eager & lazy
这代表的是两种数据的获取方式,
eager
代表当 entity 被获取时,其关联的所有实体也会被同时获取 -
关系
-
one to one
即一对一的关系,比如说 国家 和 首都,用户 和 用户信息 这种都是比较常见的一对一的关系
-
one to many
一对多,也是多对一的关系,比较常见的有 作者 和 书籍,学生 和 学校
-
many to many
多对多的关系,这应该是最常见的情况了,比如说 作者 和 出版商,学生 和 课
需要注意的是,不同的关系在不同的系统中都会有些微的差异,并非不可改变。比如说以电话系统为例,虽然日常生活中,常见的案例为 一号一人,设计上可能会偏好将 人 和 电话号码 以 一对多 的关系进行构建。但是在 ToB 的业务中就需要考虑到公司号码其实会被分拨到不同的客服手上的情况,相对于 ToC 端 one-to-many 的设计,toB 端可能就要进行 many-to-many 的设计修正
-
-
实体生命周期
hibernate session 中的生命周期有 4 个:
-
transient
new instance,如果没有引用就会被 GC 回收
-
persist
与 persistence context 关联,这个情况下,hibernate 会对数据的更新进行管理和同步
-
detached
hibernate 不再对数据进行管理
-
removed
准备彻底被删除
geeksforgeeks 上找到了个图描述了一下生命周期:
[外链图片转存中…(img-U6GRZ8j5-1742269905179)]
整体来说这个部分还是比较复杂的,简单的几句话很难描述整个生命周期的流程,图里也缺少了一些必要的 op,比如
rollback
之类的,有空再补充一下 hibernate 好了 -
这章笔记主要过一遍 1-1 的数据关联,以及原始笔记是很久之前写的了,我重新走了一下流程补充了点内容,所以项目名称会有点差异……
one-to-one uni-relational
uni-relational 的关系如上面图所示,即 A 能够找到 B 的关联,但是 B 无法回溯到 A
设置数据库
脚本如下:
DROP SCHEMA IF EXISTS `hb-01-one-to-one-uni`;
CREATE SCHEMA `hb-01-one-to-one-uni`;
use `hb-01-one-to-one-uni`;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE `instructor_detail` (
`id` int NOT NULL AUTO_INCREMENT,
`youtube_channel` varchar(128) DEFAULT NULL,
`hobby` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;
CREATE TABLE `instructor` (
`id` int NOT NULL AUTO_INCREMENT,
`first_name` varchar(45) DEFAULT NULL,
`last_name` varchar(45) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`instructor_detail_id` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `FK_DETAIL_idx` (`instructor_detail_id`),
CONSTRAINT `FK_DETAIL` FOREIGN KEY (`instructor_detail_id`) REFERENCES `instructor_detail` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;
SET FOREIGN_KEY_CHECKS = 1;
ER 图如下:
ER 图是 dbeaver(有免费版)根据对应的数据库自动生成的,有图就可以证明 ER 图生成的没问题了
配置项目
新建项目
依旧用 spring initializer 实现:
main 文件如下:
package com.example.demo;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(String[] args) {
return runner -> {
System.out.println("Hello World!");
};
}
}
这样就创建了一个终端 app
properties 文件修改
如下:
# JDBC properties
spring.datasource.url=jdbc:mysql://localhost:3306/hb-01-one-to-one-uni
spring.datasource.username=springstudent
spring.datasource.password=springstudent
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
# Disable Hibernate usage of JDBC metadata
# not having this can resolve error
spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false
# turn of the banner and lower the logging level
spring.main.banner-mode=off
logging.level.root=warn
运行结果如下:
2025-03-17T17:58:18.452-04:00 WARN 62459 --- [demo-one-to-one-uni] [ main] org.hibernate.orm.deprecation : HHH90000025: MySQLDialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default)
Hello World!
Process finished with exit code 0
创建 entity
主要就是两个,InstructorDetail
对应 instructor_detail
, Instructor
对应 instructor
-
instructor details
package com.example.demo.entity; import jakarta.persistence.*; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; @Entity @Table(name = "instructor_detail") @Data @NoArgsConstructor @ToString public class InstructorDetail { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private int id; @Column(name = "youtube_channel") private String youtubeChannel; @Column(name = "hobby") private String hobby; public InstructorDetail(String youtubeChannel, String hobby) { this.youtubeChannel = youtubeChannel; this.hobby = hobby; } }
-
instructor
package com.example.demo.entity; import jakarta.persistence.*; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; @Entity @Table(name = "instructor") @Data @NoArgsConstructor @ToString public class Instructor { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private int id; @Column(name = "first_name") private String firstname; @Column(name = "last_name") private String lastname; @Column(name = "email") private String email; // set up mapping to InstructorDetail @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "instructor_detail_id") private InstructorDetail instructorDetail; public Instructor(String firstname, String lastname, String email) { this.firstname = firstname; this.lastname = lastname; this.email = email; } }
基本上没什么新的东西,除了下面这段:
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "instructor_detail_id")
private InstructorDetail instructorDetail;
具体内容最后补充
DAO & DAOimpl
这个的实现就比较简单了,因为只是做 demo,所以没打算分成几个文件去写,所有数据库的操作都会放在这里,并且调用 entityManager
中的方法去实现,而不会用 extend JpaRepository
的方法去实现
-
AppDAO
package com.example.demo.dao; import com.example.demo.entity.Instructor; public interface AppDAO { void save (Instructor instructor); }
-
AppDAOImpl
package com.example.demo.dao; import com.example.demo.entity.Instructor; import jakarta.persistence.EntityManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; @Repository public class AppDAOImpl implements AppDAO{ private final EntityManager entityManager; @Autowired public AppDAOImpl(EntityManager entityManager) { this.entityManager = entityManager; } @Override @Transactional public void save(Instructor instructor) { // it will also save instructor detail due to cascade entityManager.persist(instructor); } }
⚠️:这个
@Transactional
导入 spring 的就好,导入 jakara 的话,spring boot 就不会管理了
更新一下 main
主要是增加一下 logging 以及输出结果
package com.example.demo;
import com.example.demo.dao.AppDAO;
import com.example.demo.entity.Instructor;
import com.example.demo.entity.InstructorDetail;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public CommandLineRunner commandLineRunner(AppDAO appDAO) {
return runner -> {
createInstructor(appDAO);
};
}
private void createInstructor(AppDAO appDAO) {
// create the instructor
Instructor instructor = new Instructor("John", "Doe", "johndoe@gmail.com");
InstructorDetail instructorDetail = new InstructorDetail("http://www.example.com", "Coding");
// associate the objects
instructor.setInstructorDetail(instructorDetail);
// NOTE: this will ALSO save the details object because of CascadeType.ALL
System.out.println("Saving instructor: " + instructor);
appDAO.save(instructor);
System.out.println("Done!");
}
}
运行结果:
可以看到,这里具体执行了 2 条 queries,一个是写入 instructor
,另一个是写入 instructor_details
数据库截图:
查询实例
这个操作很简单,主要修改三个文件:
-
DAO
public interface AppDAO { Instructor findInstructorById(int id); }
-
DAOImpl
@Repository public class AppDAOImpl implements AppDAO{ @Override public Instructor findInstructorById(int id) { return entityManager.find(Instructor.class, id); } }
-
main
@Bean public CommandLineRunner commandLineRunner(AppDAO appDAO) { return runner -> { findInstructor(appDAO); }; } private void findInstructor(AppDAO appDAO) { int id = 1; System.out.println("Finding instructor id: " + id); Instructor instructor = appDAO.findInstructorById(id); System.out.println("Instructor: " + instructor); System.out.println("Associated Instructor Details: " + instructor.getInstructorDetail()); }
效果如下:
删除实例
也是一样的操作,修改 3 个文件:
-
DAO
public interface AppDAO { void deleteInstructorById(int id); }
-
DAOImpl
@Override @Transactional public void deleteInstructorById(int id) { Instructor instructor = this.findInstructorById(id); if (instructor != null) { entityManager.remove(instructor); } }
-
main
@Bean public CommandLineRunner commandLineRunner(AppDAO appDAO) { return runner -> { deleteInstructor(appDAO); }; } private void deleteInstructor(AppDAO appDAO) { int id = 2; System.out.println("Deleting instructor id: " + id); appDAO.deleteInstructorById(id); System.out.println("Done!"); }
结果如下:
one-to-one bi-directional
这个修改其实没必要动数据库,等到之后捋一遍就明白为什么了
更新查询实例
public class InstructorDetail {
// ...
// updated code
@OneToOne(mappedBy = "instructorDetails", cascade = CascadeType.ALL)
private Instructor instructor;
// ...
// remove @ToString annotation as null field(private Instructor instructor;) will throw error
@Override
public String toString() {
return "InstructorDetail: id = " + this.getId() + ", youtubeChannel: " + this.getYoutubeChannel()
+ ", hobby: " + this.getHobby() + ".";
}
}
@toString
空值会造成 lombok 的一些问题,在这个 ticket 有提到:@ToString formatting ‘language’. #1297。这里用 getId()
比较合适,否则的话会造成循环调用,形成无止尽的递归
DAO 和 DAOImpl 更新
主要是获取新的 InstructorDetails
,代码比较简单:
public interface AppDAO {
InstructorDetail findInstructorDetailById(int id);
}
@Override
public InstructorDetail findInstructorDetailById(int id) {
return entityManager.find(InstructorDetail.class, id);
}
更新 main 方法
其实就是新增一个方法,去获取输出通过 findInstructorDetailById
获取的实例:
@Bean
public CommandLineRunner commandLineRunner(AppDAO appDAO) {
return runner -> {
findInstructorDetail(appDAO);
};
}
private void findInstructorDetail(AppDAO appDAO) {
// find the instructor detail object
int id = 1;
InstructorDetail instructorDetail = appDAO.findInstructorDetailById(id);
System.out.println("Instructor Detail:" + instructorDetail);
// print the associated instructor
System.out.println("Associated instructor: " + instructorDetail.getInstructor());
System.out.println("Done.");
}
输出结果
删除实例更新
-
DAO
void deleteInstructorDetailById(int id);
-
DAO Impl
@Override @Transactional public void deleteInstructorDetailById(int id) { InstructorDetail instructorDetail = this.findInstructorDetailById(id); if (instructorDetail != null) { entityManager.remove(instructorDetail); } }
-
main
@Bean public CommandLineRunner commandLineRunner(AppDAO appDAO) { return runner -> { deleteInstructorDetail(appDAO); }; } private void deleteInstructorDetail(AppDAO appDAO) { int id = 1; System.out.println("Deleting instructor id: " + id); appDAO.deleteInstructorDetailById(id); System.out.println("Done!"); }
结果:
只删除 instructor detail 但是不删除 instructor
这个方式可以通过修改 CascadeType
去实现,如:
@OneToOne(mappedBy = "instructorDetail", cascade = {CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
private Instructor instructor;
修改 DAO Impl 去手动移除关联:
@Override
@Transactional
public void deleteInstructorDetailById(int id) {
InstructorDetail instructorDetail = this.findInstructorDetailById(id);
// remove the associated object reference, break the bi-directional link
instructorDetail.getInstructor().setInstructorDetail(null);
entityManager.remove(instructorDetail);
}
最后运行结果:
建立关联的注解
@JoinColumn
这个代表的是当前属性为 foreign key,并且可以通过当前的 foreign key 寻找到对应的实例
需要注意的是, @JoinColumn
无法单独使用,必须要搭配对应的关系——@OneToOne
、@OneToMany
、@ManyToMany
才能够正确工作
@OneToOne
这个注解只代表了当前的属性与当前的 entity 存在 1-to-1 的对应关系,参考两种用法:
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "instructor_detail_id")
private InstructorDetail instructorDetail;
// =====================================================
@OneToOne(mappedBy = "instructorDetails", cascade = CascadeType.ALL)
private Instructor instructor;
前者可以直接通过 foreign key 寻找对应的关系,写成 query 大体如下:
select * from instructor_detail where id = 1;
后者的 query 大体如下:
select * from instructor i join instructor_detail d on i.instructor_detail_id = d.id where i.id = 1;
对比起来的话,前者因为直接用 foreign key 去找,不用调用一个 join,所以表现上会稍微快一些
reference
-
mysql 权限问题
这个取决于 properties 文件中使用的用户,如果不是
root
,那么可能就会有无法访问的问题,这个时候跑一下下面的脚本就行了,用户名和 db 名称用数据库中的代替:mysql -u root -p USE dbname; # 如果用户不存在,直接 grant 会报错 CREATE USER 'username'@'%' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON `hb-01-one-to-one-uni`.* TO 'username'@'%'; FLUSH PRIVILEGES; SHOW GRANTS FOR 'username'@'%';
-
properties 文件配置
来自官方的 repo: spring-lifecycle-smoke-tests