如何建立自訂模型欄位

簡介

模型參考 文件說明如何使用 Django 的標準欄位類別 – CharFieldDateField 等。在許多情況下,這些類別已足以滿足您的需求。然而,有時候 Django 的版本無法完全符合您的精確需求,或者您想要使用與 Django 隨附的欄位完全不同的欄位。

Django 內建的欄位類型並未涵蓋所有可能的資料庫欄位類型 – 僅涵蓋常見的類型,例如 VARCHARINTEGER。對於較少見的欄位類型,例如地理多邊形,甚至是使用者建立的類型 (例如 PostgreSQL 自訂類型),您可以定義自己的 Django Field 子類別。

或者,您可能有一個複雜的 Python 物件,可以透過某種方式序列化以符合標準的資料庫欄位類型。這是另一個 Field 子類別可以協助您在模型中使用物件的情況。

我們的範例物件

建立自訂欄位需要一些細節上的注意。為了讓您更容易理解,我們將在本文中使用一個一致的範例:包裝一個 Python 物件,該物件代表在 橋牌 一手中發出的牌。別擔心,您不必知道如何玩橋牌才能理解這個範例。您只需要知道,52 張牌會平均發給四位玩家,傳統上稱為北家東家南家西家。我們的類別如下所示

class Hand:
    """A hand of cards (bridge style)"""

    def __init__(self, north, east, south, west):
        # Input parameters are lists of cards ('Ah', '9s', etc.)
        self.north = north
        self.east = east
        self.south = south
        self.west = west

    # ... (other possibly useful methods omitted) ...

這是一個普通的 Python 類別,沒有任何 Django 特定的東西。我們希望在我們的模型中能夠執行類似這樣的操作 (我們假設模型上的 hand 屬性是 Hand 的一個實例)

example = MyModel.objects.get(pk=1)
print(example.hand.north)

new_hand = Hand(north, east, south, west)
example.hand = new_hand
example.save()

我們像其他任何 Python 類別一樣,在模型中指定和檢索 hand 屬性。訣竅在於告訴 Django 如何處理儲存和載入這樣的物件。

為了在我們的模型中使用 Hand 類別,我們完全不需要更改這個類別。這很理想,因為這表示您可以輕鬆地為現有的類別編寫模型支援,而您無法更改其原始碼。

注意

您可能只想要利用自訂資料庫欄位類型,並在模型中將資料處理為標準的 Python 類型;例如字串或浮點數。這種情況與我們的 Hand 範例相似,我們將在進行時說明任何差異。

背景理論

資料庫儲存

讓我們先從模型欄位開始。如果將其分解,模型欄位提供了一種方式,可以將一般的 Python 物件 (字串、布林值、datetime 或更複雜的物件 (如 Hand)) 轉換為處理資料庫時有用的格式,並從該格式轉換回來。(這種格式對於序列化也很有用,但正如我們稍後將看到的,一旦您控制了資料庫端,就會變得更容易)。

模型中的欄位必須以某種方式轉換為符合現有的資料庫欄位類型。不同的資料庫提供不同的有效欄位類型集,但規則仍然相同:這些是您必須使用的唯一類型。您想要儲存在資料庫中的任何內容都必須符合這些類型之一。

通常,您要麼編寫 Django 欄位來符合特定的資料庫欄位類型,要麼您需要一種將資料轉換為字串的方法。

對於我們的 Hand 範例,我們可以將牌數據轉換為一個 104 個字元的字串,方法是按照預定的順序將所有牌串聯在一起 – 例如,先是所有北家的牌,然後是東家南家西家的牌。因此,Hand 物件可以儲存到資料庫中的文字或字元欄位。

欄位類別的作用是什麼?

所有 Django 的欄位 (當我們在本文中說欄位時,我們始終指的是模型欄位,而不是 表單欄位) 都是 django.db.models.Field 的子類別。Django 記錄關於欄位的大部分資訊對於所有欄位都是通用的 – 名稱、協助文字、唯一性等等。儲存所有這些資訊是由 Field 處理的。我們稍後會詳細說明 Field 可以做什麼;目前,只要說所有內容都繼承自 Field,然後自訂類別行為的關鍵部分就足夠了。

重要的是要意識到,Django 欄位類別不是儲存在模型屬性中的內容。模型屬性包含普通的 Python 物件。當建立模型類別時,在 Meta 類別中實際上會儲存您在模型中定義的欄位類別 (如何完成此操作的確切細節在此處並不重要)。這是因為當您只是建立和修改屬性時,不需要欄位類別。相反地,它們提供了在屬性值與儲存在資料庫中或傳送到 序列化器 的內容之間進行轉換的機制。

在建立您自己的自訂欄位時,請記住這一點。您編寫的 Django Field 子類別提供了以各種方式在您的 Python 實例和資料庫/序列化器值之間進行轉換的機制 (例如,儲存值和使用值進行查詢之間存在差異)。如果這聽起來有點棘手,別擔心 – 在下面的範例中會變得更清楚。只要記住,當您想要一個自訂欄位時,通常最終會建立兩個類別

  • 第一個類別是您的使用者將會操作的 Python 物件。他們會將其指定給模型屬性,他們會從中讀取以進行顯示,諸如此類。在我們的範例中,這是 Hand 類別。

  • 第二個類別是 Field 子類別。這個類別知道如何將您的第一個類別在其永久儲存形式和 Python 形式之間來回轉換。

編寫欄位子類別

在規劃您的 Field 子類別時,請先考慮一下您的新欄位與哪個現有的 Field 類別最相似。您是否可以子類別化現有的 Django 欄位並節省一些工作?如果不行,您應該子類別化 Field 類別,所有內容都繼承自該類別。

初始化您的新欄位就是將特定於您的情況的任何引數與通用引數分開,並將後者傳遞給 Field (或您的父類別) 的 __init__() 方法。

在我們的範例中,我們將我們的欄位稱為 HandField。(最好將您的 Field 子類別稱為 <某物>Field,以便可以很容易地識別為 Field 子類別。) 它的行為不像任何現有的欄位,因此我們將直接從 Field 子類別化

from django.db import models


class HandField(models.Field):
    description = "A hand of cards (bridge style)"

    def __init__(self, *args, **kwargs):
        kwargs["max_length"] = 104
        super().__init__(*args, **kwargs)

我們的 HandField 接受大多數標準欄位選項 (請參閱下面的清單),但我們確保它具有固定的長度,因為它只需要容納 52 個牌值及其花色;總共 104 個字元。

注意

許多 Django 的模型欄位接受它們不執行的選項。例如,您可以將 editableauto_now 傳遞給 django.db.models.DateField,它會忽略 editable 參數 (設定 auto_now 表示 editable=False)。在這種情況下不會引發錯誤。

此行為簡化了欄位類別,因為它們不需要檢查不需要的選項。它們將所有選項傳遞給父類別,然後不再使用它們。您可以決定是否要讓欄位對它們選擇的選項更加嚴格,還是要使用目前欄位更寬鬆的行為。

Field.__init__() 方法採用以下參數

上述列表中未解釋的所有選項,其含義與一般 Django 欄位相同。請參閱 欄位文件 以取得範例和詳細資訊。

欄位解構

撰寫 __init__() 方法的對應是撰寫 deconstruct() 方法。它在 模型遷移 期間使用,以告知 Django 如何將新欄位的實例簡化為序列化形式 - 特別是,要傳遞哪些參數給 __init__() 以重新建立它。

如果您沒有在繼承的欄位之上新增任何額外選項,則無需撰寫新的 deconstruct() 方法。但是,如果您正在變更傳遞給 __init__() 的參數(就像我們在 HandField 中所做的那樣),您需要補充傳遞的值。

deconstruct() 會回傳一個包含四個項目的元組:欄位的屬性名稱、欄位類別的完整導入路徑、位置引數(以列表形式)和關鍵字引數(以字典形式)。請注意,這與 自訂類別deconstruct() 方法不同,後者會回傳一個包含三個項目的元組。

身為自訂欄位作者,您不需要關心前兩個值;基本的 Field 類別擁有所有程式碼來計算欄位的屬性名稱和導入路徑。但是,您必須關心位置引數和關鍵字引數,因為這些很可能是您正在變更的內容。

例如,在我們的 HandField 類別中,我們總是在 __init__() 中強制設定 max_length。基本 Field 類別上的 deconstruct() 方法會看到這一點,並嘗試在關鍵字引數中回傳它;因此,為了提高可讀性,我們可以從關鍵字引數中刪除它。

from django.db import models


class HandField(models.Field):
    def __init__(self, *args, **kwargs):
        kwargs["max_length"] = 104
        super().__init__(*args, **kwargs)

    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        del kwargs["max_length"]
        return name, path, args, kwargs

如果您新增了一個新的關鍵字引數,您需要在 deconstruct() 中撰寫程式碼,將其值放入 kwargs 中。當不需重建欄位狀態時,您也應該從 kwargs 中省略該值,例如當使用預設值時。

from django.db import models


class CommaSepField(models.Field):
    "Implements comma-separated storage of lists"

    def __init__(self, separator=",", *args, **kwargs):
        self.separator = separator
        super().__init__(*args, **kwargs)

    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        # Only include kwarg if it's not the default
        if self.separator != ",":
            kwargs["separator"] = self.separator
        return name, path, args, kwargs

更複雜的範例超出本文檔的範圍,但請記住 - 對於您的 Field 實例的任何配置,deconstruct() 必須回傳您可以傳遞給 __init__ 以重建該狀態的引數。

如果您在 Field 超類別中為引數設定新的預設值,請特別注意;您需要確保它們始終被包含,而不是在它們採用舊的預設值時消失。

此外,盡量避免以位置引數的形式回傳值;在可能的情況下,以關鍵字引數的形式回傳值,以獲得最大的未來相容性。如果您變更事物名稱的頻率高於它們在建構函式的引數列表中的位置,您可能更喜歡使用位置引數,但請記住,人們將在相當長的時間內(可能數年)從序列化版本重建您的欄位,具體取決於您的遷移持續多久。

您可以透過查看包含該欄位的遷移來查看解構的結果,並且您可以在單元測試中透過解構和重建欄位來測試解構

name, path, args, kwargs = my_field_instance.deconstruct()
new_instance = MyField(*args, **kwargs)
self.assertEqual(my_field_instance.some_attribute, new_instance.some_attribute)

不影響資料庫欄位定義的欄位屬性

您可以覆寫 Field.non_db_attrs 以自訂不影響欄位定義的欄位屬性。它在模型遷移期間用於偵測空操作的 AlterField 操作。

例如

class CommaSepField(models.Field):
    @property
    def non_db_attrs(self):
        return super().non_db_attrs + ("separator",)

變更自訂欄位的基底類別

您無法變更自訂欄位的基底類別,因為 Django 不會偵測到變更並為其建立遷移。例如,如果您從以下開始

class CustomCharField(models.CharField): ...

然後決定要改用 TextField,您無法像這樣變更子類別

class CustomCharField(models.TextField): ...

相反,您必須建立一個新的自訂欄位類別並更新您的模型以參考它

class CustomCharField(models.CharField): ...


class CustomTextField(models.TextField): ...

移除欄位 中所討論的,只要您有參考它的遷移,就必須保留原始的 CustomCharField 類別。

為您的自訂欄位建立文件

一如既往,您應該為您的欄位類型建立文件,以便使用者知道它是什麼。除了為其提供 docstring(對開發人員很有用)之外,您還可以允許管理應用程式的使用者透過 django.contrib.admindocs 應用程式查看欄位類型的簡短描述。為此,請在您的自訂欄位的 description 類別屬性中提供描述性文字。在上面的範例中,admindocs 應用程式為 HandField 顯示的描述將是「一副牌(橋牌風格)」。

django.contrib.admindocs 顯示中,欄位描述會與 field.__dict__ 內插,這允許描述包含欄位的引數。例如,CharField 的描述是

description = _("String (up to %(max_length)s)")

有用的方法

一旦您建立了 Field 子類別,您可能會考慮覆寫一些標準方法,具體取決於您欄位的行為。以下方法的列表大致按重要性遞減順序排列,因此請從頂部開始。

自訂資料庫類型

假設您建立了一個名為 mytype 的 PostgreSQL 自訂類型。您可以子類別化 Field 並實作 db_type() 方法,如下所示

from django.db import models


class MytypeField(models.Field):
    def db_type(self, connection):
        return "mytype"

一旦您有了 MytypeField,您可以在任何模型中使用它,就像任何其他 Field 類型一樣

class Person(models.Model):
    name = models.CharField(max_length=80)
    something_else = MytypeField()

如果您旨在建立一個與資料庫無關的應用程式,您應該考慮資料庫欄位類型中的差異。例如,PostgreSQL 中的日期/時間欄位類型稱為 timestamp,而 MySQL 中相同的欄位稱為 datetime。您可以在 db_type() 方法中透過檢查 connection.vendor 屬性來處理此問題。目前內建的供應商名稱有:sqlitepostgresqlmysqloracle

例如

class MyDateField(models.Field):
    def db_type(self, connection):
        if connection.vendor == "mysql":
            return "datetime"
        else:
            return "timestamp"

當框架為您的應用程式建構 CREATE TABLE 語句時(也就是當您第一次建立資料表時),Django 會呼叫 db_type()rel_db_type() 方法。當建構包含模型欄位的 WHERE 子句時(也就是當您使用 QuerySet 方法(如 get()filter()exclude())且模型欄位作為引數時),也會呼叫這些方法。

某些資料庫欄位類型接受參數,例如 CHAR(25),其中參數 25 代表最大欄位長度。在這種情況下,如果在模型中指定參數,而不是在 db_type() 方法中硬編碼,則會更加靈活。例如,擁有一個 CharMaxlength25Field,如下所示,沒有多大意義

# This is a silly example of hard-coded parameters.
class CharMaxlength25Field(models.Field):
    def db_type(self, connection):
        return "char(25)"


# In the model:
class MyModel(models.Model):
    # ...
    my_field = CharMaxlength25Field()

更好的做法是讓參數在執行時可指定 – 即,當類別被實例化時。為此,請實作 Field.__init__(),如下所示

# This is a much more flexible example.
class BetterCharField(models.Field):
    def __init__(self, max_length, *args, **kwargs):
        self.max_length = max_length
        super().__init__(*args, **kwargs)

    def db_type(self, connection):
        return "char(%s)" % self.max_length


# In the model:
class MyModel(models.Model):
    # ...
    my_field = BetterCharField(25)

最後,如果您的欄位需要非常複雜的 SQL 設定,請從 db_type() 返回 None。這會導致 Django 的 SQL 建立程式碼跳過此欄位。然後,您需要以其他方式在正確的資料表中建立欄位,但這樣做可以讓您告訴 Django 讓開。

rel_db_type() 方法會被 ForeignKeyOneToOneField 等指向另一個欄位的欄位呼叫,以決定其資料庫欄的資料類型。例如,如果您有一個 UnsignedAutoField,您也需要指向該欄位的外鍵使用相同的資料類型。

# MySQL unsigned integer (range 0 to 4294967295).
class UnsignedAutoField(models.AutoField):
    def db_type(self, connection):
        return "integer UNSIGNED AUTO_INCREMENT"

    def rel_db_type(self, connection):
        return "integer UNSIGNED"

將值轉換為 Python 物件

如果您的自訂 Field 類別處理的資料結構比字串、日期、整數或浮點數更複雜,則您可能需要覆寫 from_db_value()to_python()

如果欄位子類別存在 from_db_value(),則在從資料庫載入資料的所有情況下都會呼叫它,包括彙總和 values() 呼叫。

to_python() 會在反序列化以及表單使用的 clean() 方法期間呼叫。

一般來說,to_python() 應該能優雅地處理以下任何引數:

  • 正確類型的實例(例如,在我們持續的範例中是 Hand)。

  • 字串

  • None(如果欄位允許 null=True

在我們的 HandField 類別中,我們將資料儲存在資料庫中作為 VARCHAR 欄位,因此我們需要在 from_db_value() 中處理字串和 None。在 to_python() 中,我們也需要處理 Hand 實例。

import re

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _


def parse_hand(hand_string):
    """Takes a string of cards and splits into a full hand."""
    p1 = re.compile(".{26}")
    p2 = re.compile("..")
    args = [p2.findall(x) for x in p1.findall(hand_string)]
    if len(args) != 4:
        raise ValidationError(_("Invalid input for a Hand instance"))
    return Hand(*args)


class HandField(models.Field):
    # ...

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        return parse_hand(value)

    def to_python(self, value):
        if isinstance(value, Hand):
            return value

        if value is None:
            return value

        return parse_hand(value)

請注意,我們總是從這些方法返回 Hand 實例。這是我們想要儲存在模型的屬性中的 Python 物件類型。

對於 to_python(),如果在值轉換期間發生任何錯誤,您應該引發 ValidationError 例外。

將 Python 物件轉換為查詢值

由於使用資料庫需要雙向轉換,如果您覆寫了 from_db_value(),您也必須覆寫 get_prep_value(),將 Python 物件轉換回查詢值。

例如

class HandField(models.Field):
    # ...

    def get_prep_value(self, value):
        return "".join(
            ["".join(l) for l in (value.north, value.east, value.south, value.west)]
        )

警告

如果您的自訂欄位使用 MySQL 的 CHARVARCHARTEXT 類型,您必須確保 get_prep_value() 總是返回字串類型。當針對這些類型執行查詢且提供的值是整數時,MySQL 會執行彈性且出乎意料的匹配,這可能會導致查詢在其結果中包含意外的物件。如果您總是從 get_prep_value() 返回字串類型,就不會發生這個問題。

將查詢值轉換為資料庫值

某些資料類型(例如,日期)必須採用特定格式才能被資料庫後端使用。get_db_prep_value() 是應該進行這些轉換的方法。將用於查詢的特定連線會以 connection 參數傳遞。這允許您在需要時使用後端特定的轉換邏輯。

例如,Django 將以下方法用於其 BinaryField

def get_db_prep_value(self, value, connection, prepared=False):
    value = super().get_db_prep_value(value, connection, prepared)
    if value is not None:
        return connection.Database.Binary(value)
    return value

如果您的自訂欄位在儲存時需要特殊轉換,而該轉換與用於一般查詢參數的轉換不同,您可以覆寫 get_db_prep_save()

儲存前預處理值

如果您想在儲存之前預處理值,可以使用 pre_save()。例如,Django 的 DateTimeField 使用此方法在 auto_nowauto_now_add 的情況下正確設定屬性。

如果您確實覆寫此方法,則必須在最後返回屬性的值。如果您對值進行任何更改,也應該更新模型的屬性,以便持有模型參考的程式碼始終看到正確的值。

指定模型欄位的表單欄位

若要自訂 ModelForm 使用的表單欄位,您可以覆寫 formfield()

表單欄位類別可以透過 form_classchoices_form_class 引數指定;如果欄位指定了選項,則使用後者,否則使用前者。如果未提供這些引數,則將使用 CharFieldTypedChoiceField

所有 kwargs 字典都會直接傳遞給表單欄位的 __init__() 方法。通常,您只需要為 form_class(以及可能還有 choices_form_class)引數設定一個良好的預設值,然後將進一步的處理委派給父類別。這可能需要您編寫自訂表單欄位(甚至是一個表單小工具)。請參閱 表單文件 以取得相關資訊。

繼續我們持續的範例,我們可以將 formfield() 方法寫成:

class HandField(models.Field):
    # ...

    def formfield(self, **kwargs):
        # This is a fairly standard way to set up some defaults
        # while letting the caller override them.
        defaults = {"form_class": MyFormField}
        defaults.update(kwargs)
        return super().formfield(**defaults)

這假設我們已匯入 MyFormField 欄位類別(它有自己的預設小工具)。本文檔不涵蓋編寫自訂表單欄位的詳細資訊。

模擬內建欄位類型

如果您已建立 db_type() 方法,則無需擔心 get_internal_type() – 它不會被大量使用。但是,有時您的資料庫儲存類型與其他某些欄位相似,因此您可以使用其他欄位的邏輯來建立正確的欄位。

例如

class HandField(models.Field):
    # ...

    def get_internal_type(self):
        return "CharField"

無論我們使用哪個資料庫後端,這都意味著 migrate 和其他 SQL 命令會建立正確的欄位類型來儲存字串。

如果 get_internal_type() 傳回的字串,對於您使用的資料庫後端而言,並非 Django 已知的類型(也就是說,它沒有出現在 django.db.backends.<db_name>.base.DatabaseWrapper.data_types 中),序列化工具仍然會使用該字串,但預設的 db_type() 方法會傳回 None。請參閱 db_type() 的說明文件,瞭解為什麼這可能會有用。如果您打算在 Django 之外的其他地方使用序列化工具的輸出,將描述性字串作為欄位的類型放入序列化工具中,會是一個有用的想法。

轉換用於序列化的欄位資料

若要自訂序列化工具如何序列化值,您可以覆寫 value_to_string()。使用 value_from_object() 是在序列化之前取得欄位值的最佳方式。例如,由於 HandField 仍然使用字串來儲存其資料,我們可以重複使用一些現有的轉換程式碼。

class HandField(models.Field):
    # ...

    def value_to_string(self, obj):
        value = self.value_from_object(obj)
        return self.get_prep_value(value)

一些一般建議

撰寫自訂欄位可能是一個棘手的過程,特別是當您在 Python 類型與資料庫及序列化格式之間進行複雜轉換時。以下是一些讓事情更順利的提示。

  1. 參考現有的 Django 欄位(位於 django/db/models/fields/__init__.py)來尋求靈感。嘗試尋找一個與您想要的類似的欄位,並稍微擴充它,而不是從頭開始建立一個全新的欄位。

  2. 在您作為欄位包裝的類別上放置一個 __str__() 方法。在許多地方,欄位程式碼的預設行為是對值呼叫 str()。(在本文件的範例中,value 會是一個 Hand 實例,而不是 HandField)。因此,如果您的 __str__() 方法自動轉換為 Python 物件的字串形式,您可以節省很多工作。

撰寫 FileField 子類別

除了上述方法之外,處理檔案的欄位還有一些其他必須考慮的特殊要求。由 FileField 提供的大部分機制,例如控制資料庫儲存和擷取,可以保持不變,讓子類別處理支援特定檔案類型的挑戰。

Django 提供了一個 File 類別,該類別用作檔案內容和操作的代理。可以對其進行子類別化,以自訂檔案的存取方式以及可用的方法。它位於 django.db.models.fields.files,其預設行為在 檔案說明文件 中說明。

一旦建立了 File 的子類別,就必須告知新的 FileField 子類別要使用它。為此,請將新的 File 子類別指派給 FileField 子類別的特殊 attr_class 屬性。

一些建議

除了上述細節之外,還有一些準則可以大大提高欄位程式碼的效率和可讀性。

  1. Django 自身的 ImageField 的原始碼(位於 django/db/models/fields/files.py)是一個很好的範例,說明如何子類別化 FileField 以支援特定類型的檔案,因為它結合了上述所有技術。

  2. 盡可能快取檔案屬性。由於檔案可能儲存在遠端儲存系統中,因此檢索它們可能會花費額外的時間,甚至金錢,這並不總是必要的。一旦檢索到檔案以取得有關其內容的一些資料,請盡可能快取該資料,以減少在後續呼叫該資訊時必須檢索檔案的次數。

返回頂部