mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

【Django】複合ユニーク制約を実装する

Djangoで複合主キーっぽいことをやりたくて。

使用技術

* Django 2.2.1
* Python 3.7.3


Djangoでは単一主キーのみをサポートしているため複合主キーはできないのですが 複合ユニーク制約ならできるようです。

Django2.2にて、MetaオプションにUniqueConstraintというものが登場しました。


models.py

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]

        

※こちらよりコードを引っ張りました。

Django 2.2 LTS 主な変更点まとめ – CreditEngine Tech – Medium


class Metaにて、constraintsに制約を設定するオブジェクトの配列を代入します。

上記では、UniqueConstraintの引数「fields」に設定した'room', 'date'の2つのカラムが複合ユニークとなります。

https://docs.djangoproject.com/ja/2.2/ref/models/constraints/#django.db.models.UniqueConstraint


なお、UniqueConstraintに関してはバリデーションの代わりとはならず、既存組み合わせのレコードを登録しようとするとIntegurityErrorが発生します

>>> import datetime
>>> from .models import Reservation
>>> # 正常にcreateが実行される
Reservation.objects.create(room='room_a', date=datetime.date(2019, 6, 1))

>>> # 既存のroomとdateの組み合わせをcreateしようとするとIntegurityErrorとなる
Reservation.objects.create(room='room_a', date=datetime.date(2019, 6, 1))
# django.db.utils.IntegrityErrorが発生する


なので、バリデーションをしたい場合は

例えば下記のように実装します。

まずは指定されたroomとdateの組み合わせがすでにDBに登録されているか確認するメソッドを作成します。

models.py

import datetime

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]
    
    # 追加
    @classmethod
    def check_duplicate(cls, room: str, date: datetime.date) -> bool:
        """同じ日に同じ部屋がすでにDBに登録されているどうかを判定します

        登録されていたらTrue, されていなかったらFalseを返します。
        """
        return cls.objects.filter(room=room, date=date).exists()


続いて、フォームで下記のように実装します。

今回はdjango.formsのFormとModelFormの両方で作成してみました。

どちらを使ってもroomとdateの重複をフォームのバリデーションの時点で確認できることが分かりました。

forms.py

import datetime

from django import forms

from .models import Reservation


class ReservationForm(forms.Form):
    room = forms.CharField(max_length=20)
    date = forms.DateField()

    def clean(self):
        cleaned_data = super(ReservationForm, self).clean()
        
        room = cleaned_data.get("room")
        date = cleaned_data.get("date")

        if Reservation.check_duplicate(room=room, date=date):
            raise forms.ValidationError(f'{date}に{room}はすでに予約されているため登録できません')

        return cleaned_data


class ReservationModelForm(forms.ModelForm):
    class Meta:
        model = Reservation
        fields = ['room', 'date']


python manage.py shellで実行すると下記のようになりました。

>>> # python manage.py shellにて
>>> # DBには room='room_a', date=datetime.date(2019, 6, 1)のレコードがすでに存在する状態

>>> from app.forms.reservation_form import ReservationForm, ReservationModelForm


>>> # まずはforms.Formで実装したフォーム

>>> form_a = ReservationForm({'room':'room_a', 'date':datetime.date(2019,6,1)})
>>> form_a.is_valid()
False

>>> form_a.errors
{'__all__': ['2019-06-01にroom_aはすでに予約されているため登録できません']}
>>> # forms.ValidationErrorで設定したエラーメッセージが返される

>>> # 続いてforms.ModelFormで実装したフォーム

>>> form_b = ReservationModelForm({'room': 'room_a', 'date': datetime.date(2019, 6, 1)})
>>> form_b.is_valid()
False

>>> form_b.errors
{'__all__': ['この 部屋名 と 予約日 を持った Reservation が既に存在します。']}
>>> # DjangoのModelFormでデフォルトで実装されているエラーメッセージが返される


また、Metaオプションで複合ユニーク制約する方法として unique_togetherというものもあります。

ただし、こちらに関しては将来廃止される可能性もあると公式ドキュメントにも書いているので Django2.2以降をご利用の場合はUniqueConstraintで実装するようにしましょう。

https://docs.djangoproject.com/en/2.2/ref/models/options/#unique-together


UniqueConstraintのついでに複合インデックスもやってくれてないかなと思って調べたところ、 複合ユニーク制約のみで複合インデックスは別に実装する必要があるようです。

Metaオプションにindexesを追加します。 indexesはmodel.Indexを要素とする配列となります。

Indexの引数fieldsに複合インデックスとしたいフィールドの名前からなる配列を代入してください。

models.py

from django.db import models


class Reservation(models.Model):
    room = models.CharField(verbose_name="部屋名", max_length=20)
    date = models.DateField(verbose_name="予約日")

    class Meta:
        constraints = [
            # 同じ日に部屋の予約を重複させない
            models.UniqueConstraint(fields=['room', 'date'], name='unique_booking'),
        ]

        # 複合インデックスの実装
        indexes = [
            models.Index(fields=['room', 'date'])
        ]


複合インデックスを設定する方法でMetaクラスにindex_togetherを設定する方法がありますが、 こちらは将来廃止される可能性もあると公式ドキュメントにも書いているので Django2.2以降をご利用の場合はindexesで実装するようにしましょう。

https://docs.djangoproject.com/en/2.2/ref/models/options/#index-together