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}) とかやればいい。
なんで気が付かなかったんだろう・・・