Skip to content
90 changes: 59 additions & 31 deletions docs/docs/guides/input/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,30 +73,52 @@ class BookFilterSchema(FilterSchema):
```
The `name` field will be converted into `Q(name=...)` expression.

When your database lookups are more complicated than that, you can explicitly specify them in the field definition using a `"q"` kwarg:
```python hl_lines="14"
from ninja import FilterSchema
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:
```python hl_lines="5"
from ninja import FilterSchema, FilterLookup
from typing import Annotated

class BookFilterSchema(FilterSchema):
name: Optional[str] = FilterField(None, q='name__icontains')
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
```
You can even specify multiple lookup keyword argument names as a list:
```python hl_lines="2 3 4"

You can even specify multiple lookups as a list:
```python hl_lines="3 4 5"
class BookFilterSchema(FilterSchema):
search: Optional[str] = FilterField(None, q=['name__icontains',
'author__name__icontains',
'publisher__name__icontains'])
search: Annotated[Optional[str], FilterLookup(
["name__icontains",
"author__name__icontains",
"publisher__name__icontains"]
)]
```
And to make generic fields, you can make the field name implicit by skipping it:
```python hl_lines="2"
from typing import Annotated

IContainsField = Annotated[Optional[str], FilterField(None, q='__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:
```python hl_lines="1 4"
IContainsField = Annotated[Optional[str], FilterLookup('__icontains')]

class BookFilterSchema(FilterSchema):
name: IContainsField
name: IContainsField = None
```
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.

??? note "Deprecated syntax"

In previous versions, database lookups were specified using `Field(q=...)` syntax:
```python
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 `Field` arguments)
- 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
Expand All @@ -108,7 +130,9 @@ By default,
So, with the following `FilterSchema`...
```python
class BookFilterSchema(FilterSchema):
search: Optional[str] = FilterField(None, q=['name__icontains', 'author__name__icontains'])
search: Annotated[
Optional[str],
FilterLookup(["name__icontains", "author__name__icontains"])] = None
popular: Optional[bool] = None
```
...and the following query parameters from the user
Expand All @@ -119,14 +143,19 @@ the `FilterSchema` instance will look for popular books that have `harry` in the


You can customize this behavior using an `expression_connector` argument in field-level and class-level definition:
```python hl_lines="3 7"
```python hl_lines="12"
from ninja import FilterConfigDict, FilterLookup, FilterSchema

class BookFilterSchema(FilterSchema):
active: Optional[bool] = FilterField(None, q=['is_active', 'publisher__is_active'],
expression_connector='AND')
name: Optional[str] = FilterField(None, q='name__icontains')
active: Annotated[
Optional[bool],
FilterLookup(
["is_active", "publisher__is_active"],
expression_connector="AND"
)] = None
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None

class Meta:
expression_connector = 'OR'
model_config = FilterConfigDict(expression_connector="OR")
```

An expression connector can take the values of `"OR"`, `"AND"` and `"XOR"`, but the latter is only [supported](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor) in Django starting with 4.1.
Expand All @@ -144,20 +173,19 @@ You can make the `FilterSchema` treat `None` as a valid value that should be fil
This can be done on a field level with a `ignore_none` kwarg:
```python hl_lines="3"
class BookFilterSchema(FilterSchema):
name: Optional[str] = FilterField(None, q='name__icontains')
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
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 settings for all fields at the same time in the Config:
```python hl_lines="6"
You can also specify this setting for all fields at the same time in `model_config`:
```python hl_lines="5"
class BookFilterSchema(FilterSchema):
name: Optional[str] = FilterField(None, q='name__icontains')
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
tag: Optional[str] = None

class Meta:
ignore_none = False
model_config = FilterConfigDict(ignore_none=False)
```


Expand All @@ -173,7 +201,7 @@ class BookFilterSchema(FilterSchema):
def filter_popular(self, value: bool) -> Q:
return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q()
```
Such field methods take precedence over what is specified in the `FilterField()` definition of the corresponding fields.
Such field methods take precedence over what is specified in the `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:

Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ markdown_extensions:
- abbr
- codehilite
- admonition
- pymdownx.details
- pymdownx.superfences
plugins:
- search
Expand Down
5 changes: 3 additions & 2 deletions ninja/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic import Field

from ninja.files import UploadedFile
from ninja.filter_schema import FilterField, FilterSchema
from ninja.filter_schema import FilterConfigDict, FilterLookup, FilterSchema
from ninja.main import NinjaAPI
from ninja.openapi.docs import Redoc, Swagger
from ninja.orm import ModelSchema
Expand Down Expand Up @@ -54,7 +54,8 @@
"Schema",
"ModelSchema",
"FilterSchema",
"FilterField",
"FilterLookup",
"FilterConfigDict",
"Swagger",
"Redoc",
"PatchDict",
Expand Down
Loading