【Spring Boot】第8.6課-使用 JPA 建立多對多關聯,並配置中間表


https://unsplash.com/photos/LmyPLbbUWhA

在上一篇,已經示範過如何建立一對多關聯。本文將進一步以「學生」選修「課程」為情境,建立多對多關聯,並撰寫 RESTful API 進行測試。

過程中也會介紹多對多關聯特有的「中間表」,並說明如何在程式中正確地操作實體,讓 JPA 在中間表維護雙方的關聯。最後配置雙向關聯,站在不同的角度,查詢另一方的資料。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch8-5


一、程式專案準備

(一)實體類別介紹

以下的「Student」類別,描述了學生資料,包含 id、名字、聯繫方式與科系,共 4 個欄位。其中聯繫方式與科系是來自前面文章的示範,本文盡量不提及。

以下的「Course」類別,描述了課程,包含 id 與名稱這 2 個欄位。

由於 Course 也是要儲存到資料庫的實體類別,因此需準備 repository。

(二)準備測試資料

定義好實體類別後,讀者可啟動程式,讓 Spring Data JPA 建立 table。

若有需要,可執行以下 SQL 指令,建立課程的測試資料。畢竟使用情境中,必定是先有課程,才會有學生來選修。


二、設計多對多關聯

(一)設計方式

首先複習一下先前文章介紹的關聯。

  • 一對一關聯:每位學生擁有一組聯繫方式,每組聯繫方式只有一位擁有者。
  • 一對多關聯:每個科系有多位學生就讀,每位學生只能就讀一個科系。

在 table 的設計上,會添加額外的外鍵(Foreign Key,FK)欄位,關聯到另一張 table 的主鍵(Primary Key,PK)。

本文的學生與課程,屬於多對多關聯。在情境上,每位學生可選修多個課程,而每個課程也可被多位學生選修。然而在 table 設計上,一個 FK 欄位是無法儲存多個 PK 的。

為了建立多對多關聯,我們需要另外建立一張「中間表」(intermediary table),記錄什麼學生選修了什麼課程。示意如下:

學生課程
Vincent資料庫管理
Vincent投資學
Ivy投資學
Ivy審計學

而下圖是 table 的設計,讀者可看出除了學生表與課程表,還準備了一個叫做「student_course」的中間表。

many-to-many-relationship-student-course

從圖中可看到,中間表的欄位有學生與課程編號。這 2 個欄位會一起做為 PK 欄位(複合主鍵),確保該組合不重複,意即學生不可重複選修相同的課程。

中間表的用途是記錄兩張 table 的資料對應關係。當中的欄位均作為 FK,分別關聯到雙方 table 的 PK。也就是說,多對多關聯是讓 2 張 table 對中間表建立一對多關聯來達成。

(二)程式配置

回到程式專案,以下是在 Student 實體類別配置關聯。

此處在實體類別添加了 Set<Course> 欄位,並冠上 @ManyToMany 注解,表明多對多關聯。接著透過 @JoinTable 注解來配置中間表。

配置中間表時,會用到多個參數。name 參數是定義中間表的名稱;joinColumns 參數是站在中間表的角度,定義第一個 FK 欄位,取名為「student_id」。並提供關聯到這個實體類別(Student)的 PK 欄位名稱,也就是 id。

inverseJoinColumns 參數,則在中間表定義了第二個 FK 欄位,取名為「course_id」。它會關聯到另一個實體類別(Course)的 PK 欄位。

最後是 uniqueConstraints 參數,其用途是將這 2 個欄位設為 PK,目的是確保不會插入相同組合的值。

三、儲存多對多關聯的資料

為了確認多對多關聯的效果,我們會在 Controller 設計 RESTful API,透過 repository 存取資料庫。

(一)Request 與 Response body

以下是進行課程選修的 request body,可攜帶多個課程編號。

以下是課程資料的 response body,包含了 id 與名稱這 2 個欄位。

以下是學生資料的 response body,包含了 id 與名稱這 2 個欄位。

(二)關聯插入

以下的 API 是為某位學生進行課程選修。

首先將學生及 request body 所指定的課程都從 repository 查詢出來。另外,呼叫 Student 實體的「getCourses()」方法,本身也會在資料庫查詢該學生原本已選修的課程。

上一篇有提到,從資料庫直接查詢出來的資料,都處於「托管狀態」,被實體管理器(Entity Manager)所管理。因此不論是從 Student 實體查出的課程,還是從 repository 查出的課程,若有重複,則它們在記憶體中皆為同一個 instance。

在 Student 實體類別中,是以 Set 資料結構來攜帶多個 Course 實體。根據其特性,即便新增原本就存在的課程資料進去,也不會造成重複,畢竟記憶體位置相同。

綜上所述,JPA 會在將 Student 實體存回 repository 時,在中間表處理資料的關聯。並且只針對那些新資料,在中間表進行插入。

呼叫 API 後,JPA 在 console 印出的部份 SQL 指令,示意如下:

Hibernate:
    INSERT INTO student_course (student_id, course_id)
    VALUES (?, ?)
Hibernate:
    INSERT INTO student_course (student_id, course_id)
    VALUES (?, ?)

若有 2 筆新的 Course 被添加到 Student 中,那麼就會在中間表插入 2 筆資料。

(三)關聯刪除

以下的 API 是讓學生退選某個課程。

此處是先將 Student 實體查詢出來,接著從它持有的 Course 集合中,移除指定 id 的資料。最後存回 repository 時,JPA 會針對 Course 集合所缺少的資料,從中間表刪除。

呼叫 API 後,JPA 在 console 印出的部份 SQL 指令,示意如下:

Hibernate:
    DELETE FROM student_course
    WHERE student_id = ? AND course_id = ?

讀者可看到,不論是插入還是刪除,JPA 都會根據資料的增減,對中間表進行維護。

(四)關聯查詢

以下的 API,是查詢某位學生所選修的課程。

邏輯很單純,查詢出 Student 實體後,再從中查詢所關聯的多個 Course,將它們包裝為 response body 即可。

呼叫 API 後,JPA 在 console 印出的部份 SQL 指令,示意如下:

Hibernate:
    SELECT sc.student_id, c.id, c.name 
    FROM student_course sc
    JOIN course c ON c.id = sc.course_id
    WHERE sc.student_id = ?

可看到查詢選修課程的方式,是將中間表與課程表做關聯,再以學生編號做為查詢條件。

四、雙向關聯

在多對多關聯中,同樣也能使用雙向關聯。以下是在 Course 實體類別進行配置,藉此取得所關聯的 Student 實體。

此處添加了 Set<Student> 的欄位,並冠上 @ManyToMany 注解,表明了多對多關聯。該注解的 mappedBy 參數傳入了欄位名稱,是用來宣告這個 Course 實體,是被 Student 實體類別的哪個欄位所關聯。

以下的 API,是查詢某個課程的選修學生。

邏輯很單純,查詢出 Course 實體後,再從中查詢所關聯的多個 Student,將它們其包裝為 response body 即可。

呼叫 API 後,JPA 在 console 印出的部份 SQL 指令,示意如下:

Hibernate:
    SELECT sc.course_id, s.id, s.name
    FROM student_course sc
    JOIN student s ON s.id = sc.student_id
    WHERE sc.course_id = ?

可看到查詢修課學生的方式,是將中間表與學生表做關聯,再以課程編號做為查詢條件。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch8-6

上一篇:【Spring Boot】第8.5課-使用 JPA 建立一對多關聯,並配置雙向關聯

下一篇:【Spring Boot】第9課-MockMvc 整合測試(一)

延伸閱讀:使用 Query DSL 擴充 Spring Data JPA,更彈性地進行查詢

留言