2024 iThome 鐵人賽2024 iThome 鐵人賽

這是 Django Ninja 系列教學的第 26 篇。

查詢」是 API 中常見的附加需求,本質上是對資料的過濾(filtering)與篩選

無論是篩選文章、商品,還是查詢用戶,根據不同條件來過濾資料並獲得結果,可說是大部分專案的必備功能。

在 view 函式中,實作查詢最簡單的方式,就是使用 Django ORM 的過濾方法。例如,我們可以用filter方法來根據特定條件篩選 QuerySet。

這種方法簡單直接,適合基本的查詢需求。然而,它也有其局限性——隨著欄位與需求的增加,查詢條件可能變得越來越複雜,導致程式碼冗長且難以維護。

為了解決這一問題,Django Ninja 提供了 FilterSchema,讓我們可以用更「結構化」的方式,定義並管理查詢條件。

本文將介紹 FilterSchema,一步步實作與講解,讓你了解如何在 Django Ninja 中使用 FilterSchema,實現更加靈活、模組化的 API 查詢功能。

本文所有的程式碼改動,可參考這個 PR


傳統查詢方法與問題

上一篇我們提到「取得文章列表」API,還記得我們在〈卷 11:請求(三)查詢參數 - Query Parameters〉為它加上的「依文章標題查詢」功能嗎?

這是目前程式碼的現況:(請留意查詢參數名稱title

1
2
3
4
5
6
7
8
9
10
11
12
13
...
def get_posts(
request: HttpRequest,
title: None | str = Query(None, min_length=2, max_length=10),
) -> QuerySet[Post]:
"""
取得文章列表
"""
posts = Post.objects.all()
if title:
posts = posts.filter(
title__icontains=title).select_related('author')
return posts

那時我們就是用了 Django ORM 的filter方法!

1
posts.filter(title__icontains=title).select_related('author')

最後的select_related('author')是為了避免「N+1」問題,和查詢邏輯無關,可以先不管。

潛在問題

這樣的寫法很直觀,對於簡單的查詢需求很有效。

但隨著專案規模的擴大、查詢需求變得複雜時,就會產生以下困境

  • 程式碼重複:當你有多個地方需要進行相似的過濾時,你可能會發現自己在重複相同的過濾邏輯。
  • 維護困難:隨著過濾條件的增加,你的 view 函式可能變得難以維護。每增加一個新的過濾條件,都可能需要修改多個地方的程式碼。
  • 資料驗證與轉換:你需要手動處理資料驗證和轉換的問題,這不僅增加了錯誤的可能性,也增加了開發的複雜度。
  • 擴充性:當你需要支援更複雜的過濾條件,比如範圍查詢、多條件組合查詢時,手動組裝 ORM 查詢的方法可能會顯得非常笨重且難以管理。

因此,我們並不推薦在複雜查詢的情況下,直接使用 ORM 的filter方法來組裝查詢條件。


新需求:同時查詢作者名字

新的需求是,用同一個關鍵字, 同時查詢文章的標題或作者的名字, 只要任一符合就顯示在結果中。(二者滿足其一即可,也可以都符合

這需求類似於 iThome 鐵人賽官網的這個查詢功能

不過我們只能查 2 種,而它可以同時查 3 種:題目、簡介、參賽者暱稱。

但本質上是一樣的。

用傳統方法 + Q 物件實作

如果用傳統方法 + Django 的 Q 物件實作,則查詢會長這樣:

1
2
3
4
5
6
from django.db.models import Q
...

posts = posts.filter(
Q(title__icontains=title) | Q(author__name__icontains=title)
).select_related('author')

此時查詢參數不適合再叫title,因為它要查詢兩個欄位。沒關係,我們之後會改成query

這段程式碼有兩個重點:

  1. 查詢果然變長了!之後如果還有新的查詢條件,那豈不是……
  2. 這個Q是什麼東西?

Q則是 Django ORM 的 Q 物件,它在複雜查詢邏輯中佔據了重要地位。因此,我們有必要先簡單介紹一下。


Django Q 物件簡介

為了改善多條件查詢時,程式結構複雜的問題,Django 提供了Q物件。

Q物件允許我們靈活地組織查詢條件,使用邏輯運算子(如&|)進行條件合併。在處理複雜條件過濾時非常有用。

比如說,我們想要篩選出標題包含「Ninja」並且作者名稱包含「Alice」的文章,可以這樣寫:

1
2
3
posts = posts.filter(
Q(title__icontains='Ninja') & Q(author__name__icontains='Alice')
)

上述寫法,其實就等價於我們常見的:

1
2
3
posts = posts.filter(
title__icontains='Ninja', author__name__icontains='Alice'
)

所以你通常不會在「AND」需求時使用Q物件。

「OR」查詢條件才是Q經典場景

現在條件改為——文章標題「」作者名字有「Alice」就行。可以使用|

1
2
3
posts = posts.filter(
Q(title__icontains='Ninja') | Q(author__name__icontains='Alice')
)

Q物件讓查詢更加靈活且清晰,特別是在面對多個可選條件時。


使用 FilterSchema 改進查詢

了解傳統查詢方法容易造成程式冗長的問題,並學習了Q物件的基礎後,我們要開始介紹今天的主角——FilterSchema

Django Ninja 提供的 FilterSchema,主要的功能是讓查詢語句更加結構化、模組化,避免 view 函式變得冗長、難讀。

而且,與 Schema 中的驗證方法相同,它也一定程度實現了「關注點分離」原則——藉由將查詢邏輯從 view 函式中抽離出來。

不過,我們先不急著一步到位,容我分階段地改進程式碼。

這樣雖然有點笨拙,但你會對 FilterSchema 與複雜查詢的實作,有更深刻的了解。

第一版「改進」

我們先用 FilterSchema 實現上述的「新需求:同時查詢作者名字」。

schemas.py中建立新的 Schema,不過這次是 FilterSchema:

1
2
3
4
5
6
# post/schemas.py
from ninja import Field, FilterSchema, Schema
...

class PostFilterSchema(FilterSchema):
query: str | None = Field(None, min_length=2, max_length=10)

這個 FilterSchema,其實是給「查詢參數(query parameters)」使用的。所以它的欄位(屬性)名稱,就是你認為客戶端應該使用的查詢參數名稱

因為同時要查「文章標題」和「作者名字」,所以我命名為query

接下來,我們在 view 函式中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@router.get(...)
@paginate(CustomPagination)
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(), # 使用 FilterSchema
) -> QuerySet[Post]:
"""
取得文章列表
"""
posts = Post.objects.all()
if filters.query:
q = Q(title__icontains=filters.query) | \
Q(author__username__icontains=filters.query)
posts = posts.filter(q)
return posts

PS:這裡的專案範例程式碼有誤,第二個Q查詢誤植為「content__icontains」,請讀者留意。我已在下一個分支中修復。

看完這個新的 view 函式,你可能不禁心想:

這是在搞笑吧?完全沒有變簡單啊!

沒錯,因為這只是 FilterSchema 的「半成品」,所以看起來比不用還冗長。

儘管如此,其中還是有一些看點,值得我們了解。


重點解析

1
2
3
q = Q(title__icontains=filters.query) | \
Q(content__icontains=filters.query)
posts = posts.filter(q)

從這段能看出,Q物件可以單獨進行各種合併操作,最後再丟給 Django filter方法作為參數。

Django Ninja 常見句型

本例中,這樣的 view 函式參數「句型」在 Django Ninja 非常普遍:

1
filters: PostFilterSchema = Query()

而初學者看了會很容易「誤解」。

為什麼?因為你可能會以為,filters的型別是PostFilterSchema(這沒問題),然後它的預設值Query(),因為 Python 函式就是這樣定義的。

但並不是。

Query()並不是filters參數的預設值,否則它的型別不應該是Query嗎?

事實上,= Query()這段標記不是給你看的,是給 Django Ninja 看的,它相當於是在告訴 Django Ninja:

這個參數內容應該從 HTTP 請求中的查詢參數(query parameters)中取得,而不是從 body 或 path。

這樣想就很容易明白了。

Django Ninja 會試圖從查詢參數獲取字串,拆解它們(如果有複數個查詢字串),然後一一丟給PostFilterSchema進行初始化與驗證:

  1. 驗證失敗:回傳 422。
  2. 驗證成功:將 Schema 物件傳入 view 函式作為函式的參數區域變數)。

查詢結果

這是用「Alice」作為關鍵字的查詢結果:

查到了 30 篇文章,全都來自於作者名稱包含「Alice」的用戶。


小結與下一步

本篇先講到這裡,我們已經接觸了兩個新概念——Q 物件和 FilterSchema。

我們還分析了 Django Ninja 中常見的 view 函式參數「句型」,這對於理解框架的使用方式、習慣非常重要。

這些概念需要時間消化,但我可以向你保證,這樣的鋪陳是值得的。

比起直接深入 FilterSchema 的進階用法,這種循序漸進的學習方式更有助於理解。

下一篇你將會看到,為什麼認識 Q 物件很重要。以及如何透過 FilterSchema,來建立結構化、符合「關注點分離」的多欄位查詢

我們下篇見。