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 設計し易いフレームワークだと認識しており、これからも末永くお付き合いできれば嬉しいなぁ〜と考えております。

*1:拙作に MemoizePerRequestQuerySet と MemcacheQuerySet というものがあるのですが、そちらは、Python 系勉強会で紹介予定です