Skip to content

Instantly share code, notes, and snippets.

@altnight
Created August 22, 2024 06:07
Show Gist options
  • Save altnight/f087686360e4706f21308d727508c2c8 to your computer and use it in GitHub Desktop.
Save altnight/f087686360e4706f21308d727508c2c8 to your computer and use it in GitHub Desktop.
2024/08/22 Djangoのモデルのカラムを専用Objectで扱いコードを堅牢にする

2024/08/22 Djangoのモデルのカラムを専用Objectで扱いコードを堅牢にする

課題と結論

  • 課題: Djangoのモデルのフィールドにおいて、プリミティブ(int/str)ではなくなんらかの独自のObjectで扱いたい
  • 結論:
    • 定数値: django.db.models.enums.TextChoicesdjango-enum を使用する
    • ValueObject: dataclassで定義し、Djangoのカスタムモデルフィールドを定義する

前提

  • 複雑なアプリケーション開発ではなんらかまとまりで意味を持つ値が必要になることが多々ある
    • 例: 「桁数が20桁で有意コードを含む注文番号」「外部連携で必要になる特別な年月」など
  • そのときに、strやintのようなプリミティブしか使用しないと、コードの表現力が足りない
    • リファクタリング案件では、素朴な関数化とプリミティブな値と大量のNull許可が多く防御的コードが増え、だいたいにおいて複雑性の制御に失敗している
  • そのため、いわゆるValueObject的な概念の処理単位を定義したくなる
    • 一応戦術的ドメイン駆動開発みたいな文脈っぽい内容で書いているつもりだが、あまり厳密には考えていない。とはいえ、多かれ少なかれどんなコードでも必要な内容にはなる

DB格納時に文字列/数値のどちらで格納するか

  • 問題: Prefecture.hokkaido というような定数のDBではどの値で格納するか
    • 1 という数値で格納するか
    • hokkaido という文字列で格納するか
  • 結論: 文字列の方がよい
  • 理由:
    • DBを直接参照する時に数値で格納してあると、仕様書か実装されたコードと対比させる必要があり、手間が増える
    • DBに数値で格納してもほぼ全ての場合においてデータ量の削減にはならない
      • データ量の削減等を考えるなら先に他にやることがある
    • 数値で表現すると数値に意味を与えるコードが生まれることがある
      • 例: 0-1 ならばエラーとか。2桁など桁数で管理しようとして後からその意図を無視したコードが書かれるとか
      • そういう議論やコードが生まれる前に、文字列の値をすべて個別に指定した方が確実

定数値: django-enum を使用する

インストール

$ pip install django-enum

実装例: django-enum の適用

import django_enum
from django.db.models import enums


class NameType(enums.TextChoices):
    """名義"""

    personal = "personal", "個人"
    business_normal = "business_normal", "法人"
    business_special = "business_special", "法人(特別対応)"
    
    @property
    def special_code(self) -> str:
        cls = NameType
        d = {
            cls.personal: "1A2B",
            cls.business_normal: "3C4D",
            cls.business_special: "XXXX",
        }
        return d[self]
    
    @property
    def is_business(self) -> bool:
        cls = NameType
        return self in [cls.business_normal, cls.business_special]


class Account(BaseModel):
    name_type = django_enum.EnumCharField("名義", max_length=32, enum=NameType)

実装例: 使用

account = Account.objects.craete(name_type="personal")
account.name_type # `"personal"`という文字列ではなく、 `NameType.personal` が取得できる
account.name_type.special_code # -> `"1A2B"`

ValueObjectをカスタムモデルフィールドに適用する

例: 年月

YearMonth型をつくる

  • 年月であり日を含まない。そのため、日付型にはしたくない
    • ただし、内部的な値の比較では日付型を流用してもよい
  • Pythonでは年や月を個別で扱いたい
    • Model.some_year_monthで年月(YearMonth)を取得
    • Model.some_year_month.yearで年(int)を取得
    • Model.some_year_month.monthで月(int)を取得
    • 必ず文字列6桁であり、年月の数値が日付的に正しいことを保証したい
  • DBには YYYYMM のような6文字の文字列で格納する
  • SQLでは x_column = 202406 のように6桁の文字列で検索したい

実装例: ValueObject

  • immutableなので frozen=True
  • 1つの引数しかとらない前提なので kw_only=False
  • 文字列が来ることを保証する
    • 6桁であることを保証する
    • 日時でパースできることを保証する
  • 年と月をそれぞれ int で取得できる
from dataclasses import dataclass

@dataclass(frozen=True, kw_only=False)
class YearMonth:
    """年月"""

    value: str

    def __post_init__(self):
        self._as_date(self.value)  # 今回はパースできるかどうかで保証
        if len(self.value)  != 6:
            raise ValueError("6桁の文字列を指定してください")

    @property
    def as_date(self) -> tuple[int, int]:
        d = self._as_date(self.value)
        return d.year, d.month

    @classmethod
    def _as_date(cls, v: str):
        d = datetime.datetime.strptime(v, "%Y%m")
        return datetime.date(year=d.year, month=d.month, day=1)

実装例: カスタムフィールド

  • max_lengthとvalidatorsはkwargsでの指定を禁止してもよい
from django.db import models
from django.core.validators import MinLengthValidator


class YearMonthField(models.CharField):
    def __init__(self, *args, **kwargs):
        kwargs["validators"] = [MinLengthValidator(6)]
        kwargs["max_length"] = 6
        super().__init__(*args, **kwargs)

    def to_python(self, value: str | YearMonth | None) -> YearMonth | None:
        if value is None:
            return None
        if isinstance(value, YearMonth):
            return value
        return YearMonth(value)

    def from_db_value(self, value: str, expression, connection) -> YearMonth | None:
        # DB -> Python
        if value is None:
            return None
        return YearMonth(value)

    def get_prep_value(self, value: str | YearMonth | None) -> str | None:
        # Python -> DB
        if value is None:
            return None
        if isinstance(value, YearMonth):
            return value.value
        return value
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment