2024 iThome 鐵人賽2024 iThome 鐵人賽

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

上篇中,我們學習了 Django ORM 的Q物件和 Django Ninja 的 FilterSchema,但後者感覺只學了一半。

討論比較多的是,view 函式中使用 FilterSchema 的參數定義方式——這確實很重要,但這只是 FilterSchema 的一部分。

本篇要來補完剩下的內容:

  1. 完善 FilterSchema:使用「更道地」的寫法,釋放 FilterSchema 真正的力量
  2. 實作更進階的欄位查詢功能:多欄位查詢——篩選日期區間。
  3. 追加實作第 20 篇學到的「跨欄位驗證」:驗證查詢參數的日期區間是否合法

看來又是資訊滿滿的一篇,話不多說,直接開始吧!

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


一、將查詢邏輯遷移到 FilterSchema

還記得我們上一篇的程式碼實作嗎?

明明多定義了 FilterSchema,但 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(content__icontains=filters.query)
posts = posts.filter(q)
return posts

這簡直莫名其妙🐸

那是因為,FilterSchema 不是這麼用的!

FilterSchema 的「正確」用法

我們應該盡可能將查詢邏輯封裝到 FilterSchema 中,這樣可以讓 view 函式更簡潔,並達到「關注點分離」的效果。

來看看更合理的寫法——將查詢邏輯遷移到 FilterSchema:

1
2
3
4
5
6
7
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None,
q=['title__icontains', 'author__username__icontains'],
min_length=2,
max_length=10,
)

主要的變動是query欄位的 Field 部分,現在加上了q=參數內容:

1
q=["title__icontains", "author__name__icontains"]

很眼熟吧?沒錯,它們實際上就是Q物件的條件語句,Django Ninja 會在背後自動調用Q物件來執行這些查詢。

來自 Mypy 的提醒

一旦使用q=參數,Mypy 又會提醒你:

Unexpected keyword argument “q” for “Field”

它說的並沒有錯,因為 Pydantic Field 確實沒有這個參數——這是 Django Ninja 自行實作的。

你可以無視它,或加上必要的註解。

View 函式簡化

如此一來,view 函式只需要這樣寫就好了:

1
2
3
4
5
6
7
8
9
10
11
...
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(),
) -> QuerySet[Post]:
"""
取得文章列表
"""
posts = Post.objects.select_related('author')
posts = filters.filter(posts)
return posts

是不是簡單很多

因為查詢邏輯從 view 函式「分離」出來了,這使得 view 函式的職責更單一、更利於維護。


二、多欄位查詢:新增日期篩選功能

新需求:除了可以查文章標題或作者名稱,現在還要加入對「發文日期」的過濾!

我們將引入兩個新的 URL 查詢參數

  • start_date
  • end_date

兩者將用來查詢、過濾Post模型中的created_at欄位(即發文日期),以篩選特定時間範圍內的文章資料。

還有一個額外要求:兩者必須「全有全無」——可以都沒有,但不可以只填其中一個。

這是一個典型的「多欄位」查詢。

新增程式碼

這是加入了上述邏輯後的 FilterSchema:

1
2
3
4
5
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None, q=["title__icontains", "author__username__icontains"])
start_date: str | None = Field(None, q="created_at__gte")
end_date: str | None = Field(None, q="created_at__lte")

其中,start_dateend_date都是對模型欄位created_at查詢條件

所以我們使用created_at__gtecreated_at__lte描述過濾邏輯(它們都對應了各自的Q物件),以篩選出符合條件的資料。

那 view 函式呢?你猜得沒錯——完全不用動

這就是使用 FilterSchema 的好處。

API 文件渲染問題

有趣的是,當我試著為這些查詢參數加上文件範例時,如果這樣寫:

1
2
start_date: str | None = Field(
None, q='created_at__gte', examples=['2021-01-01']

查看 API 文件將會得到:

😱 Could not render Parameters, see the console.

但寫example='2021-01-01'卻可以成功。

這可能是 Django Ninja 與 Pydantic 在整合上的一個 bug,我們暫且就先略過吧!

客戶端查詢範例

當我們要查詢某段時間內的文章時,可以使用以下的 URL 查詢參數:

1
?start_date=2023-01-01&end_date=2023-01-31

這樣就能輕鬆查詢出 2023 年 1 月份的所有文章。

附帶一提,日期中的時間因為沒有指定,預設上都是 0 點 0 分 0 秒。所以如果填同一天,就查不到任何東西。

這是一個需要改善重新調整的細節,常見的做法是在程式內部把end_date加 1 天,而我直接選擇讓兩者不能相同XD。實際該怎麼做,取決於你的需求。

除了單純的期間查詢,我們也可以查詢某作者在某段時間內的文章,以查詢作者 Alice 為例:

1
?start_date=2023-01-01&end_date=2023-01-31&query=alice

結果將顯示 Alice 在 2023 年 1 月份的所有文章。


FilterSchema 的預設查詢條件關係

這部分一定要特別介紹,依文件所述,預設上:

  • Field-level expressions are joined together using OR operator.
  • The fields themselves are joined together using AND operator.

意思就是說,單一欄位內的多個 Q 語句,彼此是 OR 關係,比如上面query的:

1
q=['title__icontains', 'author__username__icontains']

可以查詢文章標題「」作者名稱。

不同欄位中的條件(如果都有),則是 AND 關係——必須同時符合才行。所以作者名稱與日期區間,兩者的條件必須同時符合。

這些預設邏輯可以自行變更,詳情請參考上述文件內容。


三、日期區間驗證

本例中,除了欄位查詢,我們還要確保,使用者輸入的開始日期必須早於結束日期。

並且,兩個欄位的查詢值必須是「全有」或「全無」(全無則不必驗證)。

這個需求非常眼熟——不就是第 20 篇提到的「跨欄位驗證」嗎?

沒錯,我們要透過 Pydantic 的model_validator來實現,它允許我們在驗證過程中,對輸入資料進行自定義的邏輯檢查

使用 Pydantic model_validator 實作日期區間驗證

程式碼有點多,我們直接看重點:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class PostFilterSchema(FilterSchema):
...
start_date: str | None = Field(None, q='created_at__gte')
end_date: str | None = Field(None, q='created_at__lte')

@model_validator(mode='after')
def check_date_range(self) -> Self:
# 如果開始日期和結束日期都是 None,則不進行任何檢查
if self.start_date is None and self.end_date is None:
return self

if not all([self.start_date, self.end_date]):
raise ValueError('開始日期和結束日期必須同時提供或同時不提供')

try:
start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d')
end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d')
except ValueError:
raise ValueError('日期格式無效,應為 YYYY-MM-DD')

if start_date_dt > end_date_dt:
raise ValueError('開始日期必須早於結束日期')

return self

對於查詢條件,我們使用 Pydantic 的model_validator進行跨欄位驗證,確保使用者輸入的日期是有效且合理的。

事實上,跨欄位驗證往往要考慮很多細節,否則可能掛一漏萬,間接產生新的 bug。

這個例子就是一個典型案例。

我們必須全盤考慮各種可能的輸入情況,包括日期格式是否正確、日期範圍是否合理,以及兩個日期欄位是否同時存在或同時為空。

細緻的驗證邏輯能提升 API 的可靠性,避免因為無效或不合理的輸入而導致系統出現意外行為。而粗糙的邏輯則反之。

測試錯誤回應

PS:這裡的拋出錯誤,範例程式碼中仍使用了ValueError,我並沒有變更為 Django 的ValidationError。(最新版已修正)

但以下回應則是模擬 Django 的ValidationError,以減少不必要的重複。

一、只輸入開始日期:(這發生機率不高,因為前端通常會限制)

1
2
3
{
"detail": "開始日期和結束日期必須同時提供或同時不提供"
}

二、輸入不合法的日期,比如2023-02-30

1
2
3
{
"detail": "日期格式無效,應為 YYYY-MM-DD"
}

三、輸入不合法的日期區間,比如start_date=2023-01-31&end_date=2023-01-01

1
2
3
{
"detail": "開始日期必須早於結束日期"
}

小結與下一步

這兩篇文章,我們介紹了 FilterSchema 的有效用法,完成了多欄位查詢和日期篩選,並示範如何使用model_validator來強化資料驗證,確保查詢邏輯的正確性。

我們還看了這些應用場景的實例程式碼,幫助讀者更好地理解每個步驟的用途和效果。

下一章,我們將探討 Django Ninja 中的認證(Authentication)機制,並介紹如何使用 pytest 進行單元測試,這些都是後端開發中,不可或缺的要素。