Filter by custom QuerySet of matching model in Django
Let's say I have two models: Book
andAuthor
class Author(models.Model):
name = models.CharField()
country = models.CharField()
approved = models.BooleanField()
class Book(models.Model):
title = models.CharField()
approved = models.BooleanField()
author = models.ForeignKey(Author)
Each of the two models has an attribute approved
that shows or hides the object from the website. If Book
not approved, it is hidden. If Author
not approved, all his books are hidden.
To define these criteria in a dry manner, creating a custom QuerySet looks like the ideal solution:
class AuthorQuerySet(models.query.QuerySet):
def for_site():
return self.filter(approved=True)
class BookQuerySet(models.query.QuerySet):
def for_site():
reuturn self.filter(approved=True).filter(author__approved=True)
Once these QuerysSets are connected to the appropriate models, they can be queried like this Book.objects.for_site()
, without having to hard-code all filtering every time.
However, this solution is still not perfect. Later I may decide to add another filter to authors:
class AuthorQuerySet(models.query.QuerySet):
def for_site():
return self.filter(approved=True).exclude(country='Problematic Country')
but this new filter will only work in Author.objects.for_site()
, but not in Book.objects.for_site()
, as it is hardcoded there.
So my questions are, it is possible to apply a custom queryset of a related model while filtering on another model so that it looks something like this:
class BookQuerySet(models.query.QuerySet):
def for_site():
reuturn self.filter(approved=True).filter(author__for_site=True)
where for_site
is the custom QuerySet of the model Author
.
source to share
I think I came up with a solution based on objects Q
that are described in the official documentation. This is definitely not the most elegant solution you can think of, but it works. See code below.
from django.db import models
from django.db.models import Q
######## Custom querysets
class QuerySetRelated(models.query.QuerySet):
"""Queryset that can be applied in filters on related models"""
@classmethod
def _qq(cls, q, related_name):
"""Returns a Q object or a QuerySet filtered with the Q object, prepending fields with the related_name if specified"""
if not related_name:
# Returning Q object without changes
return q
# Recursively updating keywords in this and nested Q objects
for i_child in range(len(q.children)):
child = q.children[i_child]
if isinstance(child, Q):
q.children[i_child] = cls._qq(child, related_name)
else:
q.children[i_child] = ('__'.join([related_name, child[0]]), child[1])
return q
class AuthorQuerySet(QuerySetRelated):
@classmethod
def for_site_q(cls, q_prefix=None):
q = Q(approved=True)
q = q & ~Q(country='Problematic Country')
return cls._qq(q, q_prefix)
def for_site(self):
return self.filter(self.for_site_q())
class BookQuerySet(QuerySetRelated):
@classmethod
def for_site_q(cls, q_prefix=None):
q = Q(approved=True) & AuthorQuerySet.for_site_q('author')
return cls._qq(q, q_prefix)
def for_site(self):
return self.filter(self.for_site_q())
######## Models
class Author(models.Model):
name = models.CharField(max_length=255)
country = models.CharField(max_length=255)
approved = models.BooleanField()
objects = AuthorQuerySet.as_manager()
class Book(models.Model):
title = models.CharField(max_length=255)
approved = models.BooleanField()
author = models.ForeignKey(Author)
objects = BookQuerySet.as_manager()
This way, whenever the method AuthorQuerySet.for_site_q()
changes, it will be automatically reflected in the method BookQuerySet.for_site()
.
Here custom classes QuerySet
perform class-level selection by combining different objects Q
instead of using methods filter()
or exclude()
at the object level. Having an object Q
allows three different ways to use it:
- put it inside the call
filter()
to filter the set of queries in place; - combine it with other objects
Q
using operators& (AND)
or| (OR)
; - dynamically change the names of keywords used in objects
Q
by referring to its attributechildren
, which is defined in the superclassdjango.utils.tree.Node
The method _qq()
defined in each custom class QuerySet
takes care of adding the specified related_name
to all filter keys.
If we have an object q = Q(approved=True)
, we can have the following outputs:
-
self._qq(q)
- equivalentself.filter(approved=True)
; -
self._qq(q, 'author')
- equivalentself.filter(author__approved=True)
This solution still has serious drawbacks:
- you must import and call your own class of the
QuerySet
corresponding model explicitly - for each filtering method, two methods
filter_q
(class method) andfilter
(instance method) must be defined ;
UPDATE: The disadvantage 2.
can be partially mitigated by creating dynamic filtering methods:
# in class QuerySetRelated
@classmethod
def add_filters(cls, names):
for name in names:
method_q = getattr(cls, '{0:s}_q'.format(name))
def function(self, *args, **kwargs):
return self.filter(method_q(*args, **kwargs))
setattr(cls, name, function)
AuthorQuerySet.add_filters(['for_site'])
BookQuerySet.add_filters(['for_site'])
So if anyone comes up with a more elegant solution, please suggest one. It would be very grateful.
source to share