まとめ
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
モデルはフィールドとして数値とスートを持つ。
Field
の choices
に期待していたもの
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", ), ),
今回のケースだと、 number
と suit
いずれのフィールドもNOT NULLであるため、None
を渡しても期待通りエラーが発生するが、NULLを許容する場合は素通りしてしまう。
実害は(おそらく)ないが、念のため気を付けたほうが良いだろう。
Choices
と ModelForm
以上の例だけだと、Modelのフィールドにchoices
を渡すのはそれほど有益ではない、と感じるかもしれない。
choices
や Choices
の真価は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ができる。