何かを書き留める何か

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

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ができる。