Expand a QuerySet with all related objects

class Hobby(models.Model):
    name = models.TextField()


class Person(models.Model):
    name = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    hobbies = models.ManyToManyField(Hobby, related_name='persons')


class TShirt(models.Model):
    name = models.TextField()
    person = models.ForeignKey(
        Person,
        related_name='tshirts',
        on_delete=models.CASCADE,
    )


class Shirt(models.Model):
    name = models.TextField()
    person = models.ForeignKey(
        Person,
        related_name='shirts',
        on_delete=models.CASCADE,
    )


class Shoes(models.Model):
    name = models.TextField()
    person = models.ForeignKey(
        Person,
        related_name='shoes',
        on_delete=models.CASCADE,
    )

Given a queryset of Person, e.g.

Person.objects.order_by('-created_at')[:4]

How can I make a queryset which also includes all the objects related to the Person objects in that queryset?

The input QuerySet only has Person objects, but the output one should have Hobby, Shoes, TShirt, Shirt` objects (if there are shirts/tshirts/shoes that reference any of the people in the original queryset).

I've only been able to think of solutions that rely on knowing what the related objects are, e.g. TShirt.objects.filter(person__in=person_queryset), but I would like a solution that will work for all models that reference Person without me having to one-by-one code each query for each referencing model.

What you want to do can be done, for example, using the class Meta method get_fields. The corresponding function will look like this:

from django.db import models


def get_non_hidden_m2o_and_m2m_prefetch_lookups(model):
    result = []
    for field in model._meta.get_fields():
        if not isinstance(field, models.ManyToOneRel | models.ManyToManyField):
            continue

        try:
            result.append(field.related_name or field.name + '_set')
        except AttributeError:
            result.append(field.name)

    return result

You can then use, for example, your queryset as shown in the code below and this should work for you. This will fetch all related objects: ManyToMany and ManyToOne for each person object, and attach a list of the results to the prefetch_lookup + '_list' attribute, for example: hobbies_list, shoes_list, and so on.:

prefetch_lookups = get_non_hidden_m2o_and_m2m_prefetch_lookups(model=Person)
prefetch_args = [(lookup, lookup + '_list') for lookup in prefetch_lookups]
queryset = (
    Person
    .objects
    .prefetch_related(
        *(
            models.Prefetch(lookup=lookup, to_attr=to_attr)
            for lookup, to_attr in prefetch_args
        )
    )
    .order_by('-created_at')[:4]
)
for person in queryset:
    print(person)
    for _, attr in prefetch_args:
        print(attr , getattr(person, attr))

Or, as Zen of Python advises:

import this

...
Explicit is better than implicit.
...

You can do this explicitly, for example, by simply listing what you want to extract. These two methods will return similar results:

prefetch_objects = (  
    models.Prefetch(lookup='hobbies', to_attr='hobbies_list'),  
    models.Prefetch(lookup='tshirts', to_attr='tshirts_list'),  
    models.Prefetch(lookup='shirts', to_attr='shirts_list'),  
    models.Prefetch(lookup='shoes', to_attr='shoes_list'),  
)  
queryset = (  
    Person.objects  
    .prefetch_related(*prefetch_objects)  
    .order_by('-created_at')[:4]  
)

To achieve this in a single query, we can use prefetch_related() to efficiently fetch related objects in a single database hit. However, Django's ORM does not allow returning multiple model types in one QuerySet. The best approach is to fetch the Person objects along with related Hobby, TShirt, Shirt, and Shoes using prefetch_related()

persons = Person.objects.order_by('-created_at').prefetch_related(
'hobbies',  # ManyToManyField
'tshirts',  # Reverse ForeignKey
'shirts',
'shoes')

for person in persons:
print(f"Person: {person.name}")
print("  Hobbies:", [h.name for h in person.hobbies.all()])
print("  TShirts:", [t.name for t in person.tshirts.all()])
print("  Shirts:", [s.name for s in person.shirts.all()])
print("  Shoes:", [sh.name for sh in person.shoes.all()])
print("-" * 40)
Back to Top