LoginSignup
16
10

More than 1 year has passed since last update.

【Django】論理削除(soft delete)でもCASCADE deleteしたい!

Last updated at Posted at 2021-06-13

はじめに

タイトルの通り Django を使っていて

「論理削除でも、関連テーブルのレコードを削除したい!どうにかやれないかなぁ?」
という内容です。

(論理削除は使わない方がいいのでは?関連レコードは削除しなくていいのでは?というのは置いておくとして)

Django の論理削除の実装については、調べればたくさん情報が出てくるので割愛します。

サンプルとして、以下のように Blog, Post, Comment の三つの Model を用意し、全てを論理削除としました。

from django.db import models
from django.db.models.manager import BaseManager
from django.utils import timezone


class LogicalDeletionQuerySet(models.QuerySet):
    def delete(self):
        now = timezone.now()
        return super().update(**{'deleted_at': now})


class LogicalDeletionManager(BaseManager.from_queryset(LogicalDeletionQuerySet)):
    def get_queryset(self):
        query_set = super().get_queryset()
        return query_set.filter(deleted_at__isnull=True)


class LogicalDeletionModel(models.Model):
    class Meta:
        abstract = True

    deleted_at = models.DateTimeField(null=True, default=None)

    objects = LogicalDeletionManager()
    all_objects = models.Manager()

    def delete(self, **kwargs):
        now = timezone.now()
        self.deleted_at = now
        self.save()


class Blog(LogicalDeletionModel):
    name = models.CharField(max_length=255)


class Post(LogicalDeletionModel):
    title = models.CharField(max_length=255)
    content = models.TextField()
    blog = models.ForeignKey(Blog, on_delete=models.CASCADE)


class Comment(LogicalDeletionModel):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    content = models.TextField()

この上で、on_delete=models.CASCADE の場合、紐づくレコードが全て削除されるように変更を加えていきます。

before

>>> blog = Blog.objects.create(name='blog')
>>> post = blog.post_set.create(title='post')
>>> comment = post.comment_set.create(content='comment')
>>> post.delete()
>>> Post.objects.all()
<LogicalDeletionQuerySet []>
>>> Comment.objects.all()
<QuerySet [<Comment: Comment object (1)>]>

やること

  1. model_instance.delete()CASCADE DELETE
  2. model_queryset.delete()CASCADE DELETE

1. model_instance.delete()CASCADE DELETE

まずは LogicalDeletionModeldelete() に修正を加えます。

before

class LogicalDeletionModel(models.Model):
    def delete(self, **kwargs):
        now = timezone.now()
        self.deleted_at = now
        self.save()

これを実現するためには

  • 削除されたインスタンスのモデルが ForeignKey として設定されているモデルの一覧
  • ForeignKey のフィールド名
  • ForeignKey の on_delete

の全てが取得出来る必要があります。

それが完了すれば
Model.objects.filter(field_name=deleted_instance).delete()
とすることで実装が完了します。

まず
削除されたインスタンスのモデルが ForeignKey として設定されているモデルの一覧は
instance._meta.related_objects にアクセスすることで取得出来ました。

>>> blog._meta.related_objects
(<ManyToOneRel: blogs.post>,)
>>> post._meta.related_objects
(<ManyToOneRel: blogs.comment>,)
>>> comment._meta.related_objects
()

次にそのクラスを取得します。
instance._meta.related_objects で取得出来たオブジェクトの
related_model で取得出来ました。

>>> blog._meta.related_objects[0].related_model
<class 'blogs.models.Post'>

フィールド名は related_objectfield.nameで取得出来ました。

>>> blog._meta.related_objects[0].field.name
'blog'

ForeignKey の on_delete は related_objecton_deleteで取得できました。

>>> blog._meta.related_objects[0].on_delete
<function CASCADE at 0x7f8be5cbb830>

これらを使って model_instance.delete() での CASCADE DELETE を実装することができました。

after

class LogicalDeletionModel(models.Model):
    def delete(self, **kwargs):
        now = timezone.now()
        self.deleted_at = now
        self.save()
        related_objects = self._meta.related_objects
        for related_object in related_objects:
            if related_object.on_delete == models.CASCADE:
                related_model = related_object.related_model
                related_field_name = related_object.field.name
                related_model.objects.filter(**{related_field_name: self}).delete()
>>> blog = Blog.objects.create(name='blog')
>>> post = blog.post_set.create(title='post')
>>> comment1 = post.comment_set.create(content='comment1')
>>> comment2 = post.comment_set.create(content='comment2')
>>> post.delete()
>>> Post.objects.all()
<LogicalDeletionQuerySet []>
>>> Comment.objects.all()
<QuerySet []>

2. model_queryset.delete()CASCADE DELETE

こちらは

  • 削除された queryset のモデル
  • 削除された queryset のモデルが ForeignKey として設定されているモデルの一覧
  • ForeignKey のフィールド名
  • ForeignKey の on_delete

を取得することで実装できます。

削除された queryset のモデルは queryset.model で取得できます。

>>> blogs = Blog.objects.all()
>>> blogs.model
<class 'blogs.models.Blog'>

ここからは 1 の instance._metamodel._meta に置き換えるだけで全く同じです。

これを踏まえて LogicalDeletionQuerySet の delete の実装を更新します。

before

class LogicalDeletionQuerySet(models.QuerySet):
    def delete(self):
        now = timezone.now()
        return super().update(**{'deleted_at': now})

after

class LogicalDeletionQuerySet(models.QuerySet):
    def delete(self):
        queryset_model = self.model
        related_objects = queryset_model._meta.related_objects
        for related_object in related_objects:
            if related_object.on_delete == models.CASCADE:
                related_model = related_object.related_model
                related_field_name = f'{related_object.field.name}__in'
                related_model.objects.filter(**{related_field_name: self}).delete()

        now = timezone.now()
        return = super().update(**{'deleted_at': now})

では試してみます。

>>> blog = Blog.objects.create(name='blog')
>>> post = blog.post_set.create(title='post')
>>> comment = post.comment_set.create(content='comment')
>>> Blog.objects.all().delete()
1
>>> Blog.objects.all(), Post.objects.all(), Comment.objects.all()
(<LogicalDeletionQuerySet []>, <LogicalDeletionQuerySet []>, <LogicalDeletionQuerySet []>)
>>> Blog.all_objects.all(), Post.all_objects.all(), Comment.all_objects.all()
(<QuerySet [<Blog: Blog object (1)>]>, <QuerySet [<Post: Post object (1)>]>, <QuerySet [<Comment: Comment object (1)>]>)

うまくいきました。

おわり

少し強引に実装してしまったかな?と思ってはいますが

「論理削除でも、関連テーブルのレコードを削除したい!」

を実現することができました。

より良い方法があれば是非教えてください!

ありがとうございました!!

ここに書いたソースコードはこちらにあげています
https://github.com/donaisore/django-docker/tree/logical_deletion_cascade

16
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
10