多欄位查詢#

這是 Django Ninja 系列教學的第 27 篇。
上篇中,我們學習了 Django ORM 的 Q 物件和 Django Ninja 的 FilterSchema,但後者感覺只學了一半。
討論比較多的是,view 函式中使用 FilterSchema 的參數定義方式——這確實很重要,但這只是 FilterSchema 的一部分。
本篇要來補完剩下的內容:
- 完善 FilterSchema:使用「更道地」的寫法,釋放 FilterSchema 真正的力量。
- 實作更進階的欄位查詢功能:多欄位查詢——篩選日期區間。
- 追加實作第 20 篇學到的「跨欄位驗證」:驗證查詢參數的日期區間是否合法。
看來又是資訊滿滿的一篇,話不多說,直接開始吧!
本文所有的程式碼變動,可參考這個 PR。
範例專案#
一、將查詢邏輯遷移到 FilterSchema#
還記得我們上一篇的程式碼實作嗎?
明明多定義了 FilterSchema,但 view 函式中的程式碼不僅沒有減少,反而還增加了!(雖然查詢邏輯也變多了,因為要同時查詢兩個欄位)
@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:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None,
q=['title__icontains', 'author__username__icontains'],
min_length=2,
max_length=10,
)
主要的變動是 query 欄位的 Field 部分,現在加上了 q= 參數內容:
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 函式只需要這樣寫就好了:
...
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_dateend_date
兩者將用來查詢、過濾 Post 模型中的 created_at 欄位(即發文日期),以篩選特定時間範圍內的文章資料。
還有一個額外要求:兩者必須「全有全無」——可以都沒有,但不可以只填其中一個。因為這是日期區間查詢,必須有開始和結束。
這同時也是一個典型的「多欄位」查詢。
新增程式碼#
這是加入了上述邏輯後的 FilterSchema:
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_date 和 end_date 都是對模型欄位 created_at 的查詢條件。
所以我們使用 created_at__gte 和 created_at__lte 來描述過濾邏輯(它們都對應了各自的 Q 物件),以篩選出符合條件的資料。
那 view 函式呢?你猜得沒錯——完全不用動!
這就是使用 FilterSchema 的好處。
API 文件渲染問題#
有趣的是,當我試著為這些查詢參數加上文件範例時,如果這樣寫:
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 查詢參數:
這樣就能輕鬆查詢出 2023 年 1 月份的所有文章。
附帶一提,日期中的時間因為沒有指定,預設上都是 0 點 0 分 0 秒。所以如果填同一天,就查不到任何東西。
這是一個需要改善或重新調整的細節,常見的做法是在程式內部把 end_date 加 1 天,而我直接選擇讓兩者不能相同XD。實際該怎麼做,取決於你的需求。
除了單純的期間查詢,我們也可以查詢某作者在某段時間內的文章,以查詢作者 Alice 為例:
結果將顯示 Alice 在 2023 年 1 月份的所有文章。
FilterSchema 的預設查詢條件關係#
這部分一定要特別介紹,依文件所述,預設上:
- Field-level expressions are joined together using
ORoperator. - The fields themselves are joined together using
ANDoperator.
意思就是說,單一欄位內的多個 Q 語句,彼此是 OR 關係,比如上面 query 的:
q=['title__icontains', 'author__username__icontains']
可以查詢文章標題「或」作者名稱。
而不同欄位中的條件(如果都有),則是 AND 關係——必須同時符合才行。所以作者名稱與日期區間,兩者的條件必須同時符合。
這些預設邏輯可以自行變更,詳情請參考上述文件內容。
三、日期區間驗證#
本例中,除了欄位查詢,我們還要確保,使用者輸入的開始日期必須早於結束日期。
並且,兩個欄位的查詢值必須是「全有」或「全無」(全無則不必驗證)。
這個需求非常眼熟——不就是第 20 篇提到的「跨欄位驗證」嗎?
沒錯,我們要透過 Pydantic 的 model_validator 來實現,它允許我們在驗證過程中,對輸入資料進行自定義的邏輯檢查。
使用 Pydantic model_validator 實作日期區間驗證#
程式碼有點多,我們直接看重點:
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,以減少不必要的重複。
一、只輸入開始日期:(這發生機率不高,因為前端通常會限制)
二、輸入不合法的日期,比如 2023-02-30:
三、輸入不合法的日期區間,比如 start_date=2023-01-31&end_date=2023-01-01:
小結與下一步#
這兩篇文章,我們介紹了 FilterSchema 的有效用法,完成了多欄位查詢和日期篩選,並示範如何使用 model_validator 來強化資料驗證,確保查詢邏輯的正確性。
我們還看了這些應用場景的實例程式碼,幫助讀者更好地理解每個步驟的用途和效果。
下一章,我們將探討 Django Ninja 中的認證(Authentication)機制,並介紹如何使用 pytest 進行單元測試,這些都是後端開發中,不可或缺的要素。