Django's Content Type

A Generic Relation Between a Model and other Models

July 17, 2023, 7:13 p.m.
Django

What is Content Type?

Content Type Framework是Django提供的一種Application,其目的是為了讓你追蹤所有安裝在Django Project下面的Application的model們。它提供了一種更高階層的通用介面讓你在model之間互動。

官方文件描述第一次看的時候很抽象,但是實際認識背後的機制後會發現這是很有意思的功能。

Content Type的本質也是一個Model,可以透過觀察`django.contrib.contenttypes.models.ContentType
得到他的定義:

class ContentType(models.Model):
    app_label = models.CharField(max_length=100) : CharField
    model = models.CharField(_("python model class name"), max_length=100) : CharField
    objects = ContentTypeManager() : ContentTypeManager
    ...

用Sqlite偷看一下他在資料庫的樣子

sqlite> select * from django_content_type;
id  app_label     model
--  ------------  -----------
1   admin         logentry
2   auth          permission
3   auth          group
4   auth          user
5   contenttypes  contenttype
6   sessions      session
7   YourApp       model_1_of_your_app
8   YourApp       model_2_of_your_app

可以看到ContentType Model內紀錄了每一個已安裝的Application下的所有Model。每當你安裝一個新的Model時,ContentType也會自動的在資料庫內初始化一個該model的Instance。

ContentType的Instance本身除了可以返回他所代表的Model Class以外,甚至可以Query該模型所擁有的物件們,到這邊應該已經不難想像那句「更高層級的通用介面」的意思。

class ContentType(models.Model):
    ...

    @property
    def name(self):
        ...

    @property
    def app_labeled_name(self):
        ...

    def model_class(self): -> (Unknown | None)
        ...

    def get_object_for_this_type(self, **kwargs): -> Unknown
        ...

    def get_all_objects_for_this_type(self, **kwargs): -> Unknown
        ...

    def natural_key(self): -> None
        ...

另外ContentType自帶了一個客製化的Model Manager,提供了跟ContentType互動的一些方法。

django_content_type
以上圖為例子:
今天App1-Model1就是建立了一個Generic RelationApp2-Model2
透過content_type屬性,id為99的Model 1 Instance知道了,他跟content type id 為3的Model(也就是App2-Model2)有關聯。至於是跟該Model下面哪一個Instance有關聯?那就是看object_id

Generic Relation

接下來就要帶出跟ContentType有高度密切的關聯另外兩個東西,分別是GenericRelationGenericForeignKey

GenericRelation指的就是你的Model跟ContentType Model之間的關聯。
(或者是說,你當前正在用的Model跟其他你已經安裝的Model之間的關聯)

Generic Foreign Key

這裡使用官方文件的例子,如果今天我有個Model稱為TaggedItem,這是一種通用型的Tag物件,可以幫任何的東西貼上標籤例如User, Post, Image…之類的。但如果只使用Foreign Key是不夠的(因為只能Tag到一個Model而已)。但我們可以使用GenericForeignKey來實現在不同的Model上都貼上這種通用標籤,只要在TagItem Model裡面加入些屬性即可:

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models

class TaggedItem(models.Model):
    tag = models.SlugField()
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey("content_type", "object_id")

    def __str__(self):
        return self.tag

    class Meta:
        indexes = [
            models.Index(fields=["content_type", "object_id"]),
        ]
  • 我們在TaggedItem裡面多了幾個屬性:
    • content_type:一把指向ContentType Instance的ForeignKey。
    • object_id:你想建立關係的物件的primary key。
      • 在這個範例中,就會是某篇文章的pk
    • content_object:一把GenericForeignKey,把他視為可以直接訪問被你關聯的Instance的一個捷徑即可。

有了這樣的設定後,就可以像普通的ForeignKey一樣做使用:

>>> from django.contrib.auth.models import User
>>> guido = User.objects.get(username="Guido")
>>> t = TaggedItem(content_object=guido, tag="bdfl")
>>> t.save()
>>> t.content_object
<User: Guido>
  • 產生一個名稱為Guido的使用者
  • 透過Generic Relation幫他上一個通用型的標籤bdfl

但基於GenericForeignKey的實作方式,我們沒有辦法透過filter()或是exclude()之類的指令來反向的使用content_object找到使用者,例如想找出用戶是guido的Tag們時,下面的指令會失敗:

# This will fail
>>> TaggedItem.objects.filter(content_object=guido)
# This will also fail
>>> TaggedItem.objects.get(content_object=guido)

Reverse Generic Relation

為了解決上面提到的反向搜尋的那個問題,我們可以在目標Model上面添加一個反向的關聯(reverse generic realtionship),這裡用一個新的目標Model Bookmark為例:

from django.contrib.contenttypes.fields import GenericRelation
from django.db import models

class Bookmark(models.Model):
    url = models.URLField()
    tags = GenericRelation(TaggedItem)
  • tags屬性,被設定成了GenericRelation

我們用類似上面幫用戶貼標籤的方式,也幫BookMark貼個標籤:

>>> b = Bookmark(url="https://www.djangoproject.com/")
>>> b.save()
>>> t1 = TaggedItem(content_object=b, tag="django")
>>> t1.save()
>>> t2 = TaggedItem(content_object=b, tag="python")
>>> t2.save()
>>> b.tags.all()
<QuerySet [<TaggedItem: django>, <TaggedItem: python>]>

然後原本不能用的filter之類的Query指令就可以使用:

>>> # Get all tags belonging to bookmarks containing `django` in the url
>>> TaggedItem.objects.filter(bookmark__url__contains="django")
<QuerySet [<TaggedItem: django>, <TaggedItem: python>]>

總結

最後稍微做個總結,剛理解完ContentTypeGeneric Relation後會感覺他提供的功能跟ManyToManyField好像有點類似,但實際上用途不同。ManyToMany針對的是兩個Model之間的多對多關係。GenericRelation針對的是一個Model與其他多個Model之間的關係(同時可以用它實現ManyToMany的行為)。

如果今天想設計一個通用型的Model,讓他可以在其他不同的Model間作為一種屬性存在的話,那就可以考慮使用Generic Relation來做實現他的功能。

Tags:

Django