django で論理削除を実現してみる

イベント系のデータならともかく、リソース系のデータの論理削除というのは結構需要が多そうな気がするけど、あまり触れられてないのはなんでだろうか?すでに Django Snippets とかにあるような気もするけど、なかなかいいアイデアが浮かんだのでメモしておく。

まずは前提とするモデルから。

class Author(models.Model):
    name = models.CharField(u'名前', max_length=255)
    deleted = models.BooleanField(u'論理削除フラグ', default=False)
    def delete(self):
        self.deleted = True
        self.save()

class Entry(models.Model):
    content = models.TextField(u'内容')
    author = models.ForeignKey(Author, verbose_name=u'作者')
    deleted = models.BooleanField(u'論理削除フラグ', default=False)
    def delete(self):
        self.deleted = True
        self.save()

特に特別なことはやってない。論理削除を実現するときにすぐ思いつく形だと思う。で、有効なデータを取得したければ、Author.objects.filter(deleted=False) とかやる。これでも一応実現できてるんだけど、いまいち面倒くさい。テンプレートではキーワード引数が使えないし。


これを解決するために、カスタムマネジャというものが用意されている。

基底クラスの Manager クラスを拡張して、モデル中でカスタムのマネジャをインスタンス化すれば、モデルでカスタムのマネジャを使えます。

マネジャをカスタマイズする理由は大きく分けて二つあります。一つはマネジャに追加のメソッドを持たせたい場合、もう一つはマネジャの返す初期 QuerySet を変更したい場合です。

http://michilu.com/django/doc-ja/model-api/#id31

こんな感じに定義しておけば、Entry.objects.all() で有効なデータが取得できる。

class PublicManager(models.Manager):
    def get_query_set(self):
        return super(PublicManager, self).get_query_set().filter(deleted=False)

class Entry(models.Model):
    content = models.TextField(u'内容')
    author = models.ForeignKey(Author, verbose_name=u'作者')
    deleted = models.BooleanField(u'論理削除フラグ', default=False)
    objects = PublicManager()


普通に使う分にはこれで十分だと思う。ただし、管理サイトで困ったことになる。論理削除したデータも普通に見える。ただし、クリックして編集しようとしても404ページに飛ばされる。つまり、一度論理削除したら、manage.py shell から変更するかDBを直接編集するしか復活させる手段がない。一応理にかなった仕様かと思うけど、やっぱりめんどくさい。管理サイトで復活できた方が楽だ。


そこで、カスタムマネジャは複数持つことができるのを利用して、こんな風に定義してみる。

class Entry(models.Model):
    content = models.TextField(u'内容')
    author = models.ForeignKey(Author, verbose_name=u'作者')
    deleted = models.BooleanField(u'論理削除フラグ', default=False)
    objects = models.Manager()
    public_objects = PublicManager()

こうすると管理サイトから復活できるし、Entry.public_objects.all() で有効データが取得できる。


これでめでたし、と思いきや、リレーションを逆にたどる場合に困ったことになる。 django は最初に定義してあるマネジャを使うみたいなので、author.entry_set.all() とかやると、論理削除したデータも取得できてしまう。これはあまりよろしくない。 Author をキーにして Entry を表示するページを表示するためだけに、ビューで author.entry_set.filter(deleted=False) とかやるのはさらによろしくない。


今まではここまでで諦めてビューで苦労してたんだけど、モデルAPIリファレンスを読み直していてふと思いついた。

カスタムのマネジャメソッドは何を返してもかまいません。 QuerySet を返さなくてもよいのです。

http://michilu.com/django/doc-ja/model-api/

つまり別の QuerySet を返しても問題ない。というわけでこんな風に定義してみる。

class PublicManager(models.Manager):
    def __init__(self, *args, **kwargs):
        self.args = args
        self.kwargs = kwargs
        
    def get_query_set(self):
        return super(PublicManager, self).get_query_set()
    
    def public_all(self):
        return self.get_query_set().filter(*self.args, **self.kwargs)

    def public_filter(self, *args, **kwargs):
        return self.public_all().filter(*args, **kwargs)

class Entry(models.Model):
    content = models.TextField(u'内容')
    author = models.ForeignKey(Author, verbose_name=u'作者')
    deleted = models.BooleanField(u'論理削除フラグ', default=False)
    objects = PublicManager(deleted=False)

そうすると、今まで上げてきた問題はほとんど解決できる。

  • Entry.objects.all() は全データを返す。
  • Entry.objects.public_all() は有効データだけを返す。
  • Entry.objects.public_filter() は有効データをフィルタした結果を返す。
  • author.entry_set.all() は、 author に関連した全データを返す。
  • author.entry_set.public_all() は、 author に関連した有効データを返す。

管理サイトで 404 ページに飛ばされることもないし、どうやってテンプレートに author.entry_set.filter(deleted=False) を渡すか悩む必要もありません。

追記
管理サイトで、Entryから選択できるAuthorを制限したいなら、author = models.ForeignKey(Author, limit_choices_to = {'deleted': False}) とかやればいい。

なんで気が付かなかったんだろう・・・