Django ORM эффективно запрашивает вложенные таблицы "многие ко многим"?

Допустим, я проектирую базу данных для создания моделей сессий следующим образом

from django.db import models

class Recipe(models.Model):
    name = models.CharField(max_length=255)
   
    def __str__(self):
        return self.name

class Step(models.Model):
    name = models.CharField(max_length=255)
    recipes = models.ManyToManyField(Recipe, related_name='steps')

    def __str__(self):
        return self.name

class CookingSession(models.Model):
    name = models.CharField(max_length=255)
    steps = models.ManyToManyField(Step, related_name='cooking_sessions')

    def __str__(self):
        return self.name

Как с помощью минимального количества запросов (желательно одного) получить все шаги для определенной кулинарной сессии, где каждый шаг должен иметь соответствующие рецепты, если таковые имеются.

cooking_sessions = (
            CookingSession.objects.annotate(
             
                step_list=ArrayAgg(
                    models.F(
                        "steps__name",
                    ),
                    distinct=True,
                ),
                recipe_list=ArrayAgg(models.F("steps__recipes__name")),
                
            )
           
        )

Вот как выглядят данные

[
    {
        'id': 1,
        'name': 'Italian Night',
        'step_list': ['Preparation', 'Cooking', 'Serving'],
        'recipe_list': ['Tomato Sauce', 'Pasta Dough', 'Spaghetti', 'Tomato Sauce', 'Garlic Bread']
    },
    ...
]

Я бы хотел, чтобы данные имели вид

{
    'id': 1,
    'name': 'Italian Night',
    'steps': [
        {
            'step_name': 'Preparation',
            'recipes': ['Tomato Sauce', 'Pasta Dough']
        },
        {
            'step_name': 'Cooking',
            'recipes': ['Spaghetti', 'Tomato Sauce']
        },
        {
            'step_name': 'Serving',
            'recipes': ['Garlic Bread']
        }
    ]
}

Вы можете преобразовать результат с помощью ArrayAggs [Django-doc] в:

from itertools import groupby
from operator import itemgetter

from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F

cooking_sessions = CookingSession.objects.annotate(
    step_list=ArrayAgg('steps__name'),
    recipe_list=ArrayAgg('steps__recipes__name'),
)

for cooking_session in cooking_sessions:
    cooking_session.steps = [
        {'step_name': name, 'recipes': [r for __, r in items]}
        for name, items in groupby(
            zip(cooking_session.step_list, cooking_session.recipe_list),
            itemgetter(0),
        )
    ]

Но это довольно сложно и чревато ошибками. Например, здесь мы предполагаем, что PostgreSQL вернет steps__name и steps__recipes__name в том же порядке, который со временем может измениться.

Я бы посоветовал просто префетчить элементы с помощью .prefetch_related(…) [Django-doc], что позволит сделать это за два дополнительных запроса, но не за CookingSession. Таким образом, независимо от количества CookingSessions, Steps и Recipes, мы получаем данные в трех запросах с:

CookingSession.objects.prefetch_related('steps__recipes')
Вернуться на верх