Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Filter by custom QuerySet of a related model in Django

Let's say I have two models: Book and Author

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 approved attribute, which shows or hides the object from the website. If the Book is not approved, it is hidden. If the Author is not approved, all his books are hidden.

In order to define these criteria in a DRY manner, making a custom QuerySet looks like a perfect 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)

After hooking up these QuerysSets to the corresponding models, they can be queried like this: Book.objects.for_site(), without the need to hardcode all the filtering every time.


Nevertheless, this solution is still not perfect. Later I can 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(), since there it is hardcoded.


So my questions is: is it possible to apply a custom queryset of a related model when filtering on a different model, so that it looks similar to this:

class BookQuerySet(models.query.QuerySet):
    def for_site():
        reuturn self.filter(approved=True).filter(author__for_site=True)

where for_site is a custom QuerySet of the Author model.

like image 466
BartoNaz Avatar asked Nov 08 '22 22:11

BartoNaz


1 Answers

I think, I've come up with a solution based on Q objects, which are described in the official documentation. This is definitely not the most elegant solution one can invent, but it works. See the 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 AuthorQuerySet.for_site_q() method is changed, it will be automatically reflected in the BookQuerySet.for_site() method.

Here the custom QuerySet classes perform selection at the class level by combining different Q objects, instead of using filter() or exclude() methods at the object level. Having a Q object allows 3 different ways of using it:

  1. put it inside a filter() call, to filter a queryset in place;
  2. combine it with other Q objects using & (AND) or | (OR) operators;
  3. dynamically change names of keywords used in the Q objects by accessing its children attribute, which is defined in the superclass django.utils.tree.Node

The _qq() method defined in every custom QuerySet class takes care of prepending the specified related_name to all filter keys.

If we have a q = Q(approved=True) object, then we can have the following outputs:

  1. self._qq(q) – is equivalent to self.filter(approved=True);
  2. self._qq(q, 'author') – is equivalent to self.filter(author__approved=True)

This solution still has serious drawbacks:

  1. one has to import and call custom QuerySet class of the related model explicitly;
  2. for each filter method one has to define two methods filter_q (class method) and filter (instance method);

UPDATE: The drawback 2. can be partially reduced by creating filter methods dynamically:

# 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'])

Therefore, if someone comes up with a more elegant solution, please suggest it. It would be very appreciated.

like image 102
BartoNaz Avatar answered Nov 15 '22 04:11

BartoNaz