Оптимизация запросов к базе данных Django на filter() одного и того же QuerySet и присвоение разным переменным

У меня есть база queryset и цель - построить статистическую панель, используя базу queryset, но отфильтрованную по разным значениям для каждой переменной.

Мой упрощенный код для лучшего понимания:

class Transaction(models.Model):
    """ Invoice model.
     Represents a basic income/outcome transaction. """
    
    user = models.ForeignKey(CustomUser, related_name="transactions", on_delete=models.CASCADE)
    title = models.CharField(max_length=32, verbose_name="Title")
    category = models.ForeignKey(Category, related_name="transactions", on_delete=models.CASCADE, null=True, blank=True)
    operation = models.CharField(max_length=8, choices=OPERATION_TYPE, verbose_name="operation")
    value = models.DecimalField(max_digits=14, decimal_places=2, verbose_name="value")
    date_created = models.DateTimeField(auto_now_add=True, blank=True, null=True) 

class Category(MPTTModel):
    """ Category model. 
    Represents a category where money have been spent/earned."""

    name = models.CharField(max_length=54, unique=True)
    parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name='children')

Имея вышеуказанные модели, я создал ClassBasedView(ListView) :

class DashboardView(ListView):
    """ View implements dashboard functionality. """
    model = Transaction
    template_name = "invoices/dashboard.html"
    ordering = "-date_created"


    def get_queryset(self) -> QuerySet[Any]:
        queryset = super().get_queryset()
        queryset = queryset.filter(user=self.request.user).select_related("category__parent")
        return queryset

    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        data = super().get_context_data(**kwargs)

        # retrieving all transactons
        transactions_all = self.get_queryset()

        # retrieving incomes/expenses summary for current month
        incomes_this_month = transactions_all.filter(transaction_filter.transaction_date_filter(month="current"), operation="incomes")
        incomes_this_month_sum = incomes_this_month.aggregate(Sum("value")).get("value__sum")
        
        expenses_this_month = transactions_all.filter(transaction_filter.transaction_date_filter(month="current"), operation="expenses")                          
        expenses_this_month_sum = expenses_this_month.aggregate(Sum("value")).get("value__sum")
        
        # retrieving incomes/expenses summary for previous month
        transactions_prev_month = transactions_all.filter(transaction_filter.transaction_date_filter(month="previous"))
   
        incomes_previous_month = transactions_prev_month.filter(operation="incomes")
        incomes_previous_month_sum = incomes_previous_month.aggregate(Sum("value")).get("value__sum")

        expenses_previous_month = transactions_prev_month.filter(operation="expenses")
        expenses_previous_month_sum = expenses_previous_month.aggregate(Sum("value")).get("value__sum")

Вы можете увидеть transaction_date_filter в filter() менеджере. Это просто Q() объекты для фильтрации по дате.

Используя select_related, мне удалось удалить дубликаты, но все еще остаются "похожие" запросы, поскольку Django ORM запрашивает отдельные запросы для фильтрации incomes_this_month, expenses_this_month, incomes_previous_month и expenses_previous_month по полям date= и operation=. Я знаю, что менеджер filter() "сбрасывает" кэшированные объекты, поэтому пытаюсь найти более эффективный способ получить такие данные, не забивая БД лишними запросами. Я приложил скриншот счетчика запросов DjDT. Надеюсь, это сделает вопрос более понятным.

Буду признателен за любое предложение по этой теме.

enter image description here

Чтобы свести все это к одному запросу, можно воспользоваться функцией SQL GROUP_BY, которая позволяет сделать запрос на все доходы и расходы за этот и прошлый месяц, а затем разделить их на нужные нам группы. В Django мы можем сделать это, вызвав .values() на наборе запросов. Аргументы, передаваемые в .values(), - это атрибуты, которые мы хотим использовать для создания различных групп. В вашем случае мы хотим иметь разные группы данных для различных комбинаций месяца и операции транзакции.

transactions_all.values("month", "operation")

Но прежде чем этот запрос будет работать, нам нужно .annotate() создать набор запросов, чтобы атрибут "месяц" действительно существовал для каждой транзакции. Я не знаю, как работает ваш transaction_filter.transaction_date_filter, поэтому я собираюсь написать свою собственную аннотацию. Возможно, вы сможете адаптировать существующий фильтр дат для этой цели. Я буду использовать ExtractMonth, чтобы получить только значение месяца из поля date_created.

from django.db.models.functions import ExtractMonth

transactions_all.annotate(month=ExtractMonth("date_created"))

Одна из проблем аннотированного набора запросов сейчас заключается в том, что год транзакции вообще не учитывается, поэтому мы получим все транзакции, совершенные в этом месяце этого года, а также в этом же месяце прошлого и позапрошлого года (КАЖДЫЙ март, а не только этот). Мы можем исправить это, добавив фильтр в набор запросов, чтобы убедиться, что мы запрашиваем данные только за последние два месяца.

transactions_all.filter(date_created__range=[beginning_of_last_month, end_of_this_month])

Последнее, что нужно добавить в набор запросов, - это заключительный .annotate(), который суммирует значения транзакций в каждой группе. Этот .annotate() заменяет .aggregate(), который вы использовали в своих запросах, но служит той же цели - суммированию значений транзакций. Нам это необходимо, поскольку наш результат содержит несколько объектов, поэтому мы используем .annotate(), чтобы добавить атрибут к каждому из объектов в наборе запросов. Использование .aggregate() привело бы к сжатию результата кверисета до одного объекта, что разрушило бы сделанную нами группировку.

transactions_all.filter(
        date_created__range=[beginning_of_last_month, end_of_this_month]
    ).annotate(
        month=ExtractMonth("date_created")
    ).values(
        "month", "operation"
    ).annotate(
        value_sum=Sum("value")
    )

Это позволит получить все необходимые нам данные в одном SQL-запросе и выдать результат, который выглядит следующим образом:

<QuerySet [
    {'operation': 'expenses', 'month': 2, 'value_sum': Decimal('750')},
    {'operation': 'expenses', 'month': 3, 'value_sum': Decimal('750')},
    {'operation': 'incomes', 'month': 2, 'value_sum': Decimal('240')},
    {'operation': 'incomes', 'month': 3, 'value_sum': Decimal('350')}
]>
Вернуться на верх