何かを書き留める何か

数学や読んだ本について書く何かです。最近は社会人として生き残りの術を学ぶ日々です。

DjangoのModelとChoices

まとめ

  • Choices クラスの登場により、ModelのFieldにある choices を指定しやすくなった。
  • しかし、Choices クラスを使っても、ModelのFieldにある choices では、データベースに入る値を制限できない。
  • CHECK制約を使えばデータベースに入る値を制限できる、Choices クラスの属性を活用することもできる。
  • Choices クラスに __empty__ 属性を指定した場合は少し注意が必要かもしれない。
  • ModelのFieldにある choices を指定した状態でModelFormを実装すると、期待するバリデーションを備えたFormができる。

愚かなる者よ、何故に『戦い』に身を置く?

  • 何かを守るためだ
  • そういう話は……興味ない

選択肢を出したのだから選択肢から選んでよ

Django 3.0から、Modelを定義する際に Choices クラスが使えるようになった。 以前から、Modelの Field クラスには choices 引数を指定できた。 choices 引数の渡し方は基本的にタプルのリストであり、単純な構造ながら面倒であった。 Choices クラスは実質的に列挙型であり、Fieldの選択肢を列挙型で指定できる。 タプルのリストと比較して、選択肢同士の関係が列挙型でより密接になり、扱いやすくなったと言える。

次のような例を考える。

from django.db import models


class Number(models.IntegerChoices):
    ACE = 1
    JACK = 11
    QUEEN = 12
    KING = 13

    __empty__ = "選択してください"


class Suit(models.TextChoices):
    DIAMOND = "♢", "ダイヤ"
    SPADE = "♠", "スペード"
    HEART = "♡", "ハート"
    CLUB = "♣", "クラブ"

    __empty__ = "選択してください"


class Card(models.Model):
    number = models.IntegerField(choices=Number)
    suit = models.CharField(max_length=2, choices=Suit)

    def __str__(self) -> str:
        return f"{self.suit}{self.number}"

トランプのカードを想定した(説明するためだけに存在する) Card Modelである。 Card モデルはフィールドとして数値とスートを持つ。

Fieldchoices に期待していたもの

number = models.IntegerField(choices=Number) と書いたからには、 Card モデルのレコードの number には1 , 11, 12, 13以外の数字は入って欲しくない、と思うだろう。 1 , 11, 12, 13以外の数字が指定された場合はよしなに弾いてくれる、と思うだろう。 しかし、思うだけでは何も進まない。 本当にそうなのか、ユニットテストを書いてみよう。

import pytest
from django.db.utils import DatabaseError

from ..models import Card, Number, Suit


@pytest.mark.django_db
class TestCardModel:
    @pytest.mark.parametrize("number", Number)
    @pytest.mark.parametrize("suit", Suit)
    def test_card_creation(self, number: Number, suit: Suit):
        card = Card.objects.create(number=number, suit=suit)

        assert card.number == number
        assert card.suit == suit
        assert str(card) == f"{suit}{number}"

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
        with pytest.raises(DatabaseError):
            Card.objects.create(number=number, suit=suit)

奇妙なデータは作られないと信じているが、果たしてどうだろうか。

(.venv) $ tox -e test
test: commands[0]> pytest .

...
___________________________________________________________ TestCardModel.test_invalid_argument[1-\u2605] ____________________________________________________________ 

self = <sample.tests.test_models.TestCardModel object at 0x000001E547E5E060>, number = Number.ACE, suit = '★'

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
>       with pytest.raises(DatabaseError):
E       Failed: DID NOT RAISE <class 'django.db.utils.DatabaseError'>

sample\tests\test_models.py:20: Failed
___________________________________________________________ TestCardModel.test_invalid_argument[14-\u2660] ___________________________________________________________ 

self = <sample.tests.test_models.TestCardModel object at 0x000001E547E5DF70>, number = 14, suit = Suit.SPADE

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_argument(self, number: Number, suit: Suit):
>       with pytest.raises(DatabaseError):
E       Failed: DID NOT RAISE <class 'django.db.utils.DatabaseError'>

sample\tests\test_models.py:20: Failed

期待していた動作は、想定していない値を入れたら弾く、である。 しかし、ユニットテストの結果からわかるのは、Card Modelが想定していない値を受け入れていることである。

CHECK制約

choices は何をするのか。 Djangoの公式ドキュメントを確認してみよう。

choices が指定された場合、 モデルのバリデーション によって強制的に、デフォルトのフォームウィジェットが通常のテキストフィールドの代わりにこれらの選択肢を持つセレクトボックスになります。

つまり、デフォルトのフォームウィジェットはセレクトボックスになるが、Modelのフィールドに渡せる値を制限することはできない。 我々は ドキュメントを碌に読まず、choices を過信していたようだ。

それではどうすればよいのか。 CHECK制約である。

Card モデルを次のように変更しよう。

class Card(models.Model):
    number = models.IntegerField(choices=Number)
    suit = models.CharField(max_length=2, choices=Suit)

    class Meta:
        constraints = [
            models.CheckConstraint(
                check=models.Q(number__in=Number.values), name="number_check"
            ),
            models.CheckConstraint(
                check=models.Q(suit__in=Suit.values), name="suit_check"
            ),
        ]

    def __str__(self) -> str:
        return f"{self.suit}{self.number}"

このように、Choices をCHECK制約に渡せば、Modelが受け入れる値の種類を制限できる。

注意点

注意点として、 Choices クラスに __empty__ 属性を指定した状態でCHECK制約に渡してしまうと、制約の条件としてNoneを許容してしまう事象が発生する。

        migrations.AddConstraint(
            model_name="card",
            constraint=models.CheckConstraint(
                check=models.Q(("number__in", [None, 1, 11, 12, 13])),
                name="number_check",
            ),
        ),
        migrations.AddConstraint(
            model_name="card",
            constraint=models.CheckConstraint(
                check=models.Q(("suit__in", [None, "♢", "♠", "♡", "♣"])),
                name="suit_check",
            ),
        ),

今回のケースだと、 numbersuit いずれのフィールドもNOT NULLであるため、None を渡しても期待通りエラーが発生するが、NULLを許容する場合は素通りしてしまう。 実害は(おそらく)ないが、念のため気を付けたほうが良いだろう。

ChoicesModelForm

以上の例だけだと、Modelのフィールドにchoicesを渡すのはそれほど有益ではない、と感じるかもしれない。 choicesChoices の真価はModelFormにある。

Card Modelに対応するModelFormを書く。

from django.forms import ModelForm

from .models import Card


class CardForm(ModelForm):
    class Meta:
        model = Card
        fields = ["number", "suit"]

これに対応するユニットテストも書く。

import pytest

from ..forms import CardForm
from ..models import Number, Suit


@pytest.mark.django_db
class TestCardForm:
    @pytest.mark.parametrize("number", Number)
    @pytest.mark.parametrize("suit", Suit)
    def test_valid_form(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert form.is_valid()

        card = form.save()
        assert card.number == number
        assert card.suit == suit

    def test_blank_data(self):
        form = CardForm(data={})

        assert not form.is_valid()
        assert len(form.errors) == 2

    @pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
    def test_invalid_data(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

    @pytest.mark.parametrize("number,suit", ((None, Suit.SPADE), (Number.ACE, None)))
    def test_invalid_data_with_none(self, number: Number, suit: Suit):
        form_data = {"number": number, "suit": suit}
        form = CardForm(data=form_data)

        assert not form.is_valid()
        assert len(form.errors) == 1

このテストは期待通りに動く。 つまり、Modelのフィールドのchoices引数にChoicesクラス(または従来からあるリストのタプル)を渡した上で、Modelに対応するModelFormを書くと、我々が想像していたchoicesのバリデーションが手に入る。 適切なModelFormが手に入るのだ。 ユーザから入力を受け取るような場面では役に立つだろう。 ただし、上記の例の通り、CHECK制約を設定しない限り、フォームを使わずにCard.objects.create()を使うと想定していない値を持つCardのレコードが生成できてしまう点には注意が必要である。

まとめ(2回目)

  • Choices クラスの登場により、ModelのFieldにある choices を指定しやすくなった。
  • しかし、Choices クラスを使っても、ModelのFieldにある choices では、データベースに入る値を制限できない。
  • CHECK制約を使えばデータベースに入る値を制限できる、Choices クラスの属性を活用することもできる。
  • Choices クラスに __empty__ 属性を指定した場合は少し注意が必要かもしれない。
  • ModelのFieldにある choices を指定した状態でModelFormを実装すると、期待するバリデーションを備えたFormができる。

Pythonの列挙型と型チェッカについて

言いたいこと

  • Pythonの列挙型を定義する方法としてクラスと関数呼び出しの2種類存在する。
  • 関数呼び出しで列挙型を定義するべき状況とは、列挙型を動的に制御したい状況である。
  • しかし、関数呼び出しで列挙型を定義すると、mypyを通して型チェックをする際に都合が悪い。
  • 列挙型を動的に制御したい場合は列挙型を使わずに素直に辞書を使う。

列挙型とは

Pythonの公式ドキュメントには、列挙型について次のように説明している。

一意の値に紐付けられたシンボリックな名前の集合です

要は、まとめて扱いたい定数の集まりである。

列挙型の定義方法

Pythonには列挙型の定義方法として、クラスによる定義、関数呼び出しによる定義の2種類存在する。 公式ドキュメントの例に基づいて、それぞれの例を示す。

クラスによる定義

import enum


class Planet(enum.Enum):
    MERCURY = (3.303e23, 2.4397e6)
    VENUS = (4.869e24, 6.0518e6)
    EARTH = (5.976e24, 6.37814e6)
    MARS = (6.421e23, 3.3972e6)
    JUPITER = (1.9e27, 7.1492e7)
    SATURN = (5.688e26, 6.0268e7)
    URANUS = (8.686e25, 2.5559e7)
    NEPTUNE = (1.024e26, 2.4746e7)

    def __init__(self, mass: float, radius: float) -> None:
        self.mass = mass  # in kilograms
        self.radius = radius  # in meters

    @property
    def surface_gravity(self) -> float:
        # universal gravitational constant  (m3 kg-1 s-2)

        G = 6.67300e-11
        return G * self.mass / (self.radius * self.radius)


print(Planet.EARTH.value)  # (5.976e+24, 6378140.0)
print(Planet.EARTH.surface_gravity)  # 9.802652743337129

関数呼び出しによる定義

import enum


class PlanetBase(enum.Enum):
    def __init__(self, mass: float, radius: float) -> None:
        self.mass = mass  # in kilograms
        self.radius = radius  # in meters

    @property
    def surface_gravity(self) -> float:
        # universal gravitational constant  (m3 kg-1 s-2)

        G = 6.67300e-11
        return G * self.mass / (self.radius * self.radius)


PLANET_VALUES: dict[str, tuple[float, float]] = {
    "MERCURY": (3.303e23, 2439700.0),
    "VENUS": (4.869e24, 6051800.0),
    "EARTH": (5.976e24, 6378140.0),
    "MARS": (6.421e23, 3397200.0),
    "JUPITER": (1.9e27, 71492000.0),
    "SATURN": (5.688e26, 60268000.0),
    "URANUS": (8.686e25, 25559000.0),
    "NEPTUNE": (1.024e26, 24746000.0),
}

Planet = PlanetBase("PLANETS", PLANET_VALUES)

print(Planet.EARTH.value)  # (5.976e+24, 6378140.0)
print(Planet.EARTH.surface_gravity)  # 9.802652743337129

関数呼び出しによる定義の使いどころ

上記の例は惑星に関する列挙型である。 太陽系の惑星は水星から海王星までだが、人によっては冥王星を入れたいかもしれないし、G.Holstの組曲に合わせて地球を省きたいかもしれない。 その場合は PLANET_VALUES を定義する際に if 文を入れるなどして、お好みの惑星を列挙できる。

一方、クラスの場合は別のクラスを定義するしかない。 また、列挙型は継承(拡大)できない。 実際にやってみるとわかる。

import enum


class E1(enum.Enum):
    M1 = enum.auto()
    M2 = enum.auto()


class E2(E1):
    M3 = enum.auto()
    
# TypeError: E2: cannot extend enumeration 'E1'

mypy(型チェッカ)と関数呼び出しによる定義の相性の悪さ

関数呼び出しによる定義は利点もあるが欠点もある。 型チェッカとの相性が悪い。

たとえば、クラスによる定義では、適切に型ヒントを追加すればよい。

$ mypy --strict def_by_class.py
Success: no issues found in 1 source file

一方、関数呼び出しによる定義だと、引数を__call__()に渡しているはずなのに、__init__()の引数の型と一致しないというエラーが発生する。 また、列挙型を生成した後も、存在するはずの属性EARTHが存在しないというエラーが発生する。

$ mypy --strict def_by_func.py
def_by_func.py:28: error: Argument 1 to "PlanetBase" has incompatible type "str"; expected "float"  [arg-type]
def_by_func.py:28: error: Argument 2 to "PlanetBase" has incompatible type "dict[str, tuple[float, float]]"; expected "float"  [arg-type]
def_by_func.py:30: error: "PlanetBase" has no attribute "EARTH"  [attr-defined]
def_by_func.py:31: error: "PlanetBase" has no attribute "EARTH"  [attr-defined]
Found 4 errors in 1 file (checked 1 source file)

今回の例は、列挙型のメンバの値を使う例なので少し複雑だが、単に enum.auto() を使ったとしても、属性が存在しないというエラーが発生する。 そのため、相性の悪さとしては変わりない。

なお、これは mypy の問題ではない。 pyrightやpyreのPlaygroundで試してみたところ、やはり属性が存在しないというエラーが発生する。

github.com

github.com

代替手段としての辞書

ロバストPython』の「8.2.2 列挙型を使うべきではない場合」にはこう書かれている。 (なお、『ロバストPython』はこの記事の筆者が監訳しました。)

www.oreilly.co.jp

列挙型はコードの利用者に静的な選択肢を示すのに効果を発揮する。しかし、選択肢が実行時に決まるような場合は列挙型は使わないほうがよい。(中略)実行するたびに有効な値が変わるなら、コードの読み手はどの値が使えるかの判断が難しくなる。このような場合は、辞書を勧める。

ならば、実際に辞書で書いてみよう。

import dataclasses
import typing


PLANET_NAME = typing.Literal["MERCURY", "VENUS", "EARTH", "MARS", "JUPITER", "SATURN", "URANUS", "NEPTUNE"]


@dataclasses.dataclass
class PlanetValue:
    mass: float
    radius: float

    @property
    def surface_gravity(self) -> float:
        # universal gravitational constant  (m3 kg-1 s-2)

        G = 6.67300e-11
        return G * self.mass / (self.radius * self.radius)


PLANET_VALUES: dict[PLANET_NAME, PlanetValue] = {
    "MERCURY": PlanetValue(mass=3.303e23, radius=2439700.0),
    "VENUS": PlanetValue(mass=4.869e24, radius=6051800.0),
    "EARTH": PlanetValue(mass=5.976e24, radius=6378140.0),
    "MARS": PlanetValue(mass=6.421e23, radius=3397200.0),
    "JUPITER": PlanetValue(mass=1.9e27, radius=71492000.0),
    "SATURN": PlanetValue(mass=5.688e26, radius=60268000.0),
    "URANUS": PlanetValue(mass=8.686e25, radius=25559000.0),
    "NEPTUNE": PlanetValue(mass=1.024e26, radius=24746000.0),
}

print(PLANET_VALUES["EARTH"].mass)  # 5.976e+24
print(PLANET_VALUES["EARTH"].surface_gravity)  # 9.802652743337129

辞書だけでなく、データクラスも用いた。 今回は typing.Literal を使ったが、外部から入力を受ける場合は何らかのバリデーション処理が必要になる。

これならば、必要に応じて惑星の構成要素を変更できるし、型チェッカも納得する。

$ mypy --strict def_by_dict.py
Success: no issues found in 1 source file

惑星の例では列挙型の方が優れているが、動的な列挙型を扱いたい場合は辞書の方が素直である。

言いたいこと(2回目)

  • Pythonの列挙型を定義する方法としてクラスと関数呼び出しの2種類存在する。
  • 関数呼び出しで列挙型を定義するべき状況とは、列挙型を動的に制御したい状況である。
  • しかし、関数呼び出しで列挙型を定義すると、mypyを通して型チェックをする際に都合が悪い。
  • 列挙型を動的に制御したい場合は列挙型を使わずに素直に辞書を使う。

宣伝

桜の季節にピッタリなPythonの本格的な入門書や専門書を監訳したり翻訳したりしました。 是非とも書店で手に取ったり、直販サイトで購入していただきたい。

www.oreilly.co.jp

www.oreilly.co.jp

www.oreilly.co.jp

『データエンジニアリングの基礎』の査読を担当しました

データエンジニアリングとはこれ

2024年3月27日にオライリージャパンから『Fundamentals of Data Engineering』の邦訳である『データエンジニアリングの基礎』が発売される。

www.oreilly.co.jp

本書は「データエンジニアリングライフサイクル」という概念を軸にデータシステムの要件を整理、システムの構築、データエンジニアの立ち位置などを詳細に論じたものである。 データサイエンティストではなくデータ分析の基盤を作るデータエンジニアの本ではあるものの、普段はWebシステムを作っているのでそこまでデータサイエンス関係ないんだよな、という人でも面白く読めるはずである。 特定のツールに依存しない説明であり、結果的にRDSしか使わないという選択をしたとしても本来はここまで考えないといけないのか、という記述の連続である。

データエンジニアに限らず、何らかのソフトウェアシステムに携わる人ならば是非とも読んでみてほしい。