Let's Django!Let's Django!

2024/04/18:重新編輯全文,提高可讀性。

這是 Django Tutorial 系列連載的第 3 篇。

搭配學習的範例程式碼,可參考 GitHub 專案:Django-Tutorial

系列:Django ORM 外鍵教學

  1. Django ORM 外鍵教學:設定一對一、一對多關聯
  2. Django ORM:反向關聯(Reverse relationship)介紹
  3. Django ORM 外鍵查詢指南

前言

上一篇我們介紹完 Django ORM 的關聯設定,接下來本應該要進入查詢部分。不過,由於「反向關聯」在 ORM 查詢中扮演著十分重要的角色

所以我決定專門寫這個(中)篇,好好介紹 Django ORM 中的「反向關聯」。

本文可以視為是第一篇的「補充」——對「反向關聯屬性」多加著墨。

不用說,這篇文章需要你先充分理解第一篇的內容,再行閱讀。尤其是該文中的這三個部分:

請務必熟悉。


理解反向關聯

我們可以從「正向關聯」來比較反向關聯,會更好理解。

在 Django ORM 中,正向關聯指的是那些由我們「明示」定義的「關聯」欄位,比如ForeignKeyOneToOneField等欄位。

1
2
3
4
class Comment(models.Model):
# 這是一個正向關聯欄位
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name='comments')

正向關聯屬性的最大特色是,它對應著資料庫 table 中的「外鍵」欄位。換句話說,它和 Django model 中的其它欄位一樣,都是「實體」的。

反向關聯是「虛擬」的

而反向關聯是「虛擬」的。

所謂的「虛擬」,是指反向關聯並不直接對應於資料庫中的一個實際欄位。它只存在於 Django ORM 層面上,作為模型關係的一部分

這種設計使得我們可以在不增加額外資料庫欄位的情況下,輕鬆地管理和查詢模型間的關係與對應的實例。

換言之,即使沒有反向關聯,我們還是可以透過標準 ORM 語法,查詢想要的資料——只是比較麻煩!

反向關聯在「快速獲得關聯實例」這個需求場景,大大突顯了 ORM 查詢相對於原生 SQL 查詢的便利性。也讓你多增加了一個使用 ORM 的理由。

反向關聯屬性的返回值

根據關聯的類型,反向關聯的「屬性值」會有所不同。

在一對一關係中,反向關聯屬性返回的是一個的關聯模型實例。在一對多關係中,返回的是一個QuerySet(嚴格來說其實是關係管理器——RelatedManager),代表所有相關聯的模型實例集合。

這種彈性使得反向關聯成為 Django ORM 中一個極其強大且靈活的存在。


好,講完了定義,我們趕緊來看,反向關聯屬性在實務上究竟是如何被使用。以及使用上的注意事項。

不過在此之前,我必須對原來範例程式碼中的 model 結構,做出一些調整

範例程式碼模型調整

我要變更其中的「一對一」model 關係,因為原來的設計有兩個比較大的缺陷。我們先看看舊的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Post(models.Model):
title = models.OneToOneField('Title',
on_delete=models.PROTECT,
related_name='post')
content = models.TextField()

def __str__(self):
return f'<{self.id}>: {self.title.main} - {self.title.subtitle}'

class Title(models.Model):
main = models.CharField(max_length=100)
subtitle = models.CharField(max_length=100)

def __str__(self):
return f'<{self.id}>: {self.main}'

第一個缺陷,是 Post 和 Title 的一對一關係,有違現實

把 Title 變成一個關聯物件,是非常少見的。這不僅會讓讀者「難以想像」,無法感同身受。後續使用這模型來實作 API 時,這個設計不良問題會更加突顯。

所以,還是改成比較符合現實的版本——但又不能脫離「部落格文章關係模型」這個大框架,如何再想一個更「有感」的一對一關係,不禁又讓我苦思了一段時間。

和 ChatGPT 討論很多可能,始終找不到非常適合的例子。最後我決定這樣:先把 Title 改為 Post 的一個欄位,這是比較尋常且合理的做法。

用 Subtitle 取代 Title

一對一範例部分,改用「Subtitle」模型替代。這樣一來,Post 就有了一個「可選」的副標題。

沒錯,為了突顯「反向關聯不存在」這個議題,Subtitle 最好設計成「可選」的。也就是不一定每篇文章都有關聯的 Subtitle——有時候有、有時候沒有。

原來的「Post - Title」關聯,就不是「可選」的——兩者都一定要有。不能呈現一對一關係不存在時的情境,這是舊程式碼第二個缺陷。

Subtitle 模型介紹

我們來到更新後的業務邏輯中。每一篇部落格文章,都一定要有標題,所以標題現在只是 Post 模型的一個欄位而已。

但是,如果你願意,你可以為這篇文章加上「副標題」,也就是關聯 Subtitle。這完全是「可選」的,加不加隨你。

如果你用過寫作平台 Medium,就知道它的文章正是「副標題可選」的設定。

儘管新設計並非完美,但顯然比原來的設計更加合理。

總之,我們只需知曉一件事:Post 可能有關聯的 Subtitle,也可能沒有。並且兩者是「一對一」關係。

新模型關聯

如上述修正後,新的models.py內容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 文章
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()

def __str__(self):
return f'<{self.id}>: {self.title}'

# 可選的副標題
class Subtitle(models.Model):
content = models.CharField(max_length=100)
post = models.OneToOneField(Post,
on_delete=models.CASCADE,
related_name='subtitle')

def __str__(self):
return f'<{self.id}>: {self.content}'

# 留言
class Comment(models.Model):
content = models.TextField()
post = models.ForeignKey(Post,
on_delete=models.CASCADE,
related_name='comments')

def __str__(self):
return f'<{self.id}>: {self.content}'

不知道你覺得如何?我感覺更加井然有序了!很適合用來作為教學文章解說的範例。


接下來,我們就用上述的程式碼實例,來一一解說反向關聯。

一對一反向關聯

請看 Post 與 Subtitle 這兩個模型,它們是典型的一對一關係。

Subtitle 實例有著「正向關聯」屬性——post,關聯某個 Post 模型實例。

Post 實例有著「反向關聯」屬性——subtitle,來自related_name='subtitle'。不過,即使你沒有特別定義related_name,這裡的「預設」反向關聯屬性名稱,也是subtitle(即關聯模型名稱的「小寫」型態)。

一對一反向關聯查詢重點

假設 Post 模型的實例為post_1

想要查詢post_1所關聯的 Subtile 實例,有兩種方法,分別是一般查詢,與反向關聯查詢

一般查詢方式如下:

1
subtitle = Subtitle.objects.filter(post=post_1)

如前所述,即使沒有反向關聯,我們一樣可以得到我們想查詢的資料。

但透過反向關聯屬性,則會更加方便

1
subtitle = post_1.subtitle

一對一反向關聯不存在與錯誤錯理

一對一反向關聯有一個重點,那就是「關聯物件不存在時的錯誤處理」。

post_1的反向關聯屬性.subtitle,不一定總是對應著一個 Subtitle 實例——有可能關聯不存在。即該文章沒有副標題。

當關聯不存在時,訪問post_1.subtitle會引發RelatedObjectDoesNotExist

考慮到「關聯不存在」的可能,我們的程式常常會這樣寫:

1
2
3
4
try:
subtitle = post_1.subtitle
except ObjectDoesNotExist:
...

其中ObjectDoesNotExistRelatedObjectDoesNotExist的父類別,因為你無法直接引用RelatedObjectDoesNotExist

這種寫法雖然不算優雅,但它明確地表達了你的意圖


一對多反向關聯

一對多關係,我們要把目光放到 Post 與 Comment 這兩個模型。

其中 Post 是「一方」,而 Comment 則是「多方」:一篇文章可以有多則留言,但一則留言只能屬於某一篇文章。

一對多關係中,ForeignKey欄位肯定是實作在「多方」,所以上述程式碼,定義這個「外鍵」欄位的模型是 Comment:

1
2
3
4
5
class Comment(models.Model):
content = models.TextField()
post = models.ForeignKey(Post,
on_delete=models.CASCADE,
related_name='comments')

一樣,我們假設上述模型實例分別為post_1comment_1,兩者相互獨立。

其中comment_1有正向關聯屬性.post(即 ForeignKey)。

post_1有「一對多」反向關聯屬性.comments(從related_name='comments'獲得)——注意這個複數

多對一「正向」關聯

對「多方」的comment_1來說,想知道它關聯的 Post 實例,只要呼叫它的正向關聯屬性.post即可:

1
post = comment_1.post

但這其實也不是我們所謂的「反向關聯」,所以簡單提一下即可。

一對多反向關聯查詢重點

想獲得post_1所有關聯的 Comment 實例,一樣可以透過一般查詢反向關聯查詢

一般查詢:

1
comments = Comment.objects.filter(post=post_1)

反向關聯查詢:

1
comments = post_1.comments.all()

與一對一關係的「區別」

訪問一對一反向關聯屬性,會得到兩種可能:

  1. 關聯物件。
  2. RelatedObjectDoesNotExist例外。

而一對多的反向關聯,則只有一種可能:關係管理器物件RelatedManager)。

RelatedManagerManager(即.objects的屬性值)類似,都是獲取 QuerySet 的「入口」。所以上述的post_1.comments.all()需要最後的all()方法,透過「關係管理器」再獲取「由關聯實例組成的 QuerySet」。

換句話說,光是呼叫post_1.comments本身,你只會得到「關係管理器」物件。記住這點,這將影響你對於「關聯不存在」時的處理。

一對多反向關聯不存在

如果post_1有可能還沒有任何 Comment 關聯實例。那我們應該怎麼樣在程式中考慮進去呢?

我們第一個想到的可能是這樣:

1
2
3
comments = post_1.comments.all()
if comments:
...

上面這樣寫確實是可以的,當 QuerySet 為空,會被視為 falsy,判斷式不成立。不過更好、更 Django 的寫法則是:

1
2
if post_1.comments.exists():
...

然後,千萬不要寫:

1
2
3
comments = post_1.comments
if comments:
...

因為此時的comments變數內容是一個關係管理器物件,它一定會被視為 truthy。意即這個判斷條件永遠會成立


以「反向關聯是否存在」為查詢條件

如果你想以「反向關聯是否存在」作為查詢的條件,比如我想查詢「沒有留言的文章」有哪些,要怎麼做呢?答案是——isnull

1
posts_without_comments = Post.object.filter(comments__isnull=True)

注意其中的雙底線,這是典型的 ORM 查詢條件使用方式。

同理,我想要查詢「有副標題(subtitle)的文章」則是:

1
posts_with_subtitle = Post.object.filter(subtitle__isnull=False)

可以看到,上面兩個例子,都用透過 Post 的「反向關聯屬性」來進行過濾查詢——就像是一個普通的欄位一樣。


結語

Django ORM 中的反向關聯,是 ORM 皇冠上的一顆明珠。

為什麼說 ORM 相對於原生 SQL 查詢更加優雅?從上述程式碼範例中,反向關聯無疑是最好的答案——像post_1.subtitlepost_1.comments.exists()這樣的語句,不僅簡潔,且非常可讀。

善用反向關聯,你將成為更加道地、成熟的 Django 開發者。


後記

我很少為自己的文章寫「後記」,不過仔細想想,為這篇好不容易才產出的文章寫點後記,也是值得的。

平常開發使用 Django ORM,其實也沒有想太多,只要「熟悉、習慣」就好。有時候用久了,對於常見的元素,甚至就不怎麼思考了。

但是!寫文章就不同了。為了向讀者解釋其中的細節,思考上不能草草帶過,至少要了解文中提到的部分,大概是怎麼一回事。

創作流程與心得

為了完成這篇文章,我實際上做了這些事:

  1. 先和 ChatGPT 討論文章架構、內容的取捨。同時我意識到了第一篇文章所提出的模型,在「一對一」部分有一開始提到的兩個設計缺陷。怎麼補救,也經過了一番取捨:
    1. 第一種做法是另外提出不同的模型作為舉例,只適用於本篇,這個解法比較簡單。
    2. 第二種做法則是重新設計模型中的「一對一」部分。雖然比較辛苦,但對強化範例程例程式碼的整體感、完成度,更有助益。顯然,我選擇了後者。
  2. 重新把塵封在 Notion 的 Django 筆記(Django ORM 部分)拿出來讀,大概有 2 萬字。雖然花時間,但我覺得很有幫助,難怪人家說寫文章一定會進步。
    1. 以前做這些筆記,也複習過幾次,但最後還是不了了之。
    2. 趁這次機會,我把它們「移植」到 Logseq 上,以「閃卡」形式重見天日。我還發現,需要做成的卡片張數,原來並沒有我想像中的多。
  3. 完成前兩步,最後才是把本文的內容生出來。但正因為有前兩步的鋪墊,這一步也走得相對踏實(雖然過程依舊不輕鬆)。

總之,寫一篇文章真是不容易呀!希望它對你有所幫助。