Skip to content

Commit 8f393df

Browse files
authored
Merge pull request #1514 from l1b3r/annotated-filtering
Annotation-based filtering in FilterSchema
2 parents 1850daa + b48a5ae commit 8f393df

File tree

5 files changed

+477
-200
lines changed

5 files changed

+477
-200
lines changed

docs/docs/guides/input/filtering.md

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -73,30 +73,52 @@ class BookFilterSchema(FilterSchema):
7373
```
7474
The `name` field will be converted into `Q(name=...)` expression.
7575

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

8081
class BookFilterSchema(FilterSchema):
81-
name: Optional[str] = FilterField(None, q='name__icontains')
82+
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
8283
```
83-
You can even specify multiple lookup keyword argument names as a list:
84-
```python hl_lines="2 3 4"
84+
85+
You can even specify multiple lookups as a list:
86+
```python hl_lines="3 4 5"
8587
class BookFilterSchema(FilterSchema):
86-
search: Optional[str] = FilterField(None, q=['name__icontains',
87-
'author__name__icontains',
88-
'publisher__name__icontains'])
88+
search: Annotated[Optional[str], FilterLookup(
89+
["name__icontains",
90+
"author__name__icontains",
91+
"publisher__name__icontains"]
92+
)]
8993
```
90-
And to make generic fields, you can make the field name implicit by skipping it:
91-
```python hl_lines="2"
92-
from typing import Annotated
9394

94-
IContainsField = Annotated[Optional[str], FilterField(None, q='__icontains')]
95+
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.
96+
97+
And to make generic fields, you can make the field name implicit by skipping it:
98+
```python hl_lines="1 4"
99+
IContainsField = Annotated[Optional[str], FilterLookup('__icontains')]
95100

96101
class BookFilterSchema(FilterSchema):
97-
name: IContainsField
102+
name: IContainsField = None
98103
```
99-
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.
104+
105+
??? note "Deprecated syntax"
106+
107+
In previous versions, database lookups were specified using `Field(q=...)` syntax:
108+
```python
109+
from ninja import FilterSchema, Field
110+
111+
class BookFilterSchema(FilterSchema):
112+
name: Optional[str] = Field(None, q="name__icontains")
113+
```
114+
115+
This approach is still supported, but it is considered **deprecated** and **not recommended** for new code because:
116+
117+
- Poor IDE support (IDEs don't recognize custom `Field` arguments)
118+
- Uses deprecated Pydantic features (`**extra`)
119+
- Less type-safe and harder to maintain
120+
121+
The new `FilterLookup` annotation provides better developer experience with full IDE support and type safety. Prefer using `FilterLookup` for new projects.
100122

101123

102124
## Combining expressions
@@ -108,7 +130,9 @@ By default,
108130
So, with the following `FilterSchema`...
109131
```python
110132
class BookFilterSchema(FilterSchema):
111-
search: Optional[str] = FilterField(None, q=['name__icontains', 'author__name__icontains'])
133+
search: Annotated[
134+
Optional[str],
135+
FilterLookup(["name__icontains", "author__name__icontains"])] = None
112136
popular: Optional[bool] = None
113137
```
114138
...and the following query parameters from the user
@@ -119,14 +143,19 @@ the `FilterSchema` instance will look for popular books that have `harry` in the
119143

120144

121145
You can customize this behavior using an `expression_connector` argument in field-level and class-level definition:
122-
```python hl_lines="3 7"
146+
```python hl_lines="12"
147+
from ninja import FilterConfigDict, FilterLookup, FilterSchema
148+
123149
class BookFilterSchema(FilterSchema):
124-
active: Optional[bool] = FilterField(None, q=['is_active', 'publisher__is_active'],
125-
expression_connector='AND')
126-
name: Optional[str] = FilterField(None, q='name__icontains')
150+
active: Annotated[
151+
Optional[bool],
152+
FilterLookup(
153+
["is_active", "publisher__is_active"],
154+
expression_connector="AND"
155+
)] = None
156+
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
127157

128-
class Meta:
129-
expression_connector = 'OR'
158+
model_config = FilterConfigDict(expression_connector="OR")
130159
```
131160

132161
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.
@@ -144,20 +173,19 @@ You can make the `FilterSchema` treat `None` as a valid value that should be fil
144173
This can be done on a field level with a `ignore_none` kwarg:
145174
```python hl_lines="3"
146175
class BookFilterSchema(FilterSchema):
147-
name: Optional[str] = FilterField(None, q='name__icontains')
148-
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
176+
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
177+
tag: Annotated[Optional[str], FilterLookup("tag", ignore_none=False)] = None
149178
```
150179

151180
This way when no other value for `"tag"` is provided by the user, the filtering will always include a condition `tag=None`.
152181

153-
You can also specify this settings for all fields at the same time in the Config:
154-
```python hl_lines="6"
182+
You can also specify this setting for all fields at the same time in `model_config`:
183+
```python hl_lines="5"
155184
class BookFilterSchema(FilterSchema):
156-
name: Optional[str] = FilterField(None, q='name__icontains')
157-
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
185+
name: Annotated[Optional[str], FilterLookup("name__icontains")] = None
186+
tag: Optional[str] = None
158187

159-
class Meta:
160-
ignore_none = False
188+
model_config = FilterConfigDict(ignore_none=False)
161189
```
162190

163191

@@ -173,7 +201,7 @@ class BookFilterSchema(FilterSchema):
173201
def filter_popular(self, value: bool) -> Q:
174202
return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q()
175203
```
176-
Such field methods take precedence over what is specified in the `FilterField()` definition of the corresponding fields.
204+
Such field methods take precedence over what is specified in the `Field()` definition of the corresponding fields.
177205

178206
If that is not enough, you can implement your own custom filtering logic for the entire `FilterSet` class in a `custom_expression` method:
179207

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ markdown_extensions:
100100
- abbr
101101
- codehilite
102102
- admonition
103+
- pymdownx.details
103104
- pymdownx.superfences
104105
plugins:
105106
- search

ninja/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import Field
77

88
from ninja.files import UploadedFile
9-
from ninja.filter_schema import FilterField, FilterSchema
9+
from ninja.filter_schema import FilterConfigDict, FilterLookup, FilterSchema
1010
from ninja.main import NinjaAPI
1111
from ninja.openapi.docs import Redoc, Swagger
1212
from ninja.orm import ModelSchema
@@ -54,7 +54,8 @@
5454
"Schema",
5555
"ModelSchema",
5656
"FilterSchema",
57-
"FilterField",
57+
"FilterLookup",
58+
"FilterConfigDict",
5859
"Swagger",
5960
"Redoc",
6061
"PatchDict",

0 commit comments

Comments
 (0)