Skip to content

Commit 52d510a

Browse files
authored
Remove Deprecated Config class support (#1528)
* Removed Deprecated Config class (on Schema) support * FilterField instead of Field (+ deprecation warning)
1 parent 2e450fa commit 52d510a

File tree

15 files changed

+254
-155
lines changed

15 files changed

+254
-155
lines changed

docs/docs/guides/input/filtering.md

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ parameters into database queries.
77

88
Start off with defining a subclass of `FilterSchema`:
99

10-
```python hl_lines="6 7 8"
11-
from ninja import FilterSchema, Field
10+
```python hl_lines="6 7 8 9"
11+
from ninja import FilterSchema
1212
from typing import Optional
13+
from datetime import datetime
1314

1415

1516
class BookFilterSchema(FilterSchema):
@@ -34,7 +35,7 @@ defined in `BookFilterSchema` into query parameters.
3435
You can use a shorthand one-liner `.filter()` to apply those filters to your queryset:
3536
```python hl_lines="4"
3637
@api.get("/books")
37-
def list_books(request, filters: BookFilterSchema = Query(...)):
38+
def list_books(request, filters: Query[BookFilterSchema]):
3839
books = Book.objects.all()
3940
books = filters.filter(books)
4041
return books
@@ -47,7 +48,7 @@ Alternatively to using the `.filter` method, you can get the prepared `Q`-expres
4748
That can be useful, when you have some additional queryset filtering on top of what you expose to the user through the API:
4849
```python hl_lines="5 8"
4950
@api.get("/books")
50-
def list_books(request, filters: BookFilterSchema = Query(...)):
51+
def list_books(request, filters: Query[BookFilterSchema]):
5152

5253
# Never serve books from inactive publishers and authors
5354
q = Q(author__is_active=True) | Q(publisher__is_active=True)
@@ -73,20 +74,24 @@ class BookFilterSchema(FilterSchema):
7374
The `name` field will be converted into `Q(name=...)` expression.
7475

7576
When your database lookups are more complicated than that, you can explicitly specify them in the field definition using a `"q"` kwarg:
76-
```python hl_lines="2"
77+
```python hl_lines="14"
78+
from ninja import FilterSchema
79+
7780
class BookFilterSchema(FilterSchema):
78-
name: Optional[str] = Field(None, q='name__icontains')
81+
name: Optional[str] = FilterField(None, q='name__icontains')
7982
```
8083
You can even specify multiple lookup keyword argument names as a list:
8184
```python hl_lines="2 3 4"
8285
class BookFilterSchema(FilterSchema):
83-
search: Optional[str] = Field(None, q=['name__icontains',
84-
'author__name__icontains',
85-
'publisher__name__icontains'])
86+
search: Optional[str] = FilterField(None, q=['name__icontains',
87+
'author__name__icontains',
88+
'publisher__name__icontains'])
8689
```
8790
And to make generic fields, you can make the field name implicit by skipping it:
8891
```python hl_lines="2"
89-
IContainsField = Annotated[Optional[str], Field(None, q='__icontains')]
92+
from typing import Annotated
93+
94+
IContainsField = Annotated[Optional[str], FilterField(None, q='__icontains')]
9095

9196
class BookFilterSchema(FilterSchema):
9297
name: IContainsField
@@ -103,7 +108,7 @@ By default,
103108
So, with the following `FilterSchema`...
104109
```python
105110
class BookFilterSchema(FilterSchema):
106-
search: Optional[str] = Field(None, q=['name__icontains', 'author__name__icontains'])
111+
search: Optional[str] = FilterField(None, q=['name__icontains', 'author__name__icontains'])
107112
popular: Optional[bool] = None
108113
```
109114
...and the following query parameters from the user
@@ -116,11 +121,11 @@ the `FilterSchema` instance will look for popular books that have `harry` in the
116121
You can customize this behavior using an `expression_connector` argument in field-level and class-level definition:
117122
```python hl_lines="3 7"
118123
class BookFilterSchema(FilterSchema):
119-
active: Optional[bool] = Field(None, q=['is_active', 'publisher__is_active'],
120-
expression_connector='AND')
121-
name: Optional[str] = Field(None, q='name__icontains')
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')
122127

123-
class Config:
128+
class Meta:
124129
expression_connector = 'OR'
125130
```
126131

@@ -139,19 +144,19 @@ You can make the `FilterSchema` treat `None` as a valid value that should be fil
139144
This can be done on a field level with a `ignore_none` kwarg:
140145
```python hl_lines="3"
141146
class BookFilterSchema(FilterSchema):
142-
name: Optional[str] = Field(None, q='name__icontains')
143-
tag: Optional[str] = Field(None, q='tag', ignore_none=False)
147+
name: Optional[str] = FilterField(None, q='name__icontains')
148+
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
144149
```
145150

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

148153
You can also specify this settings for all fields at the same time in the Config:
149154
```python hl_lines="6"
150155
class BookFilterSchema(FilterSchema):
151-
name: Optional[str] = Field(None, q='name__icontains')
152-
tag: Optional[str] = Field(None, q='tag', ignore_none=False)
156+
name: Optional[str] = FilterField(None, q='name__icontains')
157+
tag: Optional[str] = FilterField(None, q='tag', ignore_none=False)
153158

154-
class Config:
159+
class Meta:
155160
ignore_none = False
156161
```
157162

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

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

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Overriding Pydantic Config
22

33
There are many customizations available for a **Django Ninja `Schema`**, via the schema's
4-
[Pydantic `Config` class](https://pydantic-docs.helpmanual.io/usage/model_config/).
4+
[Pydantic `model_config`](https://docs.pydantic.dev/latest/api/config/).
55

66
!!! info
77
Under the hood **Django Ninja** uses [Pydantic Models](https://pydantic-docs.helpmanual.io/usage/models/)
@@ -11,10 +11,11 @@ There are many customizations available for a **Django Ninja `Schema`**, via the
1111

1212
## Example Camel Case mode
1313

14-
One interesting `Config` attribute is [`alias_generator`](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator).
14+
One interesting config attribute is [`alias_generator`](https://docs.pydantic.dev/latest/api/config/?query=alias_generator#pydantic.config.ConfigDict.alias_generator).
1515
Using Pydantic's example in **Django Ninja** can look something like:
1616

17-
```python hl_lines="12 13"
17+
```python hl_lines="10"
18+
from pydantic import ConfigDict
1819
from ninja import Schema
1920

2021

@@ -23,25 +24,25 @@ def to_camel(string: str) -> str:
2324
return words[0].lower() + ''.join(word.capitalize() for word in words[1:])
2425

2526
class CamelModelSchema(Schema):
27+
model_config = ConfigDict(alias_generator=to_camel)
2628
str_field_name: str
2729
float_field_name: float
28-
29-
class Config(Schema.Config):
30-
alias_generator = to_camel
3130
```
3231

33-
!!! note
34-
When overriding the schema's `Config`, it is necessary to inherit from the base `Config` class.
35-
3632
Keep in mind that when you want modify output for field names (like camel case) - you need to set as well `populate_by_name` and `by_alias`
3733

38-
```python hl_lines="6 9"
34+
```python hl_lines="6 14"
35+
from pydantic import ConfigDict
36+
3937
class UserSchema(ModelSchema):
40-
class Config:
41-
model = User
42-
model_fields = ["id", "email", "is_staff"]
38+
model_config = ConfigDict(
4339
alias_generator = to_camel
44-
populate_by_name = True # !!!!!! <--------
40+
populate_by_name = True, # !!!!!! <--------
41+
)
42+
class Meta:
43+
model = User
44+
fields = ["id", "email", "is_staff"]
45+
4546

4647

4748
@api.get("/users", response=list[UserSchema], by_alias=True) # !!!!!! <-------- by_alias
@@ -69,21 +70,4 @@ results:
6970

7071
```
7172

72-
## Custom Config from Django Model
7373

74-
When using [`create_schema`](django-pydantic-create-schema.md#create_schema), the resulting
75-
schema can be used to build another class with a custom config like:
76-
77-
```python hl_lines="10"
78-
from django.contrib.auth.models import User
79-
from ninja.orm import create_schema
80-
81-
82-
BaseUserSchema = create_schema(User)
83-
84-
85-
class UserSchema(BaseUserSchema):
86-
87-
class Config(BaseUserSchema.Config):
88-
...
89-
```

docs/docs/tutorial/other/crud.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,14 @@ To allow the user to make partial updates, use `payload.dict(exclude_unset=True)
176176

177177
**Enforcing strict field validation**
178178

179-
By default, any provided fields that don't exist in the schema will be silently ignored. To raise an error for these invalid fields, you can set `extra = "forbid"` in the schema's Config class. For example:
179+
By default, any provided fields that don't exist in the schema will be silently ignored. To raise an error for these invalid fields, you can set `extra = "forbid"` in the model_config. For example:
180180

181-
```python hl_lines="4 5"
181+
```python hl_lines="5"
182+
from pydantic import ConfigDict
182183
class EmployeeIn(Schema):
183184
# your fields here...
184185

185-
class Config:
186-
extra = "forbid"
186+
model_config = ConfigDict(extra="forbid")
187187
```
188188

189189
## Delete

ninja/__init__.py

Lines changed: 2 additions & 1 deletion
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 FilterSchema
9+
from ninja.filter_schema import FilterField, FilterSchema
1010
from ninja.main import NinjaAPI
1111
from ninja.openapi.docs import Redoc, Swagger
1212
from ninja.orm import ModelSchema
@@ -54,6 +54,7 @@
5454
"Schema",
5555
"ModelSchema",
5656
"FilterSchema",
57+
"FilterField",
5758
"Swagger",
5859
"Redoc",
5960
"PatchDict",

ninja/conf.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
from typing import Dict, Optional, Set
33

44
from django.conf import settings as django_settings
5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, ConfigDict, Field
66

77

88
class Settings(BaseModel):
9+
model_config = ConfigDict(from_attributes=True)
910
# Pagination
1011
PAGINATION_CLASS: str = Field(
1112
"ninja.pagination.LimitOffsetPagination", alias="NINJA_PAGINATION_CLASS"
@@ -29,9 +30,6 @@ class Settings(BaseModel):
2930
{"PUT", "PATCH", "DELETE"}, alias="NINJA_FIX_REQUEST_FILES_METHODS"
3031
)
3132

32-
class Config:
33-
from_attributes = True
34-
3533

3634
settings = Settings.model_validate(django_settings)
3735

ninja/filter_schema.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from typing import Any, TypeVar, cast
1+
import warnings
2+
from typing import Any, Dict, Optional, TypeVar, Union, cast
23

34
from django.core.exceptions import ImproperlyConfigured
45
from django.db.models import Q, QuerySet
6+
from pydantic import ConfigDict, Field
57
from pydantic.fields import FieldInfo
68
from typing_extensions import Literal
79

@@ -14,24 +16,36 @@
1416
# XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor
1517
ExpressionConnector = Literal["AND", "OR", "XOR"]
1618

17-
18-
# class FilterConfig(BaseConfig):
19-
# ignore_none: bool = DEFAULT_IGNORE_NONE
20-
# expression_connector: ExpressionConnector = cast(
21-
# ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
22-
# )
19+
T = TypeVar("T", bound=QuerySet)
2320

2421

25-
T = TypeVar("T", bound=QuerySet)
22+
def FilterField(
23+
default: Any = ...,
24+
*,
25+
q: Optional[Union[str, list]] = None,
26+
ignore_none: Optional[bool] = None,
27+
expression_connector: Optional[ExpressionConnector] = None,
28+
**kwargs: Any,
29+
) -> Any:
30+
"""Custom Field function for FilterSchema that properly handles filter-specific parameters."""
31+
json_schema_extra = kwargs.get("json_schema_extra", {})
32+
if isinstance(json_schema_extra, dict):
33+
if q is not None:
34+
json_schema_extra["q"] = q
35+
if ignore_none is not None:
36+
json_schema_extra["ignore_none"] = ignore_none
37+
if expression_connector is not None:
38+
json_schema_extra["expression_connector"] = expression_connector
39+
kwargs["json_schema_extra"] = json_schema_extra
40+
41+
return Field(default, **kwargs)
2642

2743

2844
class FilterSchema(Schema):
29-
# if TYPE_CHECKING:
30-
# __config__: ClassVar[Type[FilterConfig]] = FilterConfig # pragma: no cover
31-
32-
# Config = FilterConfig
45+
model_config = ConfigDict(from_attributes=True)
3346

34-
class Config(Schema.Config):
47+
class Meta:
48+
# TODO: filters_ignore_none, filters_expression_connector
3549
ignore_none: bool = DEFAULT_IGNORE_NONE
3650
expression_connector: ExpressionConnector = cast(
3751
ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
@@ -64,6 +78,18 @@ def _resolve_field_expression(
6478

6579
field_extra = field.json_schema_extra or {}
6680

81+
# Check if user is using pydantic Field with deprecated extra kwargs
82+
# This check is for backwards compatibility during transition period
83+
if hasattr(field, "extra") and field.extra:
84+
warnings.warn(
85+
f"Using pydantic Field with extra keyword arguments (q, ignore_none, expression_connector) "
86+
f"in field '{field_name}' is deprecated. Please use ninja.FilterField instead:\n"
87+
f" from ninja import FilterField\n"
88+
f" {field_name}: Optional[...] = FilterField(..., q='...', ignore_none=...)",
89+
DeprecationWarning,
90+
stacklevel=4,
91+
)
92+
6793
q_expression = field_extra.get("q", None) # type: ignore
6894
if not q_expression:
6995
return Q(**{field_name: field_value})
@@ -98,19 +124,27 @@ def _resolve_field_expression(
98124

99125
def _connect_fields(self) -> Q:
100126
q = Q()
101-
for field_name, field in self.model_fields.items():
127+
model_config = self._model_config()
128+
for field_name, field in self.__class__.model_fields.items():
102129
filter_value = getattr(self, field_name)
103130
field_extra = field.json_schema_extra or {}
104131
ignore_none = field_extra.get( # type: ignore
105132
"ignore_none",
106-
self.model_config["ignore_none"], # type: ignore
133+
model_config["ignore_none"],
107134
)
108135

109136
# Resolve q for a field even if we skip it due to None value
110137
# So that improperly configured fields are easier to detect
111138
field_q = self._resolve_field_expression(field_name, filter_value, field)
112139
if filter_value is None and ignore_none:
113140
continue
114-
q = q._combine(field_q, self.model_config["expression_connector"]) # type: ignore
141+
q = q._combine(field_q, model_config["expression_connector"]) # type: ignore
115142

116143
return q
144+
145+
@classmethod
146+
def _model_config(cls) -> Dict[str, Any]:
147+
return {
148+
"ignore_none": cls.Meta.ignore_none,
149+
"expression_connector": cls.Meta.expression_connector,
150+
}

0 commit comments

Comments
 (0)