Python Web Framework Advent Calendar 2012 (9日目) Django Model で Named Scope
前置き
この記事は、2012 Pythonアドベントカレンダー(Webフレームワーク) - connpass の 9 日目の記事となります。
今回は、Rails の Named Scope の真似を Django Model で実現する方法と、それを利用した論理削除の紹介を行います。
Django Model で Named Scope を実現する
そもそも何をしたいのか?
まず、ECサイトやソシャゲー等のユーザ情報から、最近登録したユーザの中から直近のアクセス順に上位5人を取得する例を挙げます。
import datetime from django.utils.timezone import get_default_timezone # 一週間以内の登録を "最近登録した" とみなす dt = datetime.datetime.now() - datetime.timedelta(weeks=1) # utc でも良いけれど、何となくローカライズ dt = get_default_timezone().localize(dt) User.objects.filter(created_at__gt=dt).order_by('-logged_in_at')[:5]
Named Scope を使用すると、次のようになります。
User.objects.by_newbie().order_by_active()[:5]
Named Scope を作る
では、実際の実現方法を紹介します。
import datetime from django.utils.timezone import get_default_timezone from django.db import models from django.db.models.query import QuerySet # Manager と QuerySet で同様のメソッドを使用するので Mix-in Class として切り出す class UserScopesMixin(object): _newbie_term = datetime.timedelta(weeks=1) def by_newbie(self): dt = datetime.datetime.now() - self._newbie_term dt = get_default_timezone().localize(dt) return self.filter(created_at__gt=dt) def order_by_active(self): return self.order_by('-logged_in_at') # QuerySet に Scope を Mix-in する # 継承順は賛否分かれる所ですが、この記事では、社内の目があるので Mix-in Class を後ろに羅列します(w; # 蛇足ですが、私は、私用で Python を書く場合に限り、Mix-in Class を前に羅列する派です class UserQuerySet(QuerySet, UserScopesMixin): pass # Manager に Scope を Mix-in し, 上記で定義した QuerySet を返すようにする class UserManager(models.Manager, UserScopesMixin): def get_query_set(self): return UserQuerySet(self.model) # 上記で定義した Manager を objects に設定する class User(models.Model): objects = UserManager() created_at = models.DateTimeField(auto_now_add=True, index=True) logged_in_at = models.DateTimeField(auto_now=True) @classmethod def get_active_newbies(cls, limit=5): return User.objects.by_newbie().order_by_active()[:limit]
結局、get_active_newbies() を定義するのであれば、Named Scope なんて不用ではないか?と思われるかもしれません。
しかし、get_active_newbies() の様なメソッドを多数定義する場合、スッキリ書けるのでオススメです。
また、Named Scope が癖になっていると、そもそも QuerySet をカスタム済みであるため、他のカスタム QuerySet を組み込む際に労力が減るという副作用もあります。*1
論理削除の実例
物理的にレコードを削除せずに、削除フラグを立てて削除した事にするアレ。
class LogicalDeleteScopesMixin(object): def by_alive(self): return self.filter(deleted_uuid='') def delete(self): self.update(deleted_uuid=uuid.uuid4(), deleted_at=datetime.datetime.now(pytz.utc)) class LogicalDeleteQuerySet(QuerySet, LogicalDeleteScopesMixin): pass class LogicalDeleteManager(models.Manager, LogicalDeleteScopesMixin): def get_query_set(self): return LogicalDeleteQuerySet(self.model).by_alive() # Mix-in Class であるため、object を継承したいが、 # Django Model の制約により models.Model を継承する必要がある。 class LogicalDeleteModelMixin(models.Model): class Meta: abstract = True class RedeletedError(Exception): pass objects = LogicalDeleteManager() # delete_at を有効/無効の確認に利用すると、 # 一意キー制約を設けた際に、一秒以内の delete が使えないため、 # 有効/無効を判断するための UUID フィールドを設ける。 # 初期値に NULL を指定すると、NULL はレコード毎に異なる値と認識されるため、 # UUID フィールドを一意キー制約に含められない。 # そこで、初期値には空文字列を明示的に指定しておく。 deleted_uuid = models.CharField(max_length=255, db_index=True, default='') # 念のため、記録として削除日時を残しておく。 deleted_at = models.DateTimeField(blank=True, null=True) def delete(self): if self.deleted_uuid: raise self.RedeletedError, self.pk self.deleted_uuid = uuid.uuid4() self.deleted_at = datetime.datetime.now(pytz.utc) self.save()
早速、先ほどの User Model で使用してみましょう。
# LogicalDeleteScopesMixin を継承する class UserScopesMixin(LogicalDeleteScopesMixin): pass # 内容に変更がないため省略 # LogicalDeleteScopesMixin を継承して UserScopesMixin を定義したので # UserQuerySet に変更はない。 class UserQuerySet(QuerySet, UserScopesMixin): pass # by_alive() を使用する必要がある class UserManager(models.Manager, UserScopesMixin): def get_query_set(self): return UserQuerySet(self.model).by_alive() class User(models.Model, LogicalDeleteModelMixin): pass # 内容に変更がないため省略
その他
私は、Python 歴 = Django 歴 = 半年未満であり、Django 以外の他の Python Web Framework の知識は皆無という状態ですが、今回紹介させて頂いた Named Scope や、Class Based View の存在から、Django は OO 設計し易いフレームワークだと認識しており、これからも末永くお付き合いできれば嬉しいなぁ〜と考えております。