Search filters¶
A query parameter dedicated to search functionality is common in applications. Generally speaking, this filter will require performing lookups on multiple fields.
This can easily be achieved using the template parameter. Here is an example:
class UserFilterSet(FilterSet[User]):
search = Filter(
serializers.CharField(),
template=Q("username__icontains")
| Q("email__icontains")
| Q("first_name__icontains")
| Q("last_name__icontains"),
)
Using the template parameter, we can create complex multi-field lookups with ease. Notice that we did not specify values for Q objects, since their values will be determined by the value of query parameter itself.
Now let’s do something more involved: What if we allowed users to specify which fields to search on? This way our search parameter would be much more flexible, allowing users to do lookups on specific fields.
To do this, we would need to create an auxiliary query parameter called
search.fields, containing comma separated field names. Here is a detailed
implementation:
from rest_filters.constraints import Dependency
from rest_filters.fields import CSVField
from rest_filters.filters import Entry
from django.db.models import Q
class UserFilterSet(FilterSet[User]):
search = Filter(
serializers.CharField(),
group="search",
children=[
Filter(
CSVField(
child=serializers.ChoiceField(
choices=[
"username",
"email",
"first_name",
"last_name",
]
),
),
param="fields",
noop=True,
),
],
)
def get_group_entry(self, group: str, entries: dict[str, Entry]) -> Entry:
# 'entries' contain the resolved 'Entry' objects for each filter in
# given 'group', if the param is not provided, it will not appear
# in this dict. This method won't be called for groups that do not
# appear at all in query parameters.
if group == "search" and (search := entries.get("search")) is not None:
value, fields = (
search.value,
["username", "email", "first_name", "last_name"],
)
# If user provided this query parameter, use it instead.
# Otherwise we will use all the available fields.
if search_fields := entries.get("search.fields"):
fields = search_fields.value
expr = Q()
for field in fields:
expr |= Q(**{f"{field}__icontains": value})
return Entry(group=group, value=value, expression=expr)
return super().get_group_entry(group, entries)
class Meta:
constraints = [
Dependency(
fields=["search.fields"],
depends_on=["search"],
),
]
In the example above, the following is happening:
We created a filter which encapsulates search parameters, with
search.fieldsbeing the child ofsearch.We assigned a group named “search” to these filters so that they would fall into the same group. This allows us to use
get_group_entrymethod to capture them together.We used a plain
CharFieldfor the search term and combinedCSVFieldwithChoiceFieldto create a multiple choice query parameter for search fields.We marked
search.fieldswithnoop=Trueso that it would not attempt to resolve a query expression, this is because this field by itself does nothing and is used as a “helper”.In
get_group_entry, we captured these fields’ values and dynamically resolved the final query expression of thesearchgroup.We added a dependency constraint so that specifying
search.fieldswithout a search term would raise aValidationError, informing user about the requirement.
This example could be further extended by:
Allowing lookups; for example, users could specify
usernamefor exact lookups andusername.icontainsfor substring lookups.Using an additional query parameter to determine the logical operator.
This is left as an exercise for the reader.