How do I prefetch ForeignKey of the ModelInline in Django Admin?

Viewed 169

For example, I have following models:

class Person(models.Model):
    first_name = models.CharField(max_length=255)
    last_name = models.CharField(max_length=255)

class SNS(models.Model):
    name = models.CharField(max_length=255)

    # for display in raw_id_fields
    def __str__(self):
        return self.name

class PersonSNS(models.Model):
    person = models.ForeignKey('Person', related_name='sns')
    sns = models.ForeignKey('SNS')
    url = models.CharField(max_length=255)

And in admin.py

class PersonSNSInline(admin.StackedInline):
    model = PersonSNS
    fields = ('sns', 'url')
    raw_id_fields = ('sns',)

@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
    inlines = [PersonSNSInline]

    def get_queryset(self, request):
        return super(PersonAdmin, self).get_queryset(request).prefetch_related('sns')

However, django-debug-toolbar shows the executed SQL like:

SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` IN (1, 2, 3, 5, 6)

and

SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` = 1
Duplicated 5 times.
SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` = 5
Duplicated 5 times.
SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` = 6
Duplicated 5 times. 
SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` = 3
Duplicated 5 times. 
SELECT `sns`.`id`, `sns`.`name` FROM `sns` WHERE `sns`.`id` = 2
Duplicated 5 times.

I'm wondering why the PersonSNSInline still query the database one by one though I have prefetch the related data.

1 Answers

This is not currently supported

The framework doesn't even use the related model manager for retrieving the querysets of inline formsets.

Even if you try to do your own custom inlines and inline form sets the way base classes work is quite against object cache and prefetching/ selecting related won't work as you expect.

  1. there are instances self.get_queryset()[i] paradigm inline /BaseFormaset creation methods that beat the purpose of prefetching.
  2. Baseformset's init includes:
    qs = queryset.filter(**{self.fk.name: self.instance})

in order to be able to realiably create custom inlines that support this the framework needs to:

  1. abstract fetching instance by in index in a ge_instance_by_index method so that we can implement admins that do what we want of top of current base classes. If this is done then we will be able to overwrite ge_instance_by_index to return list(self.get_queryset())[i] instead of self.get_queryset()[i] on inlines that support prefetching
  2. remove queriset preparation logic from BaseInlineFormSet constructor. For example if queryset was prepared in a method like the following we could overwrite it to keep related manager queryset intact:

def prepare_queryset(queryset):
       if queryset is None:
           queryset = self.model._default_manager
       if self.instance.pk is not None:
           qs = queryset.filter(**{self.fk.name: self.instance})
       else:
           qs = queryset.none()
       return qs

would become just

def prepare_queryset(queryset):
   return  queryset
  1. Provide object in get queryset so that we can get the queryset from the desired related manager

Hacky approach that proves that we could get to a usable custom solution if the above was fixed is:

class RelatedManagerInlineFormSet(BaseInlineFormSet):
    """
    -------hack around issue 2-------
    """ 
    def get_queryset(self):
        return list(self.queryset)
    @property
    def queryset(self):
        return self._related_manager_queryset

    @queryset.setter
    def queryset(self, v):
        #keeps the property readonly
        pass


    def __init__(self,*args, **kwargs) -> None:
        self._related_manager_queryset=kwargs['queryset']
        kwargs['queryset']=None
        super().__init__(*args, **kwargs)

class RelatedModelManagerInline(BaseInlineFormSet):
    """Relate manager inline mixin to be used
    """
    formset=RelatedManagerInlineFormSet
    def get_formset(self, request: HttpRequest, obj=None, *args, **kwargs):
        """
         -------hack around issue 3-------
        """ 
        if obj is not None:
            self.obj=obj
        return super().get_formset(request, obj=obj, *args, **kwargs)

    def get_queryset(self, request: HttpRequest) -> list:
        """
        Returns a list for related manager's queryset, although dangerous
        -------returning a list is a hack around issue 1-------
        add 'related_name' property to the class that extends this
        Args:
            request (HttpRequest): the request being performed

        Returns:
            list: the list returned from related queryset
        """
        return list(getattr(self.obj, self.related_name).all())

PLEASE DO NOT USE. THE ABOVE IS JUST DEMONSTRATING THAT IF ISSUES GET SOLVED WE COULD HAVE A CUSTOM INLINE SUPPORTING PREFETCHING

Personally I believe that prefetching support is expected to be a built-in feature since wide discussions prove the that the community expects it as a default behaviour, therefore I created a ticket here: https://code.djangoproject.com/ticket/32587

Related