Solution to not split logic in django to solve N+1 queries
Here are some of my models:
class CustomUser(AbstractUser):
def correct_date(self, date=None):
res = self.dates.order_by("date").all()
if not len(res):
return None
return res[len(res) - 1]
class Date(models.Model):
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="dates")
date = models.DateField(auto_now_add=True)
To fix N+1 queries I need to extract the order_by in the view with a Prefetch
:
queryset = Project.objects.prefetch_related(
Prefetch(
"user__dates",
queryset=Date.objects.order_by('date')
),
)
and remove the order_by
in my correct_date
method.
The problem here is that correct_date depends on the order_by
in order to retrieve the last date. But the order_by
is in an other file. That could leeds to issues for the ones using the code after.
Is there any other solutions than:
- Keeping the code as it is with a comment # needs to be used with an order_by('date') before
- Using a service to handle all that logic
- Checking in the
correct_date
method if it was called with an order_by before, and throwing an error or apply order_by if it wasn't
To avoid the N+1 query issue while maintaining clean separation of logic, you can make your correct_date()
method more flexible by allowing it to accept a list of pre-fetched, ordered dates. This avoids hidden dependencies on order_by()
and makes your model method reusable and testable.
class CustomUser(AbstractUser):
def correct_date(self, prefetched_dates=None):
dates = prefetched_dates if prefetched_dates is not None else self.dates.order_by("date").all()
return dates.last() if dates else None
Prefetch in the View:
Use Prefetch
with to_attr
to assign the ordered dates to a temporary attribute:
from django.db.models import Prefetch
queryset = Project.objects.prefetch_related(
Prefetch(
"user__dates",
queryset=Date.objects.order_by("date"),
to_attr="prefetched_ordered_dates"
)
)
Usage in View or Template Logic:
Then pass the prefetched data explicitly to the method:
for project in queryset:
user = project.user
last_date = user.correct_date(getattr(user, "prefetched_ordered_dates", None))
The solution I found was creating a Mixin:
class PrefetchedDataMixin:
def has_prefetch(self, to_attr: str = "", related_name: str = ""):
return (
hasattr(self, '_prefetched_objects_cache') and related_name in self._prefetched_objects_cache
if related_name
else hasattr(self, to_attr))
def get_prefetch(self, fct, to_attr: str = "", related_name: str = ""):
if self.has_prefetch(to_attr=to_attr, related_name=related_name):
return getattr(self, to_attr if to_attr else related_name)
return fct()
And use it un CustomUser
:
class CustomUser(AbstractUser, PrefetchedDataMixin):
def correct_date(self, date=None):
res = self.get_prefetch(
lambda: self.dates.order_by('date'),
related_name="dates"
).all()
if not len(res):
return None
return res[len(res) - 1]
That way I have a Mixin to handle:
- Prefetch to a
to_attr
if provided - Explicit needs of prefetch data when looking at code for
fct
param - A default value in case devlopper forgot to use
Prefetch