Qiita Advent Calendar 2019 Django15日目は、2019年12月2日にリリースされたDjango 3.0の主な変更点ついて紹介します。

祝・Django 3.0リリース!

祝・Django 3.0リリース!

公式サイトでのリリース情報は以下を参照してください。

Django 3.0 release notes | Django documentation | Django

MariaDB support

MariaDBを公式にサポートするようになりました。MariaDB 10.1以上に対応しています。

MariaDBを使う場合はMySQL用データベースバックエンドを使うようにしてください。1

ASGI support

ついにDjangoでASGIをサポートするようになりました! これでDjangoでチャットのような非同期アプリケーションを気軽に作れるはずです。

それでは、早速サンプルアプリを作ってみましょう!

……と言いたいところですが、結局挫折してしまいました。公式ドキュメントAsynchronous supportの以下説明を読んだところだと、3.0の時点ではASGIで通信できるようになっているものの、非同期のviewやmiddlewareの作成はまだサポートしていないようです。2

Django has developing support for asynchronous (“async”) Python, but does not yet support asynchronous views or middleware; they will be coming in a future release.

There is limited support for other parts of the async ecosystem; namely, Django can natively talk ASGI, and some async safety support.

また、非同期イベントループの中で「非同期では安全ではない」コードを実行することができません(例: ORMの操作)。実行すると、SynchronousOnlyOperationエラーが発生します。

SynchronousOnlyOperationエラーを防ぐには、該当するコードの実行をasgiref.sync.async_to_sync、またはスレッドプールを経由するように変更する必要があります。

Exclusion constraints on PostgreSQL

PostgreSQLの排他制約を作成するクラスExclusionConstraintが追加されました。

PostgreSQLの排他制約についての解説記事は以下がオススメです。

PostgreSQLで排他制約がめっちゃ便利!! - そーだいなるらくがき帳

では、実際に使ってみましょう。データベースはPostgreSQL 9.6.16を使います。モデル定義は公式ドキュメントのサンプルをそのまま使います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
from django.db import models
from django.db.models import Q


class Room(models.Model):
    number = models.IntegerField()


class Reservation(models.Model):
    room = models.ForeignKey('Room', on_delete=models.CASCADE)
    timespan = DateTimeRangeField()
    cancelled = models.BooleanField(default=False)

    class Meta:
        constraints = [
            ExclusionConstraint(
                name='exclude_overlapping_reservations',
                expressions=[
                    ('timespan', RangeOperators.OVERLAPS),
                    ('room', RangeOperators.EQUAL),
                ],
                condition=Q(cancelled=False),
            ),
        ]

同じRoomに対して時間帯が重なるReservationが作れないように排他制約が指定されています。 なお、migrateコマンド実行前に、psqlコマンド経由でCREATE EXTENSION btree_gist;を実行してbtree_gist拡張を有効にしておく必要があります。

migrateコマンド実行後のテーブル定義を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
                            Table "public.reservations_reservation"
  Column   |   Type    |                               Modifiers
-----------+-----------+-----------------------------------------------------------------------
 id        | integer   | not null default nextval('reservations_reservation_id_seq'::regclass)
 timespan  | tstzrange | not null
 cancelled | boolean   | not null
 room_id   | integer   | not null
Indexes:
    "reservations_reservation_pkey" PRIMARY KEY, btree (id)
    "exclude_overlapping_reservations" EXCLUDE USING gist (timespan WITH &&, room_id WITH =) WHERE (cancelled = false)
    "reservations_reservation_room_id_f7d9ba76" btree (room_id)
Foreign-key constraints:
    "reservations_reserva_room_id_f7d9ba76_fk_reservati" FOREIGN KEY (room_id) REFERENCES reservations_room(id) DEFERRABLE INITIALLY DEFERRED

shellコマンド経由で排他制約に違反するデータを作ってみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>>> room = Room.objects.create(number=1)
>>> from datetime import datetime
>>> Reservation.objects.create(
...     room=room,
...     timespan=[datetime(2019, 12, 1, 15, 0, 0), datetime(2019, 12, 2, 15, 0, 0)]
... )
<Reservation: Reservation object (1)>
>>> # timespanが既存のReservationのtimespanと被っているのでエラーになる。
>>> Reservation.objects.create(
...     room=room,
...     timespan=[datetime(2019, 12, 2, 14, 0, 0), datetime(2019, 12, 3, 15, 0, 0)]
... )
Traceback (most recent call last):
(中略)
django.db.utils.IntegrityError: conflicting key value violates exclusion constraint "exclude_overlapping_reservations"
(中略)
DETAIL:  Key (timespan, room_id)=(["2019-12-02 14:00:00+00","2019-12-03 15:00:00+00"), 1) conflicts with existing key (timespan, room_id)=(["2019-12-01 15:00:00+00","2019-12-02 15:00:00+00"), 1).
>>> # timespanが被っていなければエラーにならない。
>>> Reservation.objects.create(
...     room=room,
...     timespan=[datetime(2019, 12, 2, 16, 0, 0), datetime(2019, 12, 3, 15, 0, 0)]
... )
<Reservation: Reservation object (3)>

公式ドキュメントのもう一つのサンプルも作ってみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import (
    DateTimeRangeField,
    RangeBoundary,
    RangeOperators,
)
from django.db import models
from django.db.models import Func, Q


class TsTzRange(Func):
    function = 'TSTZRANGE'
    output_field = DateTimeRangeField()


class Reservation(models.Model):
    # Roomの定義は先述のサンプルと同じ
    room = models.ForeignKey('Room', on_delete=models.CASCADE)
    start = models.DateTimeField()
    end = models.DateTimeField()
    cancelled = models.BooleanField(default=False)

    class Meta:
        constraints = [
            ExclusionConstraint(
                name='exclude_overlapping_reservations',
                expressions=(
                    (TsTzRange('start', 'end', RangeBoundary()), RangeOperators.OVERLAPS),
                    ('room', RangeOperators.EQUAL),
                ),
                condition=Q(cancelled=False),
            ),
        ]

先述のサンプルではtimespanという1フィールドで予約時間帯を定義していましたが、これはstartendに分けて、RangeBoundaryクラスを使って排他制約を加えています。

migrateコマンド実行後のテーブル定義を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
                                   Table "public.reservations_reservation"
  Column   |           Type           |                               Modifiers
-----------+--------------------------+-----------------------------------------------------------------------
 id        | integer                  | not null default nextval('reservations_reservation_id_seq'::regclass)
 start     | timestamp with time zone | not null
 end       | timestamp with time zone | not null
 cancelled | boolean                  | not null
 room_id   | integer                  | not null
Indexes:
    "reservations_reservation_pkey" PRIMARY KEY, btree (id)
    "exclude_overlapping_reservations" EXCLUDE USING gist (tstzrange(start, "end", '[)'::text) WITH &&, room_id WITH =) WHERE (cancelled = false)
    "reservations_reservation_room_id_f7d9ba76" btree (room_id)
Foreign-key constraints:
    "reservations_reserva_room_id_f7d9ba76_fk_reservati" FOREIGN KEY (room_id) REFERENCES reservations_room(id) DEFERRABLE INITIALLY DEFERRED

Filter expressions

BooleanFieldを出力する式はannotateメソッドを経由しなくても直接filterメソッドに渡せるようになりました。この説明だけだとピンと来ないかもしれないので、実際にコードを書いて検証してみましょう。

公式ドキュメントのサンプルを参考にして、以下のモデルを作ってみました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.db import models


class Client(models.Model):
    ACCOUNT_TYPES = (
        ('guest', "ゲスト"),
        ('staff', "スタッフ"),
    )
    account_type = models.CharField(
        max_length=5,
        choices=ACCOUNT_TYPES,
    )

まず、Django2.2のshellコマンド上で上記モデルを使ってみましょう。

1
2
3
4
5
6
7
8
>>> from django.db.models import Exists, OuterRef
>>> non_unique_account_type = Client.objects.filter(
...     account_type=OuterRef('account_type'),
... ).exclude(pk=OuterRef('pk')).values('pk')
>>> Client.objects.filter(~Exists(non_unique_account_type))
Traceback (most recent call last):
(中略)
TypeError: cannot unpack non-iterable Exists object

filterメソッドにBooleanFieldを出力する式(non_unique_account_type)を渡すとTypeErrorになりました。これを回避するには、annotateメソッドで一旦フィールドを作ってからfilterで参照する必要があります。

1
2
3
>>> Client.objects.annotate(
...     non_unique_account_type=~Exists(non_unique_account_type)
... ).filter(non_unique_account_type=True)

Django3.0では、この回避策を取らなくてもよくなりました。

1
2
3
4
5
6
>>> from django.db.models import Exists, OuterRef
>>> non_unique_account_type = Client.objects.filter(
...     account_type=OuterRef('account_type'),
... ).exclude(pk=OuterRef('pk')).values('pk')
>>> Client.objects.filter(~Exists(non_unique_account_type))
<QuerySet [<Client: Client object (1)>]>

データベースには以下のクエリが発行されます。(django-debug-toolbar==2.1 + SQLite 3.30.1で確認3

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> Client.objects.filter(~Exists(non_unique_account_type))
SELECT "examples_client"."id",
       "examples_client"."account_type"
FROM "examples_client"
WHERE NOT EXISTS
    (SELECT U0."id"
     FROM "examples_client" U0
     WHERE (U0."account_type" = "examples_client"."account_type"
            AND NOT (U0."id" = "examples_client"."id")))
LIMIT 21 [2.51ms]

Enumerations for model field choices

Field.choicesにカスタム列挙型が使えるようになりました。

まず、2.2ではどのようなコードを書いていたのか見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.db import models


class Post(models.Model):
    STATUS_PUBLISHED = 'published'
    STATUS_DRAFT = 'draft'
    STATUSES = (
        (STATUS_PUBLISHED, "公開"),
        (STATUS_DRAFT, "下書き"),
    )
    status = models.CharField(
        max_length=20,
        choices=STATUSES,
        default=STATUS_PUBLISHED,
    )

choicesにはタプルで「データベースに保存する値」・「ユーザーに見せる文言」が指定されています。Post.statusを読み書きするコードの中で'draft''publish'を直に書かなくて済むよう、さらに定数を定義するようにしています。

主観的な問題なので違う意見の人もいるかもしれませんが、定数の中で別の定数を参照する、という設計は少々ややこしいと感じる人もいるのではないでしょうか。

3.0からは以下のように書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.db import models


class Post(models.Model):
    class Status(models.TextChoices):
        PUBLISHED = 'published', "公開"
        DRAFT = 'draft', "下書き"

    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

choicesの中身はStatusクラスの定義だけを見れば分かるようになりました。

また、valueslabelsを使えば、定義内容の一覧を取得することもできます。

1
2
3
4
>>> Post.Status.values
['published', 'draft']
>>> Post.Status.labels
['公開', '下書き']

django-model-utilsChoiceと似たような機能を公式でもサポートした感じですね。4


  1. 詳細はMariaDB notesを参照。 ↩︎

  2. 私の勘違いの可能性もあります。実際にアプリケーションを作ったことがあるという人は、ぜひ私@ryu22e に教えてください。 ↩︎

  3. PostgreSQLを使いたかったのですが、Django==3.0 + django-debug-toolbar==2.1 + PostgreSQL 9.6.16の組み合わせだとクエリが表示されなかったため、やむを得ずSQLiteを使いました。django-debug-toolbar側の問題かもしれませんが、調べきれていません。 ↩︎

  4. django-model-utilsを参考にしたかどうかは分かりません。 ↩︎