Fork me on GitHub

Django models offer to specify a piece of logic that will run for objects that refer to an object through a ForeignKey or OneToOnField if that object these refer to is removed.

Often handlers like CASCADE [Django-doc] or SET(…) [Django-doc] are often picked to remove the object that refers to the object, or let the ForeignKey point to a new object respectively. While SET(…) can be given a callable, but it takes no parameters.

Often, it is useful to be given the instance where the modification should take place, such that a method based on the data of that instance, can provide a new value for the ForeignKey or OneToOneField. With this pattern, we make it also possible to perform a cascaded delete based on that instance.

What problems are solved with this?

Consider the following model:

from django.conf import settings
from django.db import models
from app_name.deletion import SET_WITH

def new_organizer(meeting):
    # …
    pass

class Meeting(models.Model):
    organizer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=SET_WITH(new_organizer),
        related_name='organized_meetings'
    )
    members = models.ManyToManyField(
        settings.AUTH_USER_MODEL
    )

Here we have a model Meeting that is linked to multiple participants, and one organizer. If the User object that is the organizer is removed, another person that is a participant is upgraded as organizer. In case there are no participants anymore, the meeting object should be removed.

For this we will implement the SET_WITH deletion handler. This will make a call to the new_organizer function, which will return a new value for the organizer, or can also signal that the Meeting object should be removed as well, so with a cascade removal.

What does this pattern look like?

We can take a look at the implementation of the SET handler [GitHub]:

def SET(value):
    if callable(value):
        def set_on_delete(collector, field, sub_objs, using):
            collector.add_field_update(field, value(), sub_objs)
    else:
        def set_on_delete(collector, field, sub_objs, using):
            collector.add_field_update(field, value, sub_objs)
    set_on_delete.deconstruct = lambda: ('django.db.models.SET', (value,), {})
    return set_on_delete

This function needs to return a function that will then later be called with a collector, field, sub_objs and using. The collector is an object that keeps track on what fields to update to what value, and what items to remove. The field is a reference to the field object that is here triggered. This can be useful, for example to determine the .default attribute. The sub_objs is a collection of objects that needs to be updated. These are all model objects with as model the model where the deletion handler is set. Finally using specifies what database connection should be used to update objects.

Here SET will first check if value is a callable of not. If it is not a callable, it will for all the sub_objs specify in the collector that these should be updated with value. If it is a callable, these will all be updated with value().

What we need to change is the fact that not all these sub_objs are updated to value, or value(), but that we call the function with each object in the sub_objs, and for each of these items add the result in the collector.

It is possible that we might want to remove a (subset) of the sub_objs. We can do this with by constructing an object DO_CASCADE. In case the function returns that object, then it will be added to the list of items that we will then collect to perform a cascade.

# app_name/deletion.py

from django.db.models.deletion import CASCADE

DO_CASCADE = object()

def SET_WITH(func):
    def set_on_delete(collector, field, sub_objs, using):
        cascades = []
        for obj in sub_objs:
            result = func(obj)
            if result is DO_CASCADE:
                cascades.append(obj)
            else:
                collector.add_field_update(field, result, [obj])
        if cascades:
            CASCADE(collector, field, cascades, using)
    set_on_delete.deconstruct = lambda: ('app_name.SET_WITH', (func,), {})
    return set_on_delete

Now that we have implemented the SET_WITH(…) handler, we can use this for the implementation of our new_organizer(…) method. This function takes the Meeting object, and will look for the user object that is not the user that is the organizer, and we check if there is at least such element. If not, we return the DO_CASCADE method to perform a cascaded removal:

from app_name.deletion import DO_CASCADE

def new_organizer(meeting):
    item = meeting.members.exclude(pk=meeting.organizer_id).first()
    if item is None:
        return DO_CASCADE
    return item

We thus first access the .members and will exclude the organizer_id to prevent assigning the organizer that is not yet removed from the .members. We access the .first() item. If it is None, that means that there are no members left, so then we return DO_CASCADE to ensure that the Meeting object will be removed. In case it returns a user object, we use that as the new value for the .organizer.