Skip to content

Commit 27fc4e8

Browse files
authored
Improve performance by removing DjangoGetter (#28)
* Get Managers and Files working * Reuse DjangoGetter for resolvers for now, AliasPath, formatting * Don't attach validators for ClassVars * Redo FileField type to use core schema * Add resolver and alias support, add compatibility mode * Clean up, docs * Fix docs * Fix 3.8 compatibility * Add compatibility for Pydantic 2.6 and older * Improve test coverage, bug fixes - Made message for edge case error in FileField more useful - Reverted wrap validator for Schema in compatibility mode to fix edge case incompatibility * Docs, clean up * Improve wording in docs and comments * Revert small change to DjangoGetter
1 parent 58132e1 commit 27fc4e8

File tree

12 files changed

+551
-62
lines changed

12 files changed

+551
-62
lines changed

docs/docs/differences.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,34 @@ A write-up will be completed for them if Shinobi releases 1.4.0 before Ninja.
1313

1414
## Features
1515

16+
### Schema Performance Improvements
17+
18+
Shinobi significantly improves the performance of Schema, especially for handling large data payloads.
19+
These improvements are not fully backwards compatible. Depending on the project,
20+
they may work without any changes to your code, but you may need to make changes for
21+
custom `model_validator` or `field_validator`s. There may also be issues with FileFields on Pydantic 2.6
22+
and older, so upgrading is recommended.
23+
24+
Shinobi has a compatibility mode to retain support for the old Schema behavior. This compatibility mode is
25+
enabled by default in 1.4.0 to help ease the migration, and the full performance improvements are currently **opt-in**.
26+
You can enable them by setting `NINJA_COMPATIBILITY` in your settings.py to False.
27+
28+
```python
29+
# settings.py
30+
NINJA_COMPATIBILITY = False # True by default
31+
```
32+
33+
The performance improvements can also be configured per Schema by setting `_compatibility` to `True` or `False`.
34+
35+
```python
36+
class MySchema(Schema):
37+
_compatibility = True
38+
...
39+
```
40+
41+
In 1.5.0, the default value for `NINJA_COMPATIBILITY` will be set to `True`, making the performance improvements
42+
**opt-out**. The compatibility behavior will be removed in 1.6.0.
43+
1644
### Improved Choices Enum support
1745

1846
[Choices and Enums](/django-shinobi/guides/response/django-pydantic/#choices-and-enums)
@@ -102,3 +130,11 @@ such as `toCamel`, Pydantic will not rewrite the alias for the foreign key field
102130

103131
Shinobi adds a `@property` field to the Schema so that the normal name can be accessed without
104132
using Pydantic's aliases, freeing it to be used for other manual or automatically generated aliases.
133+
134+
135+
### FileFields now properly validate when non-null
136+
137+
Previously, while FileField and ImageField could show a non-null type, they would always accept
138+
null. This is fixed with the schema improvements and requires Pydantic 2.7.
139+
Fixing this created a regression where Pydantic 2.6 and older
140+
always show the field as nullable, so upgrading is recommended.

ninja/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class Settings(BaseModel):
2929
{"PUT", "PATCH", "DELETE"}, alias="NINJA_FIX_REQUEST_FILES_METHODS"
3030
)
3131

32+
COMPATIBILITY: bool = Field(True, alias="NINJA_COMPATIBILITY")
33+
3234
class Config:
3335
from_attributes = True
3436

ninja/files.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
from typing import Any, Callable, Dict
1+
from typing import Callable, Dict, Union
22

33
from django.core.files.uploadedfile import UploadedFile as DjangoUploadedFile
4+
from django.db.models.fields.files import FieldFile, FileField
5+
from pydantic import GetCoreSchemaHandler
46
from pydantic_core import core_schema
7+
from typing_extensions import Annotated, Any
58

69
__all__ = ["UploadedFile"]
710

11+
from pydantic_core.core_schema import (
12+
BeforeValidatorFunctionSchema,
13+
ChainSchema,
14+
ValidationInfo,
15+
)
16+
817

918
class UploadedFile(DjangoUploadedFile):
1019
@classmethod
@@ -27,3 +36,67 @@ def __get_pydantic_core_schema__(
2736
cls, source: Any, handler: Callable[..., Any]
2837
) -> Any:
2938
return core_schema.with_info_plain_validator_function(cls._validate)
39+
40+
41+
def validate_file_field(value: Any, info: ValidationInfo) -> Any:
42+
if isinstance(value, FieldFile):
43+
if not value:
44+
return None
45+
return value.url
46+
return value
47+
48+
49+
class _FileFieldType:
50+
@classmethod
51+
def __get_pydantic_core_schema__(
52+
cls,
53+
_source_type: Any,
54+
_handler: GetCoreSchemaHandler,
55+
) -> core_schema.CoreSchema:
56+
from ninja.signature.details import is_optional
57+
58+
# Deprecate: Pydantic 2.6-2.7 do not support model_type_stack
59+
if hasattr(_handler._generate_schema, "model_type_stack"): # type: ignore[attr-defined]
60+
# Introspect the field using this type to determine if it's supposed to be optional
61+
# TODO: Make a test that creates a stack > 1
62+
if (
63+
len(_handler._generate_schema.model_type_stack._stack) != 1 # type: ignore[attr-defined]
64+
):
65+
raise Exception(
66+
"Unexpected issue creating a schema with a FileField. Please open an issue on Django Shinobi."
67+
) # pragma: no cover
68+
69+
file_field_schema: Union[BeforeValidatorFunctionSchema, ChainSchema] = (
70+
core_schema.chain_schema([
71+
core_schema.with_info_before_validator_function(
72+
validate_file_field, core_schema.str_schema()
73+
),
74+
core_schema.str_schema(),
75+
])
76+
)
77+
78+
field = _handler._generate_schema.model_type_stack._stack[ # type: ignore[attr-defined]
79+
0
80+
].model_fields[_handler.field_name]
81+
82+
optional = is_optional(field.annotation)
83+
else:
84+
# Older versions of Pydantic do not return this info
85+
# Set optional to True just in case (this was the old behavior anyway, we're just being more honest now)
86+
optional = True
87+
88+
if optional:
89+
field_type = core_schema.union_schema([
90+
core_schema.str_schema(),
91+
core_schema.none_schema(),
92+
])
93+
file_field_schema = core_schema.with_info_before_validator_function(
94+
validate_file_field, field_type
95+
)
96+
97+
return core_schema.json_or_python_schema(
98+
json_schema=file_field_schema, python_schema=file_field_schema
99+
)
100+
101+
102+
FileFieldType = Annotated[FileField, _FileFieldType]

ninja/orm/factory.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ def create_schema(
6262

6363
definitions = {}
6464
for fld in model_fields_list:
65-
field_name, python_type, field_info = get_schema_field(
65+
# Mypy is very confused about get_schema_field
66+
field_name, python_type, field_info = get_schema_field( # type: ignore[no-untyped-call, unused-ignore]
6667
fld,
6768
depth=depth,
6869
optional=optional_fields and (fld.name in optional_fields),

ninja/orm/fields.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from ninja.enum import NinjaChoicesList
2525
from ninja.errors import ConfigError
26+
from ninja.files import FileFieldType
2627
from ninja.openapi.schema import OpenAPISchema
2728
from ninja.types import DictStrAny
2829

@@ -67,12 +68,13 @@ def validate(cls, value: Any, _: Any) -> Any:
6768
"DateTimeField": datetime.datetime,
6869
"DecimalField": Decimal,
6970
"DurationField": datetime.timedelta,
70-
"FileField": str,
71+
"FileField": FileFieldType,
7172
"FilePathField": str,
7273
"FloatField": float,
7374
"GenericIPAddressField": IPvAnyAddress,
7475
"IPAddressField": IPvAnyAddress,
7576
"IntegerField": int,
77+
"ImageField": FileFieldType,
7678
"JSONField": AnyObject,
7779
"NullBooleanField": bool,
7880
"PositiveBigIntegerField": int,

0 commit comments

Comments
 (0)