Search code examples
pythondjangodjango-modelsdjango-signals

Django CASCADE and post_delete interaction


I have the following model:

class A():
  foriegn_id1 = models.CharField  # ref to a database not managed by django
  foriegn_id2 = models.CharField

class B():
  a = models.OneToOneField(A, on_delete=models.CASCADE)

So I want A to be deleted as well when B is deleted:

@receiver(post_delete, sender=B)
def post_delete_b(sender, instance, *args, **kwargs):
  if instance.a:
    instance.a.delete()

And on the deletion of A, I want to delete the objects from the unmanaged databases:

@receiver(post_delete, sender=A)
def post_delete_b(sender, instance, *args, **kwargs):
  if instance.foriegn_id1:
    delete_foriegn_obj_1(instance.foriegn_id1)
  if instance.foriegn_id2:
    delete_foriegn_obj_2(instance.foriegn_id2)

Now, if I delete object B, it works fine. But if I delete obj A, then obj B is deleted by cascade, and then it emits a post_delete signal, which triggers the deletion of A again. Django knows how to manage that on his end, so it works fine until it reaches delete_foriegn_obj, which is then called twice and returns a failure on the second attempt.

I thought about validating that the object exists in delete_foriegn_obj, but it adds 3 more calls to the DB.

So the question is: is there a way to know during post_delete_b that object a has been deleted? Both instance.a and A.objects.get(id=instance.a.id) return the object (I guess Django caches the DB update until it finishes all of the deletions are done).


Solution

  • The problem is that the cascaded deletions are performed before the requested object is deleted, hence when you queried the DB (A.objects.get(id=instance.a.id)) the related a instance is present there. instance.a can even show a cached result so there's no way it would show otherwise.

    So while deleting a B model instance, the related A instance will always be existent (if actually there's one). Hence, from the B model post_delete signal receiver, you can get the related A instance and check if the related B actually exists from DB (there's no way to avoid the DB here to get the actual picture underneath):

    @receiver(post_delete, sender=B)
    def post_delete_b(sender, instance, *args, **kwargs):
        try:
            a = instance.a
        except AttributeError:
            return
    
        try:
            a._state.fields_cache = {}
        except AttributeError:
            pass
    
        try:
            a.b  # one extra query
        except AttributeError:
            # This is cascaded delete
            return
    
        a.delete()
    

    We also need to make sure we're not getting any cached result by making a._state.fields_cache empty. The fields_cache (which is actually a descriptor that returns a dict upon first access) is used by the ReverseOneToOneDescriptor (accessor to the related object on the opposite side of a one-to-one) to cache the related field name-value. FWIW, the same is done on the forward side of the relationship by the ForwardOneToOneDescriptor accessor.


    Edit based on comment:

    If you're using this function for multiple senders' post_delete, you can dynamically get the related attribute via getattr:

    getattr(a, sender.a.field.related_query_name())
    

    this does the same as a.b above but allows us to get attribute dynamically via name, so this would result in exactly similar query as you can imagine.