Django ORM:反向關聯(Reverse relationship)介紹
Let's Django!
2024/04/18
:重新編輯全文,提高可讀性。
這是 Django Tutorial 系列連載的第 3 篇。
搭配學習的範例程式碼,可參考 GitHub 專案:Django-Tutorial。
系列:Django ORM 外鍵教學
- Django ORM 外鍵教學:設定一對一、一對多關聯
- Django ORM:反向關聯(Reverse relationship)介紹
- Django ORM 外鍵查詢指南
前言
上一篇我們介紹完 Django ORM 的關聯設定,接下來本應該要進入查詢部分。不過,由於「反向關聯」在 ORM 查詢中扮演著十分重要的角色。
所以我決定專門寫這個(中)篇,好好介紹 Django ORM 中的「反向關聯」。
本文可以視為是第一篇的「補充」——對「反向關聯屬性」多加著墨。
不用說,這篇文章需要你先充分理解第一篇的內容,再行閱讀。尤其是該文中的這三個部分:
請務必熟悉。
理解反向關聯
我們可以從「正向關聯」來比較反向關聯,會更好理解。
在 Django ORM 中,正向關聯指的是那些由我們「明示」定義的「關聯」欄位,比如ForeignKey
、OneToOneField
等欄位。
1 | class Comment(models.Model): |
正向關聯屬性的最大特色是,它對應著資料庫 table 中的「外鍵」欄位。換句話說,它和 Django model 中的其它欄位一樣,都是「實體」的。
反向關聯是「虛擬」的
而反向關聯是「虛擬」的。
所謂的「虛擬」,是指反向關聯並不直接對應於資料庫中的一個實際欄位。它只存在於 Django ORM 層面上,作為模型關係的一部分。
這種設計使得我們可以在不增加額外資料庫欄位的情況下,輕鬆地管理和查詢模型間的關係與對應的實例。
換言之,即使沒有反向關聯,我們還是可以透過標準 ORM 語法,查詢想要的資料——只是比較麻煩!
反向關聯在「快速獲得關聯實例」這個需求場景,大大突顯了 ORM 查詢相對於原生 SQL 查詢的便利性。也讓你多增加了一個使用 ORM 的理由。
反向關聯屬性的返回值
根據關聯的類型,反向關聯的「屬性值」會有所不同。
在一對一關係中,反向關聯屬性返回的是一個的關聯模型實例。在一對多關係中,返回的是一個QuerySet
(嚴格來說其實是關係管理器——RelatedManager
),代表所有相關聯的模型實例集合。
這種彈性使得反向關聯成為 Django ORM 中一個極其強大且靈活的存在。
好,講完了定義,我們趕緊來看,反向關聯屬性在實務上究竟是如何被使用。以及使用上的注意事項。
不過在此之前,我必須對原來範例程式碼中的 model 結構,做出一些調整。
範例程式碼模型調整
我要變更其中的「一對一」model 關係,因為原來的設計有兩個比較大的缺陷。我們先看看舊的程式碼:
1 | class Post(models.Model): |
第一個缺陷,是 Post 和 Title 的一對一關係,有違現實。
把 Title 變成一個關聯物件,是非常少見的。這不僅會讓讀者「難以想像」,無法感同身受。後續使用這模型來實作 API 時,這個設計不良問題會更加突顯。
所以,還是改成比較符合現實的版本——但又不能脫離「部落格文章關係模型」這個大框架,如何再想一個更「有感」的一對一關係,不禁又讓我苦思了一段時間。
和 ChatGPT 討論很多可能,始終找不到非常適合的例子。最後我決定這樣:先把 Title 改為 Post 的一個欄位,這是比較尋常且合理的做法。
用 Subtitle 取代 Title
一對一範例部分,改用「Subtitle」模型替代。這樣一來,Post 就有了一個「可選」的副標題。
沒錯,為了突顯「反向關聯不存在」這個議題,Subtitle 最好設計成「可選」的。也就是不一定每篇文章都有關聯的 Subtitle——有時候有、有時候沒有。
原來的「Post - Title」關聯,就不是「可選」的——兩者都一定要有。不能呈現一對一關係不存在時的情境,這是舊程式碼第二個缺陷。
Subtitle 模型介紹
我們來到更新後的業務邏輯中。每一篇部落格文章,都一定要有標題,所以標題現在只是 Post 模型的一個欄位而已。
但是,如果你願意,你可以為這篇文章加上「副標題」,也就是關聯 Subtitle。這完全是「可選」的,加不加隨你。
如果你用過寫作平台 Medium,就知道它的文章正是「副標題可選」的設定。
儘管新設計並非完美,但顯然比原來的設計更加合理。
總之,我們只需知曉一件事:Post 可能有關聯的 Subtitle,也可能沒有。並且兩者是「一對一」關係。
新模型關聯
如上述修正後,新的models.py
內容如下:
1 | # 文章 |
不知道你覺得如何?我感覺更加井然有序了!很適合用來作為教學文章解說的範例。
接下來,我們就用上述的程式碼實例,來一一解說反向關聯。
一對一反向關聯
請看 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 | try: |
其中ObjectDoesNotExist
是RelatedObjectDoesNotExist
的父類別,因為你無法直接引用RelatedObjectDoesNotExist
。
這種寫法雖然不算優雅,但它明確地表達了你的意圖。
一對多反向關聯
一對多關係,我們要把目光放到 Post 與 Comment 這兩個模型。
其中 Post 是「一方」,而 Comment 則是「多方」:一篇文章可以有多則留言,但一則留言只能屬於某一篇文章。
一對多關係中,ForeignKey
欄位肯定是實作在「多方」,所以上述程式碼,定義這個「外鍵」欄位的模型是 Comment:
1 | class Comment(models.Model): |
一樣,我們假設上述模型實例分別為post_1
、comment_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() |
與一對一關係的「區別」
訪問一對一反向關聯屬性,會得到兩種可能:
- 關聯物件。
RelatedObjectDoesNotExist
例外。
而一對多的反向關聯,則只有一種可能:關係管理器物件(RelatedManager
)。
RelatedManager
和Manager
(即.objects
的屬性值)類似,都是獲取 QuerySet 的「入口」。所以上述的post_1.comments.all()
需要最後的all()
方法,透過「關係管理器」再獲取「由關聯實例組成的 QuerySet」。
換句話說,光是呼叫post_1.comments
本身,你只會得到「關係管理器」物件。記住這點,這將影響你對於「關聯不存在」時的處理。
一對多反向關聯不存在
如果post_1
有可能還沒有任何 Comment 關聯實例。那我們應該怎麼樣在程式中考慮進去呢?
我們第一個想到的可能是這樣:
1 | comments = post_1.comments.all() |
上面這樣寫確實是可以的,當 QuerySet 為空,會被視為 falsy,判斷式不成立。不過更好、更 Django 的寫法則是:
1 | if post_1.comments.exists(): |
然後,千萬不要寫:
1 | comments = post_1.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.subtitle
和post_1.comments.exists()
這樣的語句,不僅簡潔,且非常可讀。
善用反向關聯,你將成為更加道地、成熟的 Django 開發者。
後記
我很少為自己的文章寫「後記」,不過仔細想想,為這篇好不容易才產出的文章寫點後記,也是值得的。
平常開發使用 Django ORM,其實也沒有想太多,只要「熟悉、習慣」就好。有時候用久了,對於常見的元素,甚至就不怎麼思考了。
但是!寫文章就不同了。為了向讀者解釋其中的細節,思考上不能草草帶過,至少要了解文中提到的部分,大概是怎麼一回事。
創作流程與心得
為了完成這篇文章,我實際上做了這些事:
- 先和 ChatGPT 討論文章架構、內容的取捨。同時我意識到了第一篇文章所提出的模型,在「一對一」部分有一開始提到的兩個設計缺陷。怎麼補救,也經過了一番取捨:
- 第一種做法是另外提出不同的模型作為舉例,只適用於本篇,這個解法比較簡單。
- 第二種做法則是重新設計模型中的「一對一」部分。雖然比較辛苦,但對強化範例程例程式碼的整體感、完成度,更有助益。顯然,我選擇了後者。
- 重新把塵封在 Notion 的 Django 筆記(Django ORM 部分)拿出來讀,大概有 2 萬字。雖然花時間,但我覺得很有幫助,難怪人家說寫文章一定會進步。
- 以前做這些筆記,也複習過幾次,但最後還是不了了之。
- 趁這次機會,我把它們「移植」到 Logseq 上,以「閃卡」形式重見天日。我還發現,需要做成的卡片張數,原來並沒有我想像中的多。
- 完成前兩步,最後才是把本文的內容生出來。但正因為有前兩步的鋪墊,這一步也走得相對踏實(雖然過程依舊不輕鬆)。
總之,寫一篇文章真是不容易呀!希望它對你有所幫助。