使用 Query DSL 擴充 Spring Data JPA,更彈性地進行查詢


在「使用 JPA Repository 存取 MySQL 資料庫」文章中,用到了 Spring Data JPA 的 repository。我們可透過指定格式的方法名稱,或直接撰寫原生語法來查詢。但前者無法做到複雜的查詢,而後者會依賴於資料庫種類,且容易寫錯。

另外,前端可能會傳來各種 query string,後端要如何解讀也是一大問題。因此這種將條件寫死的 repository 方法有時並不好用。

本文將介紹「Query DSL」這套框架,它能以撰寫 Java 程式碼的方式完成查詢。此外也能接收 query string,自動生成 repository 能執行的查詢條件,讓我們開發更有彈性。

一、Query DSL 介紹

(一)背景

Query DSL 是一套讓我們能直接以 Java 語言,以近似原生 SQL 的形式,來撰寫查詢語法的框架。它可以與 Spring Data JPA 結合,存取 MySQL 等常見的關聯式資料庫,此外也支援 MongoDB。

在介紹之前,請讀者回想一下 Spring Data JPA 的用法。假設我們有個叫做「Student」(學生)的實體類別,若要以叫做「grade」(年級)的欄位來查詢,可在 repository 宣告各種方法。

但只透過方法名稱,很難寫出太複雜的條件。因此也可根據資料庫種類,撰寫原生語法。

然而維護原生語法也是件費工的事,尤其是讀者所開發的系統不排除未來會更換資料庫。

(二)Query DSL 用法示意

回到 Query DSL,這套框架讓我們享受以程式語言來定義查詢條件的好處,因此不會受資料庫種類限制。同時也能以接近 SQL 的形式寫出查詢。

下面是簡單的例子,是以 name 欄位包含「mar」字串做為條件,且不分大小寫。

讀者可看到其寫法與原生 SQL 很相近。

而本文第七節也會介紹 Query DSL 的另一項好處。那就是在 controller 接收 query string,自動產生 SQL 中「WHERE」子句的查詢條件,十分便利。

接下來就讓我們開始準備程式專案,探索 Query DSL 的各種用法。


二、實體類別介紹

本文設計了 3 個實體類別,table 的關聯圖如下。

one-to-many-and-many-to-many-relationship-erd.pn

以下的「Department」類別描述了科系,包含 id 與名稱這 2 個欄位。

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

以下的「Student」類別描述了學生,包含 id、名字、年級這 3 個欄位。並且與科系建立多對一關聯,也與課程建立多對多關聯。

建立好實體類別後,讀者可先啟動程式,讓 Spring Data JPA 在資料庫產生 table。


三、在專案中導入 Query DSL

(一)匯入函式庫與外掛

請在 pom.xml 檔案添加以下依賴。

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
    <version>5.0.0</version>
</dependency>

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <version>5.0.0</version>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

此外也得添加 Query DSL 的 plugin(外掛)。

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

重新整理 pom.xml 檔案後,請在命令列環境執行 mvn clean compile 指令。

這時該 plugin 將掃描專案中具有 @Entity 注解的實體類別,並在專案的「target/generated-sources」路徑下產生「Q 類別」,如「QStudent」、「QDepartment」與「QCourse」。

透過這些產生出來的類別、欄位與方法,我們可以在使用 Query DSL 時,指定資料庫的 table 與欄位,或者定義查詢條件、呼叫聚合函數等。

當實體類別有更改,我們都需要再執行 mvn clean compile 指令,重新產生 Q 類別。

(二)建立 Repository

由於 Query DSL 框架能夠與 Spring Data JPA 結合,因此讓我們將各個實體的 repository 準備好。

以 Student 實體類別為例,repository 介面除了繼承 JpaRepository,也繼承了 QuerydslPredicateExecutorQuerydslBinderCustomizer

繼承 QuerydslBinderCustomizer 時,會在泛型類別傳入 Q 類別。該介面讓我們能將 controller 所接收到的 query string,轉換成自定義的查詢條件。在本文第七節會示範操作。

此處需覆寫 customize 方法,否則啟動程式時,會出現以下例外訊息。

Caused by: org.springframework.data.mapping.PropertyReferenceException:
    No property 'customize' found for type 'Student'

至於 Department 與 Course 的 repository,也請讀者自行建立。

(三)配置查詢器

有別於在 repository 宣告查詢方法,使用 Query DSL 存取資料庫時,需透過一個叫做 JPAQueryFactory 的物件來進行。

以下將其配置為元件,讓所有需要的地方都能共用同一個 instance。

建立時,傳入了 Spring Data JPA 的 EntityManager。實際上 JPAQueryFactory 內部仍然是透過它來進行查詢。

(四)準備測試類別

本文會以撰寫測試的方式,來示範如何使用 Query DSL。以下建立一個測試類別,並將 repository 與 JPAQueryFactory 元件注入進來。

為了準備測試資料,以下再提供一些實用的方法。包含插入學生、科系與課程資料。

平常在開發商業邏輯時,只要將 JPAQueryFactory 元件注入進來即可。下一節開始,就來看看如何使用它。

四、單表查詢

(一)相等條件與模糊查詢

以下是根據學生的名字欄位來查詢。

呼叫 JPAQueryFactoryselectFrom 方法,並傳入學生的 Q 類別物件,代表要在資料庫中的學生表進行查詢,並取得所有欄位值。

至於查詢條件的定義方式,則是先從 Q 類別取得欄位名稱,再呼叫對應的方法。此處選擇了 eq 方法,代表完全相等。

將查詢條件傳入 where 方法,最後呼叫 fetch 方法,即可獲得查詢結果。

JPA 在 console 印出的 SQL 指令,示意如下:

Hibernate:
    SELECT s.id, s.name, s.grade, s.department_id
    FROM student s
    WHERE s.name = ?

除了 eq 方法,讀者也能選擇 equalsIgnoreCasecontainscontainsIgnoreCase 等方法,來做模糊查詢。至於 in 方法,顧名思義就是傳入多個值,找出欄位值等於其中之一的資料。

JPA 在 console 印出的 SQL 指令,示意如下:

Hibernate:
    SELECT s.id, s.name, s.grade, s.department_id
    FROM student s
    WHERE LOWER (s.name) LIKE ?
Hibernate:
    SELECT s.id, s.name, s.grade, s.department_id
    FROM student s
    WHERE s.name IN (?, ?)

認識如何使用 Query DSL 的方法,並確認實際執行的 SQL 指令後,接著讓我們進行其他條件的查詢。

(二)範圍查詢

以下是根據學生的年級欄位,做範圍條件的查詢。

以同樣的手法,從 Q 類別取得欄位名稱後,呼叫 gt 方法,即可定義出「大於」的條件。

而「大於等於」所對應的方法為 goe(greater or equal);「小於」和「小於等於」分別為 ltloe

至於 between 方法,則可同時傳入範圍的上限與下限,其條件會包含邊界值。

五、跨表查詢

(一)一對多關聯

以下是根據學生的科系名稱做模糊查詢。此處要查詢的是科系名稱包含「資訊」的學生。

範例程式中,在呼叫了 selectFrom 方法後,接下來就如同撰寫原生 SQL 那樣,會定義要 JOIN 的 table。

做法相當直覺,首先呼叫 join 方法,傳入科系表的 Q 類別。然後呼叫 on 方法,並傳入條件式,藉此將兩張表關聯起來即可。

(二)多對多關聯

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

在這個例子中,想回傳的欄位是課程,而查詢時是以學生表為基礎。由於是不同表,此處將不直接呼叫 selectFrom 方法,而是分別呼叫 selectfrom 方法。

在多對多關聯中,會透過「中間表」來得知要關聯另一方 table 的哪些資料。因此在 join 方法中,先傳入了學生 Q 類別的 courses 欄位(代表中間表),再傳入另一方的課程 Q 類別。Query DSL 會自行處理關聯,就不必再呼叫 on 方法了。

(三)回傳額外欄位

進行跨表查詢時,難免會想一起取得其他 table 的欄位。這時我們可以另外設計一個類別,準備攜帶這些資料。

這個叫做 StudentAggregate 的類別,除了學生相關的欄位,也包含了科系名稱。

以下的例子是將學生與科系做關聯查詢,並連同科系名稱一起查詢回來。

在呼叫 select 方法時,可以挑選欄位。這時 JPAQueryFactory 會回傳 Tuple 物件,它包含了查詢結果的所有欄位。

我們需自行取出 Tuple 物件中的內容,組合成想要的物件。

呼叫 Tupleget 方法,並傳入 Q 類別的欄位,便能得到對應的值。

六、分組查詢

以下是統計每位學生選修的總學分數,並篩選出大於 5 學分的人。

由於這個例子屬於多對多關聯,所以會 JOIN 課程表與中間表。

分組時,需呼叫 groupBy 方法,傳入分組的欄位依據,此處為學生 id。接著呼叫 having 方法,並傳入條件式,針對分組後的結果做篩選。

此處使用的聚合函數為 sum,代表加總。其他尚有 avgmaxmincount 等函數可選擇。這些聚合函數都是可以被放入 select 方法中,做為查詢結果的。


七、在 Controller 接收 query string

(一)建立 RESTful API

前面的內容都是 case by case,針對當下的需求,獨立撰寫查詢語法。而本節將介紹如何透過 Query DSL 在 controller 接收 query string,並轉換成自定義的條件。

以下在 controller 提供一支 API,用途是取得多筆學生資料。

在 API 的處理方法,我們會以 Predicate 型態來接收 query string。此處還冠上了 @QuerydslPredicate 注解,指定該參數的查詢對象為 Student 實體。

此時讀者已經可以準備測試資料,並試著呼叫 API 了,以下是一個例子:

GET /students?name=Vincent&grade=2&department.id=1&department.name=資訊管理

能夠使用的 query string,就和實體類別所定義的相同。

至於像 Department 這種物件欄位,可用「.」符號來指向其內部的欄位。Query DSL 預設會將這些 query string 視為「相等」的條件,並以 AND 的邏輯串接起來。

(二)自定義查詢條件

接著來到 repository,在本文第三節已經繼承 QuerydslBinderCustomizer。透過覆寫 customize 方法,我們不但能替 query string 取名,也能「改寫」查詢條件。

以下的例子,是將「name」參數的條件,改寫成模糊查詢,且不區分大小寫。

做法是呼叫 QuerydslBindings 物件的 bind 方法,先指定要查詢的實體類別欄位。接著再呼叫 first 方法覆寫條件。上面是經過簡化的 lambda 寫法,而完整的寫法為:

bindings.bind(root.name)
        .first((path, value) -> path.containsIgnoreCase(value));

其中 path 參數指的是欄位,而 value 則是 query string 的值。讀者可先完整寫出來,再讓開發工具簡化成 lambda 寫法。

以下的例子,是允許在 API 接收多個「grade」參數,並將條件定義成符合其中一個值,也就是 SQL 的「IN」語法。

上面兩個例子,分別使用 firstall 方法來提供查詢條件。差別在於後端是否要接收重複的 query string。以查詢多個年級的學生為例,呼叫 API 時,可攜帶多個 grade 參數,如下:

GET /students?grade=1&grade=3

若使用 first 方法,則只會接收第 1 個參數,而 all 方法則是全部接收。

(三)自定義參數名稱

我們也能設計自己的 query string 名稱,這樣就不必限制於只能使用實體類別的欄位。

以下的例子,是接收自定義的「deptName」參數,用來對科系名稱做模糊查詢。

在本文的範例,Student 與 Department 實體類別中,並沒有叫做 deptName 的欄位。但透過 as 方法,我們便能將自己的 query string 套用到指定的欄位。

再舉一個例子,假設 Student 實體類別有「生日」欄位,但沒有「年齡」欄位。那麼透過以上的手法,便能接收叫做「age」的 query string,並在 firstall 方法中進行 functional programming,推導出日期範圍的條件。

(四)子查詢

最後以子查詢做為例子。以下是接收自定義的「minTotalCredits」參數,查詢總學分數達到指定值的學生。

此處的做法,是建構出子查詢,將符合的學生 id 當作查詢條件。JPA 在 console 印出的 SQL 指令,示意如下:

Hibernate:
    SELECT s1.id, s1.name, s1.grade s1.department_id
    FROM student s1
    WHERE s1.id IN (
        SELECT s2.id
        FROM student s2
        JOIN student_course sc ON s2.id = sc.student_id
        JOIN course c ON c.id = sc.course_id
        GROUP BY s2.id
        HAVING SUM (c.credit) >= ?
    )

不論 Query DSL 在 controller 接收多少 query string,最終目標都是組合出整個 SQL 指令中「WHERE」子句的條件。善用自定義查詢的功能,可讓我們靈活地因應不同需求。

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

留言