Filtering
If you want to allow the user to filter your querysets by a number of different attributes, it makes sense
to encapsulate your filters into a FilterSchema class. FilterSchema is a regular Schema, so it's using all the
necessary features of Pydantic, but it also adds some bells and whistles that ease the translation of the user-facing filtering
parameters into database queries.
Start off with defining a subclass of FilterSchema:
from ninja import FilterSchema
from typing import Optional
from datetime import datetime
class BookFilterSchema(FilterSchema):
name: Optional[str] = None
author: Optional[str] = None
created_after: Optional[datetime] = None
Next, use this schema in conjunction with Query in your API handler:
@api.get("/books")
def list_books(request, filters: BookFilterSchema = Query(...)):
books = Book.objects.all()
books = filters.filter(books)
return books
Just like described in defining query params using schema, Django Ninja converts the fields
defined in BookFilterSchema into query parameters.
You can use a shorthand one-liner .filter() to apply those filters to your queryset:
@api.get("/books")
def list_books(request, filters: Query[BookFilterSchema]):
books = Book.objects.all()
books = filters.filter(books)
return books
Under the hood, FilterSchema converts its fields into Q expressions which it then combines and uses to filter your queryset.
Alternatively to using the .filter method, you can get the prepared Q-expression and perform the filtering yourself.
That can be useful, when you have some additional queryset filtering on top of what you expose to the user through the API:
@api.get("/books")
def list_books(request, filters: Query[BookFilterSchema]):
# Never serve books from inactive publishers and authors
q = Q(author__is_active=True) | Q(publisher__is_active=True)
# But allow filtering the rest of the books
q &= filters.get_filter_expression()
return Book.objects.filter(q)
By default, the filters will behave the following way:
Nonevalues will be ignored and not filtered against;- Every non-
Nonefield will be converted into aQ-expression based on theFielddefinition of each field; - All
Q-expressions will be merged into one usingANDlogical operator; - The resulting
Q-expression is used to filter the queryset and return you a queryset with a.filterclause applied.
Customizing Fields
By default, FilterSet will use the field names to generate Q expressions:
class BookFilterSchema(FilterSchema):
name: Optional[str] = None
name field will be converted into Q(name=...) expression.
When your database lookups are more complicated than that, you can annotate your fields with an instance of FilterLookup where you specify how you wish your field to be looked up for filtering:
from ninja import FilterSchema, FilterLookup
from typing import Annotated
class BookFilterSchema(FilterSchema):
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
You can even specify multiple lookups as a list:
class BookFilterSchema(FilterSchema):
search: Annotated[Optional[str], FilterLookup(
["name__icontains",
"author__name__icontains",
"publisher__name__icontains"]
)]
By default, field-level expressions are combined using "OR" connector, so with the above setup, a query parameter ?search=foobar will search for books that have "foobar" in either of their name, author or publisher.
And to make generic fields, you can make the field name implicit by skipping it:
IContainsField = Annotated[Optional[str], FilterLookup('__icontains')]
class BookFilterSchema(FilterSchema):
name: IContainsField = None
Deprecated syntax
In previous versions, database lookups were specified using Field(q=...) syntax:
from ninja import FilterSchema, Field
class BookFilterSchema(FilterSchema):
name: Optional[str] = Field(None, q="name__icontains")
This approach is still supported, but it is considered deprecated and not recommended for new code because:
- Poor IDE support (IDEs don't recognize custom
Fieldarguments) - Uses deprecated Pydantic features (
**extra) - Less type-safe and harder to maintain
The new FilterLookup annotation provides better developer experience with full IDE support and type safety. Prefer using FilterLookup for new projects.
Combining expressions
By default,
- Field-level expressions are joined together using
ORoperator. - The fields themselves are joined together using
ANDoperator.
So, with the following FilterSchema...
class BookFilterSchema(FilterSchema):
search: Annotated[
Optional[str],
FilterLookup(["name__icontains", "author__name__icontains"])] = None
popular: Optional[bool] = None
http://localhost:8000/api/books?search=harry&popular=true
FilterSchema instance will look for popular books that have harry in the book's or author's name.
You can customize this behavior using an expression_connector argument in field-level and class-level definition:
from ninja import FilterConfigDict, FilterLookup, FilterSchema
class BookFilterSchema(FilterSchema):
active: Annotated[
Optional[bool],
FilterLookup(
["is_active", "publisher__is_active"],
expression_connector="AND"
)] = None
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
model_config = FilterConfigDict(expression_connector="OR")
An expression connector can take the values of "OR", "AND" and "XOR", but the latter is only supported in Django starting with 4.1.
Now, a request with these query parameters
http://localhost:8000/api/books?name=harry&active=true
harry in their name or are active themselves and are published by active publishers.
Filtering by Nones
You can make the FilterSchema treat None as a valid value that should be filtered against.
This can be done on a field level with a ignore_none kwarg:
class BookFilterSchema(FilterSchema):
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
tag: Annotated[Optional[str], FilterLookup("tag", ignore_none=False)] = None
This way when no other value for "tag" is provided by the user, the filtering will always include a condition tag=None.
You can also specify this setting for all fields at the same time in model_config:
class BookFilterSchema(FilterSchema):
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
tag: Optional[str] = None
model_config = FilterConfigDict(ignore_none=False)
Custom expressions
Sometimes you might want to have complex filtering scenarios that cannot be handled by individual Field annotations.
For such cases you can implement your field filtering logic as a custom method. Simply define a method called filter_<fieldname> which takes a filter value and returns a Q expression:
class BookFilterSchema(FilterSchema):
tag: Optional[str] = None
popular: Optional[bool] = None
def filter_popular(self, value: bool) -> Q:
return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q()
Field() definition of the corresponding fields.
If that is not enough, you can implement your own custom filtering logic for the entire FilterSet class in a custom_expression method:
class BookFilterSchema(FilterSchema):
name: Optional[str] = None
popular: Optional[bool] = None
def custom_expression(self) -> Q:
q = Q()
if self.name:
q &= Q(name__icontains=self.name)
if self.popular:
q &= (
Q(view_count__gt=1000) |
Q(downloads__gt=100) |
Q(tag='popular')
)
return q
custom_expression method takes precedence over any other definitions described earlier, including filter_<fieldname> methods.