Let's Django!Let's Django!

這是 Django Tutorial 的第 8 篇、Django ORM 外鍵教學的第 3 篇——完結篇。

範例程式碼可參考我的 GitHub 專案,更多教學請見「Django 文章總覽」。

系列:Django ORM 外鍵教學

  1. Django ORM:一對一、一對多外鍵教學(上)前言與關聯設定
  2. Django ORM:一對一、一對多外鍵教學(中)反向關聯
  3. Django ORM:一對一、一對多外鍵教學(下)關聯查詢

經過前 2 篇的鋪墊,我們可以真正開始感受,使用 ORM 來查詢 db 關聯物件的方便與直觀之美。

開始前,我們要先匯入範例資料,方式請參考〈用 Django Fixture 匯入與導出資料〉介紹的 Django fixture 與資料內容。

打開範例專案,cd 至專案根目錄,並使用指令:

1
python manage.py loaddata post_data.json

好,現在我們的 db 已經有資料了。

如果你已經不記得具體有哪些 table、它們代表什麼,可參考第一篇的模型介紹,以及第二篇對模型架構的調整,或直接觀看 models.py 原始碼。

本文主旨

本文只專注介紹 Django ORM 中的外鍵關聯查詢

畢竟 Django ORM 的查詢語法實在太多了,很多時候都要回去看文件。

而其中關聯查詢特別常用,值得我們專門學習,熟練掌握。

範例資料簡介

目前 db 中有 3 個 table,範例資料是由這段程式碼所建立:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from post.models import Post, Subtitle, Comment

# 建立 Post
post1 = Post.objects.create(title="Django Fixtures", content="Content of Django Fixtures")
post2 = Post.objects.create(title="ORM Basics", content="Content of ORM Basics")
post3 = Post.objects.create(title="Advanced Queries", content="Content of Advanced Queries")

# 建立 Subtitle
subtitle1 = Subtitle.objects.create(content="An Overview", post=post1)
subtitle2 = Subtitle.objects.create(content="Introduction to ORM", post=post2)

# 建立 Comment
Comment.objects.create(content="Great article!", post=post1)
Comment.objects.create(content="Very informative.", post=post2)
Comment.objects.create(content="Helped a lot!", post=post3)
Comment.objects.create(content="Need more examples.", post=post3)
Comment.objects.create(content="Thanks for the tips!", post=post3)

範例設計

如你所見,總共有 3 篇文章,其中 2 篇有副標題。這些文章共計有 5 篇留言,分屬於不同的文章。

為了確保教學內容聚焦在「外鍵關聯查詢」本身,我設計的範例資料相對單純,且筆數很少。以免你光要看懂範例資料就花掉好些時間。

不過它們依舊很有代表性!

這些 db 實例覆蓋了以下幾個重要的場景:

  1. 多個文章(Post)實例,每個文章有不同的標題和內容。
  2. 文章和副標題(Subtitle)的一對一關聯,其中有文章有副標題,有的沒有,這可以用來展示一對一關聯的查詢。以及「關聯不存在」的情況。
  3. 文章和留言(Comment)的一對多關聯,每個文章有不同數量的留言,這可以用來展示外鍵關聯的查詢。

使用__進行 Django ORM 查詢

開始學習前,還有另一件重要的事要提醒。

Django ORM 會大量使用雙底線(__)進行查詢。因此,你的 Django 模型欄位名稱不可以有雙底線

而這些查詢主要分為兩大類。

一、以特定條件查詢欄位

利用雙底線來指定欄位的查詢條件,例如等於、不等於、大於、小於等。

常見的查詢條件有:__exact__iexact__contains__icontains__gt(大於)、__gte(大於等於)、__lt(小於)、__lte(小於等於)、__in__isnull 等。

實例如下:

1
2
# 查詢標題包含 'Python' 的文章,不區分大小寫
Post.objects.filter(title__icontains='Python')

二、外鍵關聯查詢

外鍵關聯查詢,就是「跨 table」的查詢。只用一次查詢,就能同時對兩個 table 進行條件過濾並獲得結果。

此時的雙底線,代表的是外鍵欄位的屬性。比如postComment的外鍵欄位,而post__title指的是Posttitle欄位。

接來就用這些資料,示範常見的外鍵關聯查詢。

附帶一提,第二篇使用反向關聯欄位的方式,也可以算是外鍵關聯查詢的一種。


了解雙底線的兩大用途後,才不會混淆查詢條件和關聯查詢。

以下例子不算複雜,但也未必很簡單。畢竟我們要展示的是「關聯查詢」,而不是單純的查詢。

第二篇介紹的「反向關聯」,是必須熟悉的內容。因為前兩個例子都與反向關聯有關。

讓我們開始吧!

一、查詢「有副標題」的文章

文章有副標題,代表文章和副標題之間「存在」一對一關聯。

這裡的重點:我們可以用isnull作為查詢條件,判斷「關聯是否存在」。

1
2
3
4
5
from post.models import Post

# 查詢所有「有副標題」的文章
posts_with_subtitle = Post.objects.filter(
subtitle__isnull=False)

subtitlePost的一對一反向關聯欄位,查詢方式是subtitle__isnull

值得注意的是,如果subtitle普通欄位正向關聯欄位,那相同寫法的查詢效果,將截然不同——會變成限制欄位值是否為None

當然,正向關聯欄位的值若為None,也是代表(正向)關聯不存在。不過這與反向關聯不存在,本質上仍有所區別。

查詢結果:

1
2
>>> posts_with_subtitles
<QuerySet ['Django Fixtures', 'ORM Basics']>

簡言之,isnull很常用來查詢「關聯是否存在」,這在外鍵關聯查詢中非常實用。

二、查詢留言中有「Thanks」一詞的文章

以「反向關聯欄位」進行查詢,我們來看第二種情況

查詢留言中包括特定字詞,比如「Thanks」的文章。

1
2
posts_with_thankful_comments = Post.objects.filter(
comments__content__icontains='Thanks')

commentsPost的反向關聯屬性,和前面的subtitle類似。只不過這裡是一對多關聯。

這裡的重點是,related_name 不僅可以用來訪問 post.comments,也可以直接用在查詢中,就像上面寫的那樣。

因為它也是一個外鍵關聯屬性——反向關聯屬性

查詢結果:

1
2
>>> posts_with_thankful_comments
<QuerySet [<Post: <3>: Advanced Queries>]>

三、查詢 id 為 1 的文章留言數

無論是一對一或一對多,當查詢對象是外鍵關聯欄位時,可以直接以__查詢並過濾該外鍵欄位「值」(即另一個 db 實例所擁有的欄位名稱。

1
2
3
from post.models import Comment

comment_count = Comment.objects.filter(post__id=1).count()

Comment有一個外鍵欄位post,指向一個Post實例。我們可以直接使用__,以 Post自己的屬性——本例中為id,作為查詢條件。

查詢結果:

1
2
>>> comment_count
1

這種以「正向關聯欄位」的屬性(比如post的屬性id)作為條件的查詢,佔了關聯查詢需求的很大一部分

查詢外鍵 Primary Key 的不同變體

上述的id是外鍵post的主鍵(Primary Key),用id查詢的情況很常見。

因此,用外鍵的主鍵(Primary Key)欄位查詢時,Django 幫你準備了多種寫法。以下寫法效果皆相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 使用 post__id 原來的寫法
comments_count = Comment.objects.filter(post__id=1).count()

# 使用 post_id
comments_count = Comment.objects.filter(post_id=1).count()

# 使用 post__pk
comments_count = Comment.objects.filter(post__pk=1).count()

# 使用直接使用物件查詢
post = Post.objects.get(id=1)
comments_count = Comment.objects.filter(post=post).count()

# 你甚至可以這樣寫!
comments_count = Comment.objects.filter(post=1).count()

Django 會自動為外鍵加上_id,代表該外鍵的主鍵,命名模式為<外鍵名稱>_id。且無論該外鍵欄位主鍵名稱是什麼,都可以使用這種寫法。

同理,post__pk也是類似效果,它們都算是一種欄位別名

雖然用別名的寫法讓人感覺更加「道地」、更像內行人。但如果想保守起見,還是使用post__id比較好。因為這種寫法最直觀,而且適用於所有欄位。

我個人認為,不用別名也沒關係,explicit 至上。

何時使用哪種寫法?

Django 入門者最常使用的是第 4 種,它很好懂,因為欄位值正是一個 db 物件!

然而,它需要程式碼上下文中,先有一個post物件。如果沒有,就要像上面寫的,先透過查詢取得post物件——這會增加一次 db 查詢。

如果當前上下文只有post的 id,強烈建議使用前 3 種寫法(個人推薦第 1 種),以減少不必要的查詢負擔。

第 5 種寫法雖然也「很 Django」,但很多人不熟悉,所以我不敢輕易採用。

四、查詢標題有「Django」的所有留言

上面「三」是用 id 查詢,這裡改用特定字詞查詢。

而且,不是直接查詢標題中包含「Django」的文章(這和外鍵查詢無關),而是文章標題中包括 Django 的所有留言

1
2
comments_for_django_posts = Comment.objects.filter(
post__title__icontains='Django')

」的comments__content__icontains,和這裡的post__title__icontains,有一個區別——前者是反向關聯欄位,後者是正向關聯欄位

兩者都用了經典的「兩次雙底線」查詢,即post__title__icontains

這樣的語句看起來有點「抽象」,這也是為什麼我們要把雙底線查詢分成兩大類,因為它們可能會同時出現!

弄清楚兩者之間的區別,才更容易明白,兩次雙底線各代表什麼查詢條件:

  1. post__title:查詢Posttitle欄位。
  2. title__icontains:查詢title欄位中包含特定字詞(不分大小寫)的文章。

查詢結果:

1
2
>>> comments_for_django_posts
<QuerySet [<Comment: <1>: Great article!>]>

查詢結果的變數命名

所有的 ORM 查詢結果的變數,只要結果是 0 到多個——也就是 QuerySet,請務必使用「複數」命名,比如commentsposts

只有在確定「最多只會拿到一個」db 實例時,才用單數。這通常只發生在,你使用了getfirstlast等直接回傳實例的 QuerySet 方法。

本文中的查詢結果變數命名都較為冗長,主要是為了教學上的表達。

實務中我通常不會把查詢條件一一映射到變數命名,除非該條件、特性非常重要。


結語:Djangonic

本文僅涵蓋了 Django ORM 外鍵查詢的一部分,但都是經典且常用的場景,有一定的代表性。

善用這些關聯查詢的重點在於使程式碼更加直觀和優雅。

如第二篇文章所述,其實這些查詢也可以「不透過」外鍵關聯查詢來實現,但可能會讓程式碼變得冗長,甚至難以理解。

熟練的 Python 開發者可以達到 Pythonic 的境界,在 Django 中也有類似 Pythonic 的術語,稱為「Djangonic」或「Djangonic way」。

這種風格強調利用 Django 的內建功能和最佳實踐,以簡化程式碼和提高可維護性。

這也是我們作為 Django 開發者的日常追求。