Django/Wagtail - most efficient way to refine page queryset by multiple parent pages

Viewed 424

I have a list of category pages obtained as follows:

category_ids = request.GET.getlist('category[]')
category_pages = MyCategoryPage.objects.filter(pk__in=category_ids)

What I'd like to do is refine a list of pages to include only those that are children of the categories specified above.

I'm aware that you can do this for one object as follows:

pages = MySubPage.objects.child_of(category)

..but as far as I can tell there is no way to do child_of(category_pages) for more than one parent.

In normal Django, where I'd probably have a category ForeignKey on MySubPage, I'd just do:

pages = MySubPage.objects.filter(category__in=filter_categories)

..but again I'm not sure that I can do that in Wagtail.

I know I could probably loop through all the categories and append the sub pages to a list:

pages = []
for category in category_pages:
    pages.append(MySubPage.objects.child_of(category))

...and then join them all, but that doesn't seem very efficient and obviously it would return a list rather than a queryset.

I've searched the docs but can't find any more direct way of achieving this.

1 Answers

To solve this, you can simply look at what is going on in the background when you're calling child_of on the queryset. This is what this method and methods of this queryset that it uses looks like (order of their appearance in code not preserved):

    def child_of(self, other):
        """
        This filters the QuerySet to only contain pages that are direct children of the specified page.
        """
        return self.filter(self.child_of_q(other))
   
    def child_of_q(self, other):
        return self.descendant_of_q(other) & Q(depth=other.depth + 1)


    def descendant_of_q(self, other, inclusive=False):
        q = Q(path__startswith=other.path) & Q(depth__gte=other.depth)

        if not inclusive:
            q &= ~Q(pk=other.pk)

        return q

As you can see, wagtail will just return a queryset with some Q objects to filter the list of pages. This filter is based on path to the parent page and depth. To recreate the same filter, but with multiple parents, you can do:

import operator

category_ids = request.GET.getlist('category[]')
pages = MySubPage.objects.filter(reduce(
    operator.or_,
    (
        Q(path__startswith=category.path) & Q(depth=category.depth + 1)
        for category in MyCategoryPage.objects.filter(pk__in=category_ids)
    )
))

Note that I've optimized the query by removing Q(depth__gte=category.depth) from it, because this condition will be met by Q(depth=category.depth + 1). It also doesn't need the ~Q(pk=category.pk) for the same reason.

Also note that this needs 2 queries in total, which can be optimized to only one query with clever use of path and depth of categories directly in your GET parameters.

Related