Using subgroups¶
Subgroups offer a powerful way to control relationships between filters and groups. With properly assigned subgroups, you can control the logical operator between each resolved query expression, whether it is created by a group or by a single query parameter.
Warning
Subgroups are a relatively advanced (and niche) feature. Make sure you understand the Concepts and familiarize yourself with the FilterSet Reference before using them.
Before using subgroups, you should also try solving the problem at hand
using groups. Groups can resolve fairly complex queries using APIs like
get_combinator and get_group_entry. See: Using groups
To create a subgroup, you need to choose a namespace which will contain your groups. For example:
class UserFilterSet(FilterSet[User]):
city = Filter(serializers.CharField(), group="user.location")
This filter defines a group named user.location and a namespace called
user. However, this is not particularly useful yet, since there is only one
subgroup assigned to that namespace. Let’s add another subgroup and additional
filters:
class UserFilterSet(FilterSet[User]):
city = Filter(serializers.CharField(), group="user.location")
country = Filter(serializers.CharField(), group="user.location")
website = Filter(serializers.CharField(), group="user.contact")
email = Filter(serializers.CharField(), group="user.contact")
In this example we have the following:
A group namespace called
userA group named
user.location, subgroup ofuserA group named
user.contact, subgroup ofuser
Now, with this setup, we would like to perform the following request:
?city=New
&country=USA
&website=alan
&email=alan
and achieve a query equivalent to:
(Q(city="New") | Q(country="USA")) & (Q(website="alan") | Q(email="alan"))
# ^ user.location ^ @user ^ user.contact
This can be done by specifying combinators in the Meta class like so:
import operator
class Meta:
combinators = {
"user.location": operator.or_,
"user.contact": operator.or_,
}
Nice. But we could have done this using different group names anyway. No need for subgroups, right? Not quite; using different group names would have created the following query instead:
User.objects.filter((Q(city="New") | Q(country="USA"))).filter(
(Q(website="alan") | Q(email="alan"))
)
This is slightly different, as you may remember from Using groups. Also, we have something more useful here: we can now control the operator between different groups. For example:
class Meta:
combinators = {
"@user": operator.or_,
# Not specifying other groups so they use
# the default, which is AND.
}
Resulting in:
(Q(city="New") & Q(country="USA")) | (Q(website="alan") & Q(email="alan"))
# ^ user.location ^ @user ^ user.contact
Notice that we used @ prefix to reference the combinator between subgroups
of that namespace. If we used user instead of @user, that would refer
to a concrete group called user which is non-existent in this example.
Note
You can also use namespaces as group names, however this is not recommended since it might lead to confusion.
Now, let’s get spicy. In the following example, we give all the control to our users, so they will decide which group gets which combinator:
class UserFilterSet(FilterSet[User]):
location = Filter(
serializers.CharField(),
namespace=True,
group="user.location",
children=[
Filter(param="city", field="city"),
Filter(param="country", field="country"),
Filter(
serializers.ChoiceField(choices=["and", "or"]),
param="combine",
noop=True,
),
],
)
contact = Filter(
serializers.CharField(),
namespace=True,
group="user.contact",
children=[
Filter(param="email", field="email"),
Filter(param="website", field="website"),
Filter(
serializers.ChoiceField(choices=["and", "or"]),
param="combine",
noop=True,
),
],
)
combine = Filter(
serializers.ChoiceField(choices=["and", "or"]),
group="user.meta",
param="combine",
noop=True,
)
def get_combinator(
self, group: str, entries: dict[str, Entry]
) -> Callable[..., Any]:
lookups = {"and": operator.and_, "or": operator.or_}
if group in ("user.contact", "user.location"):
name = group.split(".")[-1] # e.g., "contact", "location"
if combine := entries.get(f"{name}.combine"):
return lookups[combine.value]
if group == "@user":
# At this point, '@user' is trying to combine 3 groups,
# 'user.contact', 'user.location', and 'user.meta'
# Did the user specify any filters from the 'user.meta' group?
if meta := entries.get("user.meta"):
# ^ meta.value is dict, containing query parameters and their
# parsed values for the 'user.meta' group.
lookup = meta.value["combine"]
# ^ No need to use .get(), since only 1 filter exists in the
# 'user.meta' group; we can count on its existence.
return lookups[lookup]
return super().get_combinator(group, entries)
The example above allows queries such as:
location.city=New&location.country=USA&location.combine=andlocation.city=New&contact.email=alan&combine=orlocation.city=New&contact.email=alan&contact.website=alan&contact.combine=or&combine=or
As demonstrated in this example, the following methods receive group names and
namespaces, indicated by a leading @:
Important
Namespaces can have arbitrary depth. For example, a group specifier
user.contact.private will create 2 namespaces: user and
user.contact. Similarly, you’ll be able to resolve @user.contact;
this capability allows for building arbitrary query expressions.