Qiita Advent Calendar 2019 Django 15日目は、2019年12月2日にリリースされたDjango 3.0の主な変更点ついて紹介します。
祝・Django 3.0リリース!
公式サイトでのリリース情報は以下を参照してください。
Django 3.0 release notes | Django documentation | Django 
MariaDB support#  MariaDB を公式にサポートするようになりました。MariaDB 10.1以上に対応しています。
MariaDBを使う場合はMySQL用データベースバックエンドを使うようにしてください。
ASGI support#  ついにDjangoでASGIをサポートするようになりました! これでDjangoでチャットのような非同期アプリケーションを気軽に作れるはずです。
それでは、早速サンプルアプリを作ってみましょう!
……と言いたいところですが、結局挫折してしまいました。公式ドキュメントAsynchronous support の以下説明を読んだところだと、3.0の時点ではASGIで通信できるようになっているものの、非同期のviewやmiddlewareの作成はまだサポートしていないようです。
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
 -----------+-----------+-----------------------------------------------------------------------
 |  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フィールドで予約時間帯を定義していましたが、これはstart・endに分けて、RangeBoundary クラスを使って排他制約を加えています。
migrateコマンド実行後のテーブル定義を見てみましょう。
 1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
                                    Table  "public.reservations_reservation" 
   Column    |            Type            |                                Modifiers
 -----------+--------------------------+-----------------------------------------------------------------------
 |  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で確認
 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.51 ms] 
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クラスの定義だけを見れば分かるようになりました。
また、values・labelsを使えば、定義内容の一覧を取得することもできます。
1
 2
 3
 4
 >>>  Post. Status. values
['published' , 'draft' ]
 >>>  Post. Status. labels
['公開' , '下書き' ] 
django-model-utils のChoice と似たような機能を公式でもサポートした感じですね。