Aggregation with django-filter via Proxy Models

By Aaron O. Ellis

The django-filter package adds a quick way to add URL parameter driven filtering to existing Django database models. For example, take the following model:

from django.db import models

class Purchase(models.Model):
    item = models.TextField()
    amount = models.FloatField()
    at = models.DateTimeField()

    class Meta:
        db_table = "purchases"

We can then use django-filter to quickly create a FilterSet class that wraps the above database model:

import django_filters
from .models import Purchase

class PurchaseFilter(django_filters.FilterSet):
    item = django_filters.CharFilter(lookup_expr="iexact")
    sort = django_filters.OrderingFilter(
        fields=(
            ("item", "item"),
            ("amount", "amount"),
            ("at", "at"),
        ),
    )

    class Meta:
        model = Purchase
        fields = ("amount", "at")

The above can then be integrated into views, such as ListView:

from django.views.generic.list import ListView
from .filters import PurchaseFilter

class Purchases(ListView):
    def get_queryset(self, **kwargs):
        return PurchaseFilter(self.request.GET).qs

This configuration allows GET parameters to control the filtering and ordering of database queries in views with a minimal amount of glue code. For instance, if the above Purchases view is at the /purchases URL, requesting /purchases?item=lunch&sort=amount will generate the following SQL:

SELECT "purchases"."id", "purchases"."item", "purchases"."amount", "purchases"."at"
FROM "purchases"
WHERE "purchases"."item" LIKE 'lunch'
ORDER BY "purchases"."amount";

The FilterSet can be extended by overriding its qs property. For instance, to aggregate the results by item and sum their amounts:

class AggregatedPurchaseFilter(django_filters.FilterSet):
    item = django_filters.CharFilter(lookup_expr="iexact")
    sort = django_filters.OrderingFilter(
        fields=(
            ("item", "item"),
            ("total", "total"),
        ),
    )

    @property
    def qs(self):
        return super().qs.values("item").annotate(total=models.Sum("amount"))

    class Meta:
        model = Purchase
        fields = ("item",)

The above produces a familiar GROUP BY statement in SQL:

SELECT "purchases"."item", SUM("purchases"."amount") AS "total"
FROM "purchases"
GROUP BY "purchases"."item";

However, when attempting to sort the filter by total using the URL sort parameter, the following error is returned:

FieldError
Cannot resolve keyword 'total' into field. Choices are: amount, at, id, item

Order Matters

This error originates from the Django ORM query planner when the parent FilterSet class attempts to add ordering to the queryset. In brief, the query planner is being told to order by a field that has not yet been created! We can reproduce the same error without django-filter using the following query:

Purchase.objects.order_by("total").values("item").annotate(total=models.Sum("amount"))

Developers familiar with SQL might consider this an obvious mistake. Of course order matters! SQL is fairly strict about its syntax and placing an ORDER BY clause before GROUP BY would result in a syntax error. The Django ORM, however, abstracts query construction, and in our examples above, that construction is spread across several classes and their sub-classes.

To fix our FieldError issue, we need to aggregate the queryset before it is passed to the FilterSet. Thankfully, the FilterSet includes an optional queryset parameter that does exactly this:

queryset = Purchase.objects.values("item").annotate(total=models.Sum("amount"))
PurchaseFilter(request.GET, queryset=queryset)

Which produces the expected SQL without issue:

SELECT "purchases"."item", SUM("purchases"."amount") AS "total"
FROM "purchases"
GROUP BY "purchases"."item"
ORDER BY "total";

Proxy Models

Unfortunately, having to filter the queryset before initializing the FilterSet adds a considerable amount of glue code to our application and prevents us from writing views with modular filters that can be specified by class.

Thankfully, we can also aggregate our queryset before the FilterSet via proxy models and custom managers. We can add the following alongside our Purchase class:

class AggregatedPurchaseManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().values("item").annotate(total=models.Sum("amount"))


class AggregatedPurchase(Purchase):
    objects = AggregatedPurchaseManager()

    class Meta:
        proxy = True

The above proxy model allows us to change the default queryset without additional database tables. We also no longer need to extend the FilterSet queryset or pass the filtered queryset to it during initialization. We only need to change its model property to our proxy:

class AggregatedPurchaseFilter(django_filters.FilterSet):
    item = django_filters.CharFilter(lookup_expr="iexact")
    sort = django_filters.OrderingFilter(
        fields=(
            ("item", "item"),
            ("total", "total"),
        ),
    )

    class Meta:
        model = AggregatedPurchase
        fields = ("item",)

By using the above FilterSet in our view, we can now order by the aggregated field via URL parameters such as /purchases?sort=total.