django-ninja-aio-crud 2.18.0__py3-none-any.whl → 2.18.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.18.0
3
+ Version: 2.18.2
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -34,11 +34,9 @@ Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
34
34
  Provides-Extra: test
35
35
 
36
36
  <p align="center">
37
- <img src="https://raw.githubusercontent.com/caspel26/django-ninja-aio-crud/main/docs/images/logo.png" alt="django-ninja-aio-crud" width="120">
37
+ <img src="https://raw.githubusercontent.com/caspel26/django-ninja-aio-crud/main/docs/images/logo-full.png" alt="django-ninja-aio-crud">
38
38
  </p>
39
39
 
40
- <h1 align="center">django-ninja-aio-crud</h1>
41
-
42
40
  <p align="center">
43
41
  <strong>Async CRUD framework for Django Ninja</strong><br>
44
42
  Automatic schema generation · Filtering · Pagination · Auth · M2M management
@@ -51,11 +49,13 @@ Provides-Extra: test
51
49
  <a href="https://pypi.org/project/django-ninja-aio-crud/"><img src="https://img.shields.io/pypi/v/django-ninja-aio-crud?color=g&logo=pypi&logoColor=white" alt="PyPI - Version"></a>
52
50
  <a href="LICENSE"><img src="https://img.shields.io/pypi/l/django-ninja-aio-crud" alt="PyPI - License"></a>
53
51
  <a href="https://github.com/astral-sh/ruff"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
52
+ <a href="https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/performance.yml"><img src="https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/performance.yml/badge.svg" alt="Performance"></a>
54
53
  </p>
55
54
 
56
55
  <p align="center">
57
56
  <a href="https://django-ninja-aio.com">Documentation</a> ·
58
57
  <a href="https://pypi.org/project/django-ninja-aio-crud/">PyPI</a> ·
58
+ <a href="https://caspel26.github.io/django-ninja-aio-crud/">Performance Benchmarks</a> ·
59
59
  <a href="https://github.com/caspel26/ninja-aio-blog-example">Example Project</a> ·
60
60
  <a href="https://github.com/caspel26/django-ninja-aio-crud/issues">Issues</a>
61
61
  </p>
@@ -392,7 +392,13 @@ class ReadOnlyBookViewSet(APIViewSet):
392
392
 
393
393
  ---
394
394
 
395
- ## Performance Tips
395
+ ## Performance
396
+
397
+ View live benchmarks tracking schema generation, serialization, and CRUD throughput:
398
+
399
+ **[Live Performance Report](https://caspel26.github.io/django-ninja-aio-crud/)** — Interactive charts with historical trends
400
+
401
+ ### Performance Tips
396
402
 
397
403
  - Use `queryset_request` classmethod to `select_related` / `prefetch_related`
398
404
  - Index frequently filtered fields
@@ -1,10 +1,10 @@
1
- ninja_aio/__init__.py,sha256=CezrF0WOFMCLNAybIgP0uAtNTsUMEiOezULFLq3iluY,120
1
+ ninja_aio/__init__.py,sha256=GG9VIprfl_xsxe8mkdQQOZKg-SjYn-uPExDck4VTOSg,120
2
2
  ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
3
  ninja_aio/auth.py,sha256=f4yk45fLi36Qctu0A0zgHTFedb9yk3ewq5rOMpoPYIE,9035
4
4
  ninja_aio/exceptions.py,sha256=w8QWmVlg88iJvBrNODSDBHSsy8nNpwngaCGWRnkXoPo,3899
5
5
  ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
6
6
  ninja_aio/renders.py,sha256=CW0xDa05Xna-UvL0MZqZeDEgueEaUassV_nG7Rh1-cw,1824
7
- ninja_aio/types.py,sha256=E3yfXbNKkvLVcr8bvkHTSyIiCRZ4zumzJaXj1aiBi5U,735
7
+ ninja_aio/types.py,sha256=fSDTLLraQeZ9-bpoWSQcV5WN8vy2v4Te48D5dnnW25M,1441
8
8
  ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
9
9
  ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
10
10
  ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
@@ -14,17 +14,17 @@ ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
14
14
  ninja_aio/helpers/api.py,sha256=va_HvZVBFm1KxwQhH4u09U4F1JS5JrQuRpRmPTHJt7w,21326
15
15
  ninja_aio/helpers/query.py,sha256=Lqv4nrWYr543tC5K-SEcBottLID8cb83aDc26i2Wxj4,5053
16
16
  ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
17
- ninja_aio/models/serializers.py,sha256=Obw6HCLMU9iTvAtuoGFXB2gtRELtR3g1nhn23U9McrM,60376
18
- ninja_aio/models/utils.py,sha256=lAXtc3YY7_n4f0jIacX4DSXhUOzMy7y5MsBnInNxtfk,32874
17
+ ninja_aio/models/serializers.py,sha256=pRRa0ci8ObhmJoQkzreDxV0JA6elG0Tyj8mJutRDqpo,64021
18
+ ninja_aio/models/utils.py,sha256=uIjQz6-89H7Myires79_v9xSMU5A6JVBBmMzlDLZwp4,37261
19
19
  ninja_aio/schemas/__init__.py,sha256=dHILiYBKMb51lDcyQdiXRw_0nzqM7Lu81UX2hv7kEfo,837
20
20
  ninja_aio/schemas/api.py,sha256=dGUpJXR1iAf93QNR4kYj1uqIkTjiMfXultCotY6GtaQ,361
21
21
  ninja_aio/schemas/filters.py,sha256=VxzH2xSWok8cUSkyfeqtrGhRewtFVmNHQfHNvY8Aynw,2662
22
22
  ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
23
23
  ninja_aio/schemas/helpers.py,sha256=CpubwNXsZHtu8jddliyQybF1epwZ-GO60vHIuF5AR1Y,8967
24
24
  ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
25
- ninja_aio/views/api.py,sha256=AAqkj0xT8J3PmJvsbluZ33cfrmrXJHiV9ARe2BqnfQ8,22492
26
- ninja_aio/views/mixins.py,sha256=Zl6J8gbVagwT85bzDuKyJTk3iFxxFgX0YgYkjiUxZGg,17040
27
- django_ninja_aio_crud-2.18.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
28
- django_ninja_aio_crud-2.18.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
29
- django_ninja_aio_crud-2.18.0.dist-info/METADATA,sha256=2R1JT9MbqKfuAb05If2Ic1U-Cx6l_uTcXLPKHIyod5Q,12911
30
- django_ninja_aio_crud-2.18.0.dist-info/RECORD,,
25
+ ninja_aio/views/api.py,sha256=Sj4yIVLVQEVKxwFzVbT6YhiSCxXtcvdlTtWhJfccOus,26191
26
+ ninja_aio/views/mixins.py,sha256=rE4otyuenx6bfOLmnvMiSn10Kh7p0newU0-HarWDWS4,17779
27
+ django_ninja_aio_crud-2.18.2.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
28
+ django_ninja_aio_crud-2.18.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
29
+ django_ninja_aio_crud-2.18.2.dist-info/METADATA,sha256=uRj6qJ8HaV6vM5nhqSTs6FYwd1Bex_gfCosWXtlJDbw,13404
30
+ django_ninja_aio_crud-2.18.2.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.18.0"
3
+ __version__ = "2.18.2"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -11,6 +11,8 @@ from typing import (
11
11
  )
12
12
  import warnings
13
13
  import sys
14
+ import threading
15
+ from functools import lru_cache
14
16
 
15
17
  from django.conf import settings
16
18
  from ninja import Schema
@@ -103,6 +105,56 @@ class BaseSerializer:
103
105
  queryset_request = ModelQuerySetSchema()
104
106
  extras: list[ModelQuerySetExtraSchema] = []
105
107
 
108
+ # Thread-local storage for tracking circular reference resolution
109
+ _resolution_context = threading.local()
110
+
111
+ @classmethod
112
+ def _get_resolution_stack(cls) -> list[str]:
113
+ """
114
+ Get the current resolution stack for detecting circular references.
115
+
116
+ Returns
117
+ -------
118
+ list[str]
119
+ Stack of model names currently being resolved (thread-safe).
120
+ """
121
+ if not hasattr(cls._resolution_context, 'stack'):
122
+ cls._resolution_context.stack = []
123
+ return cls._resolution_context.stack
124
+
125
+ @classmethod
126
+ def _is_circular_reference(cls, model: models.Model) -> bool:
127
+ """
128
+ Check if resolving this model would create a circular reference.
129
+
130
+ Security: Prevents infinite recursion and stack overflow attacks.
131
+
132
+ Parameters
133
+ ----------
134
+ model : models.Model
135
+ The model to check
136
+
137
+ Returns
138
+ -------
139
+ bool
140
+ True if the model is already being resolved (circular reference detected)
141
+ """
142
+ model_key = f"{model._meta.app_label}.{model._meta.model_name}"
143
+ return model_key in cls._get_resolution_stack()
144
+
145
+ @classmethod
146
+ def _push_resolution(cls, model: models.Model) -> None:
147
+ """Add a model to the resolution stack."""
148
+ model_key = f"{model._meta.app_label}.{model._meta.model_name}"
149
+ cls._get_resolution_stack().append(model_key)
150
+
151
+ @classmethod
152
+ def _pop_resolution(cls) -> None:
153
+ """Remove the most recent model from the resolution stack."""
154
+ stack = cls._get_resolution_stack()
155
+ if stack:
156
+ stack.pop()
157
+
106
158
  @classmethod
107
159
  def _collect_validators(cls, source_class) -> dict:
108
160
  """
@@ -425,29 +477,51 @@ class BaseSerializer:
425
477
  -------
426
478
  Schema | Union[Schema, ...] | None
427
479
  Generated schema, Union of schemas, or None if cannot be resolved.
428
- """
429
- # Auto-resolve ModelSerializer with readable fields
430
- if isinstance(rel_model, ModelSerializerMeta):
431
- has_readable_fields = rel_model.get_fields(
432
- "read"
433
- ) or rel_model.get_custom_fields("read")
434
- return rel_model.generate_related_s() if has_readable_fields else None
435
-
436
- # Resolve from explicit serializer mapping
437
- rel_serializers = cls._get_relations_serializers() or {}
438
- serializer_ref = rel_serializers.get(field_name)
439
480
 
440
- if not serializer_ref:
481
+ Notes
482
+ -----
483
+ Includes circular reference detection to prevent infinite recursion.
484
+ """
485
+ # Security: Check for circular references to prevent infinite recursion
486
+ if cls._is_circular_reference(rel_model):
487
+ # Circular reference detected - return None to break the cycle
488
+ warnings.warn(
489
+ f"Circular reference detected for {rel_model._meta.label} "
490
+ f"in field '{field_name}' of {cls._get_model()._meta.label}. "
491
+ f"Skipping nested schema generation to prevent infinite recursion.",
492
+ UserWarning,
493
+ stacklevel=2
494
+ )
441
495
  return None
442
496
 
443
- resolved = cls._resolve_serializer_reference(serializer_ref)
497
+ # Track this model in the resolution stack
498
+ cls._push_resolution(rel_model)
499
+ try:
500
+ # Auto-resolve ModelSerializer with readable fields
501
+ if isinstance(rel_model, ModelSerializerMeta):
502
+ has_readable_fields = rel_model.get_fields(
503
+ "read"
504
+ ) or rel_model.get_custom_fields("read")
505
+ return rel_model.generate_related_s() if has_readable_fields else None
506
+
507
+ # Resolve from explicit serializer mapping
508
+ rel_serializers = cls._get_relations_serializers() or {}
509
+ serializer_ref = rel_serializers.get(field_name)
510
+
511
+ if not serializer_ref:
512
+ return None
513
+
514
+ resolved = cls._resolve_serializer_reference(serializer_ref)
444
515
 
445
- # Handle Union of serializers
446
- if get_origin(resolved) is Union:
447
- return cls._generate_union_schema(resolved)
516
+ # Handle Union of serializers
517
+ if get_origin(resolved) is Union:
518
+ return cls._generate_union_schema(resolved)
448
519
 
449
- # Handle single serializer
450
- return resolved.generate_related_s()
520
+ # Handle single serializer
521
+ return resolved.generate_related_s()
522
+ finally:
523
+ # Always pop from resolution stack when done
524
+ cls._pop_resolution()
451
525
 
452
526
  @classmethod
453
527
  def _is_special_field(
@@ -928,64 +1002,45 @@ class BaseSerializer:
928
1002
  )
929
1003
 
930
1004
  @classmethod
931
- def _generate_model_schema(
932
- cls,
933
- schema_type: type[SCHEMA_TYPES],
934
- depth: int = None,
935
- ) -> Schema:
936
- """
937
- Core schema factory bridging serializer configuration to ``ninja.orm.create_schema``.
938
-
939
- Dispatches to the appropriate field/custom/exclude gathering logic based
940
- on the requested schema type and delegates to Django Ninja's
941
- ``create_schema`` for the actual Pydantic model construction.
942
-
943
- Parameters
944
- ----------
945
- schema_type : SCHEMA_TYPES
946
- One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
947
- depth : int, optional
948
- Nesting depth for related model schemas (used by ``Out`` and ``Detail``).
949
-
950
- Returns
951
- -------
952
- Schema | None
953
- Generated Pydantic schema, or ``None`` if no fields are configured.
954
- """
955
- model = cls._get_model()
956
- validators = cls._get_validators(schema_type)
957
-
958
- # Handle special schema types with custom logic
959
- if schema_type == "Out" or schema_type == "Detail":
960
- fields, reverse_rels, excludes, customs, optionals = (
961
- cls.get_schema_out_data(schema_type)
962
- )
963
- if not any([fields, reverse_rels, excludes, customs]):
964
- return None
965
- schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
966
- schema = create_schema(
967
- model=model,
968
- name=f"{model._meta.model_name}{schema_name}",
969
- depth=depth,
970
- fields=fields,
971
- custom_fields=reverse_rels + customs + optionals,
972
- exclude=excludes,
973
- )
974
- return cls._apply_validators(schema, validators)
1005
+ def _create_out_or_detail_schema(
1006
+ cls, schema_type: type[SCHEMA_TYPES], model, validators, depth: int = None
1007
+ ) -> Schema | None:
1008
+ """Create schema for Out or Detail types."""
1009
+ fields, reverse_rels, excludes, customs, optionals = (
1010
+ cls.get_schema_out_data(schema_type)
1011
+ )
1012
+ if not any([fields, reverse_rels, excludes, customs]):
1013
+ return None
1014
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
1015
+ schema = create_schema(
1016
+ model=model,
1017
+ name=f"{model._meta.model_name}{schema_name}",
1018
+ depth=depth,
1019
+ fields=fields,
1020
+ custom_fields=reverse_rels + customs + optionals,
1021
+ exclude=excludes,
1022
+ )
1023
+ return cls._apply_validators(schema, validators)
975
1024
 
976
- if schema_type == "Related":
977
- fields, customs = cls.get_related_schema_data()
978
- if not fields and not customs:
979
- return None
980
- schema = create_schema(
981
- model=model,
982
- name=f"{model._meta.model_name}SchemaRelated",
983
- fields=fields,
984
- custom_fields=customs,
985
- )
986
- return cls._apply_validators(schema, validators)
1025
+ @classmethod
1026
+ def _create_related_schema(cls, model, validators) -> Schema | None:
1027
+ """Create schema for Related type."""
1028
+ fields, customs = cls.get_related_schema_data()
1029
+ if not fields and not customs:
1030
+ return None
1031
+ schema = create_schema(
1032
+ model=model,
1033
+ name=f"{model._meta.model_name}SchemaRelated",
1034
+ fields=fields,
1035
+ custom_fields=customs,
1036
+ )
1037
+ return cls._apply_validators(schema, validators)
987
1038
 
988
- # Handle standard In/Patch schema types
1039
+ @classmethod
1040
+ def _create_in_or_patch_schema(
1041
+ cls, schema_type: type[SCHEMA_TYPES], model, validators
1042
+ ) -> Schema | None:
1043
+ """Create schema for In or Patch types."""
989
1044
  s_type = "create" if schema_type == "In" else "update"
990
1045
  fields = cls.get_fields(s_type)
991
1046
  optionals = cls.get_optional_fields(s_type)
@@ -1020,6 +1075,42 @@ class BaseSerializer:
1020
1075
  )
1021
1076
  return cls._apply_validators(schema, validators)
1022
1077
 
1078
+ @classmethod
1079
+ def _generate_model_schema(
1080
+ cls,
1081
+ schema_type: type[SCHEMA_TYPES],
1082
+ depth: int = None,
1083
+ ) -> Schema:
1084
+ """
1085
+ Core schema factory bridging serializer configuration to ``ninja.orm.create_schema``.
1086
+
1087
+ Dispatches to the appropriate field/custom/exclude gathering logic based
1088
+ on the requested schema type and delegates to Django Ninja's
1089
+ ``create_schema`` for the actual Pydantic model construction.
1090
+
1091
+ Parameters
1092
+ ----------
1093
+ schema_type : SCHEMA_TYPES
1094
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1095
+ depth : int, optional
1096
+ Nesting depth for related model schemas (used by ``Out`` and ``Detail``).
1097
+
1098
+ Returns
1099
+ -------
1100
+ Schema | None
1101
+ Generated Pydantic schema, or ``None`` if no fields are configured.
1102
+ """
1103
+ model = cls._get_model()
1104
+ validators = cls._get_validators(schema_type)
1105
+
1106
+ if schema_type in ("Out", "Detail"):
1107
+ return cls._create_out_or_detail_schema(schema_type, model, validators, depth)
1108
+
1109
+ if schema_type == "Related":
1110
+ return cls._create_related_schema(model, validators)
1111
+
1112
+ return cls._create_in_or_patch_schema(schema_type, model, validators)
1113
+
1023
1114
  @classmethod
1024
1115
  def get_related_schema_data(cls):
1025
1116
  """
@@ -1055,10 +1146,13 @@ class BaseSerializer:
1055
1146
  return non_relation_fields, customs
1056
1147
 
1057
1148
  @classmethod
1149
+ @lru_cache(maxsize=128)
1058
1150
  def generate_read_s(cls, depth: int = 1) -> Schema:
1059
1151
  """
1060
1152
  Generate the read (Out) schema for list responses.
1061
1153
 
1154
+ Performance: Results are cached per (class, depth) combination.
1155
+
1062
1156
  Parameters
1063
1157
  ----------
1064
1158
  depth : int, optional
@@ -1072,6 +1166,7 @@ class BaseSerializer:
1072
1166
  return cls._generate_model_schema("Out", depth)
1073
1167
 
1074
1168
  @classmethod
1169
+ @lru_cache(maxsize=128)
1075
1170
  def generate_detail_s(cls, depth: int = 1) -> Schema:
1076
1171
  """
1077
1172
  Generate the detail (single-object) read schema.
@@ -1079,6 +1174,8 @@ class BaseSerializer:
1079
1174
  Falls back to the standard read schema if no detail-specific
1080
1175
  configuration is defined.
1081
1176
 
1177
+ Performance: Results are cached per (class, depth) combination.
1178
+
1082
1179
  Parameters
1083
1180
  ----------
1084
1181
  depth : int, optional
@@ -1092,10 +1189,13 @@ class BaseSerializer:
1092
1189
  return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
1093
1190
 
1094
1191
  @classmethod
1192
+ @lru_cache(maxsize=128)
1095
1193
  def generate_create_s(cls) -> Schema:
1096
1194
  """
1097
1195
  Generate the create (In) schema for input validation.
1098
1196
 
1197
+ Performance: Results are cached per class.
1198
+
1099
1199
  Returns
1100
1200
  -------
1101
1201
  Schema | None
@@ -1104,10 +1204,13 @@ class BaseSerializer:
1104
1204
  return cls._generate_model_schema("In")
1105
1205
 
1106
1206
  @classmethod
1207
+ @lru_cache(maxsize=128)
1107
1208
  def generate_update_s(cls) -> Schema:
1108
1209
  """
1109
1210
  Generate the update (Patch) schema for partial updates.
1110
1211
 
1212
+ Performance: Results are cached per class.
1213
+
1111
1214
  Returns
1112
1215
  -------
1113
1216
  Schema | None
@@ -1116,6 +1219,7 @@ class BaseSerializer:
1116
1219
  return cls._generate_model_schema("Patch")
1117
1220
 
1118
1221
  @classmethod
1222
+ @lru_cache(maxsize=128)
1119
1223
  def generate_related_s(cls) -> Schema:
1120
1224
  """
1121
1225
  Generate the related (nested) schema for embedding in parent schemas.
@@ -1123,6 +1227,8 @@ class BaseSerializer:
1123
1227
  Includes only non-relational model fields and custom fields, preventing
1124
1228
  infinite nesting of related objects.
1125
1229
 
1230
+ Performance: Results are cached per class.
1231
+
1126
1232
  Returns
1127
1233
  -------
1128
1234
  Schema | None
ninja_aio/models/utils.py CHANGED
@@ -94,6 +94,9 @@ class ModelUtil:
94
94
  - Stateless wrapper; safe per-request instantiation.
95
95
  """
96
96
 
97
+ # Performance: Class-level cache for relation discovery (model structure is static)
98
+ _relation_cache: dict[tuple[type, str, str], list[str]] = {}
99
+
97
100
  def __init__(
98
101
  self, model: type["ModelSerializer"] | models.Model, serializer_class=None
99
102
  ):
@@ -192,6 +195,7 @@ class ModelUtil:
192
195
  """
193
196
  return [field.name for field in self.model._meta.get_fields()]
194
197
 
198
+
195
199
  @property
196
200
  def model_name(self) -> str:
197
201
  """
@@ -518,6 +522,9 @@ class ModelUtil:
518
522
  """
519
523
  Discover reverse relation names for safe prefetching.
520
524
 
525
+ Performance: Results are cached per (model, serializer_class, is_for) tuple
526
+ since model structure is static.
527
+
521
528
  Parameters
522
529
  ----------
523
530
  is_for : Literal["read", "detail"]
@@ -528,9 +535,17 @@ class ModelUtil:
528
535
  list[str]
529
536
  Relation attribute names.
530
537
  """
538
+ # Check cache first (performance optimization)
539
+ cache_key = (self.model, str(self.serializer_class), is_for)
540
+ if cache_key in self._relation_cache:
541
+ return self._relation_cache[cache_key].copy()
542
+
531
543
  reverse_rels = self._get_read_optimizations(is_for).prefetch_related.copy()
532
544
  if reverse_rels:
545
+ # Cache and return
546
+ self._relation_cache[cache_key] = reverse_rels
533
547
  return reverse_rels
548
+
534
549
  serializable_fields = self._get_serializable_field_names(is_for)
535
550
  for f in serializable_fields:
536
551
  field_obj = getattr(self.model, f)
@@ -542,6 +557,9 @@ class ModelUtil:
542
557
  continue
543
558
  if isinstance(field_obj, ReverseOneToOneDescriptor):
544
559
  reverse_rels.append(field_obj.related.name)
560
+
561
+ # Cache the result
562
+ self._relation_cache[cache_key] = reverse_rels
545
563
  return reverse_rels
546
564
 
547
565
  def get_select_relateds(
@@ -550,6 +568,9 @@ class ModelUtil:
550
568
  """
551
569
  Discover forward relation names for safe select_related.
552
570
 
571
+ Performance: Results are cached per (model, serializer_class, is_for) tuple
572
+ since model structure is static.
573
+
553
574
  Parameters
554
575
  ----------
555
576
  is_for : Literal["read", "detail"]
@@ -560,9 +581,17 @@ class ModelUtil:
560
581
  list[str]
561
582
  Relation attribute names.
562
583
  """
584
+ # Check cache first (performance optimization)
585
+ cache_key = (self.model, str(self.serializer_class) + "_select", is_for)
586
+ if cache_key in self._relation_cache:
587
+ return self._relation_cache[cache_key].copy()
588
+
563
589
  select_rels = self._get_read_optimizations(is_for).select_related.copy()
564
590
  if select_rels:
591
+ # Cache and return
592
+ self._relation_cache[cache_key] = select_rels
565
593
  return select_rels
594
+
566
595
  serializable_fields = self._get_serializable_field_names(is_for)
567
596
  for f in serializable_fields:
568
597
  field_obj = getattr(self.model, f)
@@ -571,12 +600,17 @@ class ModelUtil:
571
600
  continue
572
601
  if isinstance(field_obj, ForwardManyToOneDescriptor):
573
602
  select_rels.append(f)
603
+
604
+ # Cache the result
605
+ self._relation_cache[cache_key] = select_rels
574
606
  return select_rels
575
607
 
576
- async def _get_field(self, k: str):
608
+ async def _get_field(self, k: str) -> models.Field:
609
+ """Get Django field object for a given field name."""
577
610
  return (await agetattr(self.model, k)).field
578
611
 
579
- def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field):
612
+ def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field) -> None:
613
+ """Decode base64-encoded binary field values in place."""
580
614
  if not isinstance(field_obj, models.BinaryField):
581
615
  return
582
616
  try:
@@ -591,7 +625,8 @@ class ModelUtil:
591
625
  k: str,
592
626
  v: Any,
593
627
  field_obj: models.Field,
594
- ):
628
+ ) -> None:
629
+ """Resolve foreign key ID to model instance in place."""
595
630
  if not isinstance(field_obj, models.ForeignKey):
596
631
  return
597
632
  rel_util = ModelUtil(field_obj.related_model)
@@ -600,10 +635,11 @@ class ModelUtil:
600
635
 
601
636
  async def _bump_object_from_schema(
602
637
  self, obj: type["ModelSerializer"] | models.Model, schema: Schema
603
- ):
638
+ ) -> dict:
639
+ """Convert model instance to dict using Pydantic schema."""
604
640
  return (await sync_to_async(schema.from_orm)(obj)).model_dump()
605
641
 
606
- def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema):
642
+ def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema) -> None:
607
643
  """Validate required parameters for read operations."""
608
644
  if request is None:
609
645
  raise SerializeError(
@@ -667,12 +703,115 @@ class ModelUtil:
667
703
  obj = await self.get_object(request, query_data=query_data, is_for=is_for)
668
704
  return await self._bump_object_from_schema(obj, obj_schema)
669
705
 
706
+
707
+ def _collect_custom_and_optional_fields(
708
+ self, payload: dict, is_serializer: bool, serializer
709
+ ) -> tuple[dict[str, Any], list[str]]:
710
+ """
711
+ Collect custom and optional fields from payload.
712
+
713
+ Parameters
714
+ ----------
715
+ payload : dict
716
+ Input payload.
717
+ is_serializer : bool
718
+ Whether using a ModelSerializer.
719
+ serializer : ModelSerializer | Serializer
720
+ Serializer instance if applicable.
721
+
722
+ Returns
723
+ -------
724
+ tuple[dict[str, Any], list[str]]
725
+ (custom_fields_dict, optional_field_names)
726
+ """
727
+ customs: dict[str, Any] = {}
728
+ optionals: list[str] = []
729
+
730
+ if not is_serializer:
731
+ return customs, optionals
732
+
733
+ customs = {
734
+ k: v
735
+ for k, v in payload.items()
736
+ if serializer.is_custom(k) and k not in self.model_fields
737
+ }
738
+ optionals = [
739
+ k for k, v in payload.items() if serializer.is_optional(k) and v is None
740
+ ]
741
+
742
+ return customs, optionals
743
+
744
+ def _determine_skip_keys(
745
+ self, payload: dict, is_serializer: bool, serializer
746
+ ) -> set[str]:
747
+ """
748
+ Determine which keys to skip during model field processing.
749
+
750
+ Parameters
751
+ ----------
752
+ payload : dict
753
+ Input payload.
754
+ is_serializer : bool
755
+ Whether using a ModelSerializer.
756
+ serializer : ModelSerializer | Serializer
757
+ Serializer instance if applicable.
758
+
759
+ Returns
760
+ -------
761
+ set[str]
762
+ Set of keys to skip.
763
+ """
764
+ if not is_serializer:
765
+ return set()
766
+
767
+ skip_keys = {
768
+ k
769
+ for k, v in payload.items()
770
+ if (serializer.is_custom(k) and k not in self.model_fields)
771
+ or (serializer.is_optional(k) and v is None)
772
+ }
773
+ return skip_keys
774
+
775
+ async def _process_payload_fields(
776
+ self, request: HttpRequest, payload: dict, fields_to_process: list[tuple[str, Any]]
777
+ ) -> None:
778
+ """
779
+ Process payload fields: decode binary and resolve foreign keys.
780
+
781
+ Parameters
782
+ ----------
783
+ request : HttpRequest
784
+ HTTP request object.
785
+ payload : dict
786
+ Payload dict to modify in place.
787
+ fields_to_process : list[tuple[str, Any]]
788
+ List of (field_name, field_value) tuples to process.
789
+ """
790
+ if not fields_to_process:
791
+ return
792
+
793
+ # Fetch all field objects in parallel
794
+ field_tasks = [self._get_field(k) for k, _ in fields_to_process]
795
+ field_objs = await asyncio.gather(*field_tasks)
796
+
797
+ # Decode binary fields (synchronous, must be sequential)
798
+ for (k, v), field_obj in zip(fields_to_process, field_objs):
799
+ self._decode_binary(payload, k, v, field_obj)
800
+
801
+ # Resolve all FK fields in parallel
802
+ fk_tasks = [
803
+ self._resolve_fk(request, payload, k, v, field_obj)
804
+ for (k, v), field_obj in zip(fields_to_process, field_objs)
805
+ ]
806
+ await asyncio.gather(*fk_tasks)
807
+
670
808
  async def parse_input_data(self, request: HttpRequest, data: Schema):
671
809
  """
672
810
  Transform inbound schema data to a model-ready payload.
673
811
 
674
812
  Steps
675
813
  -----
814
+ - Validate fields against schema (including aliases and custom fields).
676
815
  - Strip custom fields (retain separately).
677
816
  - Drop optional fields with None (ModelSerializer only).
678
817
  - Decode BinaryField base64 values.
@@ -692,7 +831,7 @@ class ModelUtil:
692
831
  Raises
693
832
  ------
694
833
  SerializeError
695
- On base64 decoding failure.
834
+ On base64 decoding failure or invalid field names.
696
835
  """
697
836
  payload = data.model_dump(mode="json")
698
837
 
@@ -701,36 +840,20 @@ class ModelUtil:
701
840
  )
702
841
  serializer = self.serializer if self.with_serializer else self.model
703
842
 
704
- # Collect custom and optional fields (only if ModelSerializerMeta)
705
- customs: dict[str, Any] = {}
706
- optionals: list[str] = []
707
- if is_serializer:
708
- customs = {
709
- k: v
710
- for k, v in payload.items()
711
- if serializer.is_custom(k) and k not in self.model_fields
712
- }
713
- optionals = [
714
- k for k, v in payload.items() if serializer.is_optional(k) and v is None
715
- ]
843
+ # Note: Field validation is handled by Pydantic during schema deserialization
844
+ # No additional validation needed here since data is already a validated Schema instance
716
845
 
717
- skip_keys = set()
718
- if is_serializer:
719
- # Keys to skip during model field processing
720
- skip_keys = {
721
- k
722
- for k, v in payload.items()
723
- if (serializer.is_custom(k) and k not in self.model_fields)
724
- or (serializer.is_optional(k) and v is None)
725
- }
726
-
727
- # Process payload fields
728
- for k, v in payload.items():
729
- if k in skip_keys:
730
- continue
731
- field_obj = await self._get_field(k)
732
- self._decode_binary(payload, k, v, field_obj)
733
- await self._resolve_fk(request, payload, k, v, field_obj)
846
+ # Collect custom and optional fields
847
+ customs, optionals = self._collect_custom_and_optional_fields(
848
+ payload, is_serializer, serializer
849
+ )
850
+
851
+ # Determine which keys to skip during model field processing
852
+ skip_keys = self._determine_skip_keys(payload, is_serializer, serializer)
853
+
854
+ # Process payload fields - gather field objects in parallel for better performance
855
+ fields_to_process = [(k, v) for k, v in payload.items() if k not in skip_keys]
856
+ await self._process_payload_fields(request, payload, fields_to_process)
734
857
 
735
858
  # Preserve original exclusion semantics (customs if present else optionals)
736
859
  exclude_keys = customs.keys() or optionals
ninja_aio/types.py CHANGED
@@ -10,6 +10,44 @@ SCHEMA_TYPES = Literal["In", "Out", "Detail", "Patch", "Related"]
10
10
  VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
11
11
  JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey | jwk.OctKey
12
12
 
13
+ # Django ORM field lookup suffixes for QuerySet filtering
14
+ # See: https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups
15
+ DjangoLookup = Literal[
16
+ "exact",
17
+ "iexact",
18
+ "contains",
19
+ "icontains",
20
+ "in",
21
+ "gt",
22
+ "gte",
23
+ "lt",
24
+ "lte",
25
+ "startswith",
26
+ "istartswith",
27
+ "endswith",
28
+ "iendswith",
29
+ "range",
30
+ "date",
31
+ "year",
32
+ "iso_year",
33
+ "month",
34
+ "day",
35
+ "week",
36
+ "week_day",
37
+ "iso_week_day",
38
+ "quarter",
39
+ "time",
40
+ "hour",
41
+ "minute",
42
+ "second",
43
+ "isnull",
44
+ "regex",
45
+ "iregex",
46
+ ]
47
+
48
+ # Set of valid Django lookup suffixes for runtime validation
49
+ VALID_DJANGO_LOOKUPS: set[str] = set(DjangoLookup.__args__)
50
+
13
51
 
14
52
  class SerializerMeta(type):
15
53
  """Metaclass for serializers - extend with custom behavior as needed."""
ninja_aio/views/api.py CHANGED
@@ -6,6 +6,7 @@ from ninja.pagination import paginate, AsyncPaginationBase, PageNumberPagination
6
6
  from django.http import HttpRequest
7
7
  from django.db.models import Model, QuerySet
8
8
  from django.conf import settings
9
+ from django.core.exceptions import FieldDoesNotExist
9
10
  from pydantic import create_model
10
11
 
11
12
  from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
@@ -16,7 +17,7 @@ from ninja_aio.schemas import (
16
17
  M2MRelationSchema,
17
18
  )
18
19
  from ninja_aio.helpers.api import ManyToManyAPI
19
- from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
20
+ from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES, VALID_DJANGO_LOOKUPS
20
21
  from ninja_aio.decorators import unique_view, decorate_view, aatomic
21
22
  from ninja_aio.models import serializers
22
23
 
@@ -31,7 +32,7 @@ class API:
31
32
  auth: list | None = NOT_SET
32
33
  router: Router = None
33
34
 
34
- def views(self):
35
+ def views(self) -> None:
35
36
  """
36
37
  Override this method to add your custom views. For example:
37
38
  @self.router.get(some_path, response=some_schema)
@@ -65,13 +66,15 @@ class API:
65
66
  """
66
67
  pass
67
68
 
68
- def _add_views(self):
69
+ def _add_views(self) -> Router:
70
+ """Register views decorated with @api_register."""
69
71
  for name in dir(self.__class__):
70
72
  method = getattr(self.__class__, name)
71
73
  if hasattr(method, "_api_register"):
72
74
  method._api_register(self)
75
+ return self.router
73
76
 
74
- def add_views_to_route(self):
77
+ def add_views_to_route(self) -> Router:
75
78
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
76
79
 
77
80
 
@@ -322,6 +325,98 @@ class APIViewSet(API):
322
325
  filter
323
326
  )
324
327
 
328
+ def _is_lookup_suffix(self, part: str) -> bool:
329
+ """
330
+ Check if a part is a valid Django lookup suffix.
331
+
332
+ Args:
333
+ part: The part to check
334
+
335
+ Returns:
336
+ bool: True if the part is a valid lookup suffix
337
+ """
338
+ return part in VALID_DJANGO_LOOKUPS
339
+
340
+ def _get_related_model(self, field):
341
+ """
342
+ Extract the related model from a field if it exists.
343
+
344
+ Args:
345
+ field: The Django field object
346
+
347
+ Returns:
348
+ Model class or None
349
+ """
350
+ if hasattr(field, 'related_model') and field.related_model:
351
+ return field.related_model
352
+ if hasattr(field, 'remote_field') and field.remote_field and hasattr(field.remote_field, 'model'):
353
+ return field.remote_field.model
354
+ return None
355
+
356
+ def _validate_non_relation_field(self, parts: list[str], i: int) -> bool:
357
+ """
358
+ Validate a non-relation field that appears before the end of the path.
359
+
360
+ Args:
361
+ parts: List of field path parts
362
+ i: Current index in parts
363
+
364
+ Returns:
365
+ bool: True if valid, False otherwise
366
+ """
367
+ if i >= len(parts) - 1:
368
+ return True
369
+ next_part = parts[i + 1]
370
+ return self._is_lookup_suffix(next_part)
371
+
372
+ def _validate_filter_field(self, field_path: str) -> bool:
373
+ """
374
+ Validate that a filter field path corresponds to valid model fields.
375
+
376
+ Security: Prevents field injection attacks by ensuring only valid model
377
+ fields can be used in filters.
378
+
379
+ Args:
380
+ field_path: The field path to validate (e.g., "name", "author__name")
381
+
382
+ Returns:
383
+ bool: True if the field path is valid, False otherwise
384
+
385
+ Examples:
386
+ "name" -> validates against direct model field
387
+ "author__name" -> validates author is a relation, then name on related model
388
+ "created_at__gte" -> validates created_at field, lookup suffix is allowed
389
+ """
390
+ if not field_path:
391
+ return False
392
+
393
+ parts = field_path.split('__')
394
+ current_model = self.model
395
+
396
+ # Iterate through the path, validating each part
397
+ for i, part in enumerate(parts):
398
+ # Check if this is the last part and might be a lookup suffix
399
+ is_last_part = i == len(parts) - 1
400
+ if is_last_part and self._is_lookup_suffix(part):
401
+ return True
402
+
403
+ try:
404
+ field = current_model._meta.get_field(part)
405
+ except (FieldDoesNotExist, AttributeError):
406
+ # Field doesn't exist on this model
407
+ return False
408
+
409
+ # If this is a relation field and not the last part, traverse to related model
410
+ related_model = self._get_related_model(field)
411
+ if related_model and not is_last_part:
412
+ current_model = related_model
413
+ elif not is_last_part:
414
+ # Non-relation field in the middle - must be followed by a lookup suffix
415
+ if not self._validate_non_relation_field(parts, i):
416
+ return False
417
+
418
+ return True
419
+
325
420
  def _auth_view(self, view_type: str):
326
421
  """
327
422
  Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
@@ -329,16 +424,20 @@ class APIViewSet(API):
329
424
  auth = getattr(self, f"{view_type}_auth", None)
330
425
  return auth if auth is not NOT_SET else self.auth
331
426
 
332
- def get_view_auth(self):
427
+ def get_view_auth(self) -> list | None:
428
+ """Get authentication configuration for GET endpoints."""
333
429
  return self._auth_view("get")
334
430
 
335
- def post_view_auth(self):
431
+ def post_view_auth(self) -> list | None:
432
+ """Get authentication configuration for POST endpoints."""
336
433
  return self._auth_view("post")
337
434
 
338
- def patch_view_auth(self):
435
+ def patch_view_auth(self) -> list | None:
436
+ """Get authentication configuration for PATCH endpoints."""
339
437
  return self._auth_view("patch")
340
438
 
341
- def delete_view_auth(self):
439
+ def delete_view_auth(self) -> list | None:
440
+ """Get authentication configuration for DELETE endpoints."""
342
441
  return self._auth_view("delete")
343
442
 
344
443
  def _generate_schema(self, fields: dict, name: str) -> Schema:
@@ -347,7 +446,7 @@ class APIViewSet(API):
347
446
  """
348
447
  return create_model(f"{self.model_util.model_name}{name}", **fields)
349
448
 
350
- def _generate_path_schema(self):
449
+ def _generate_path_schema(self) -> Schema:
351
450
  """
352
451
  Schema containing only the primary key field for path resolution.
353
452
  """
ninja_aio/views/mixins.py CHANGED
@@ -56,7 +56,9 @@ class IcontainsFilterViewSetMixin(APIViewSet):
56
56
  **{
57
57
  f"{key}__icontains": value
58
58
  for key, value in filters.items()
59
- if isinstance(value, str) and not self._is_special_filter(key)
59
+ if isinstance(value, str)
60
+ and not self._is_special_filter(key)
61
+ and self._validate_filter_field(key)
60
62
  }
61
63
  )
62
64
 
@@ -98,7 +100,9 @@ class BooleanFilterViewSetMixin(APIViewSet):
98
100
  **{
99
101
  key: value
100
102
  for key, value in filters.items()
101
- if isinstance(value, bool) and not self._is_special_filter(key)
103
+ if isinstance(value, bool)
104
+ and not self._is_special_filter(key)
105
+ and self._validate_filter_field(key)
102
106
  }
103
107
  )
104
108
 
@@ -140,7 +144,9 @@ class NumericFilterViewSetMixin(APIViewSet):
140
144
  **{
141
145
  key: value
142
146
  for key, value in filters.items()
143
- if isinstance(value, (int, float)) and not self._is_special_filter(key)
147
+ if isinstance(value, (int, float))
148
+ and not self._is_special_filter(key)
149
+ and self._validate_filter_field(key)
144
150
  }
145
151
  )
146
152
 
@@ -182,7 +188,9 @@ class DateFilterViewSetMixin(APIViewSet):
182
188
  **{
183
189
  f"{key}{self._compare_attr}": value
184
190
  for key, value in filters.items()
185
- if hasattr(value, "isoformat") and not self._is_special_filter(key)
191
+ if hasattr(value, "isoformat")
192
+ and not self._is_special_filter(key)
193
+ and self._validate_filter_field(key)
186
194
  }
187
195
  )
188
196
 
@@ -341,7 +349,8 @@ class RelationFilterViewSetMixin(APIViewSet):
341
349
  rel_filters = {}
342
350
  for rel_filter in self.relations_filters:
343
351
  value = filters.get(rel_filter.query_param)
344
- if value is not None:
352
+ # Validate the configured query_filter path for security
353
+ if value is not None and self._validate_filter_field(rel_filter.query_filter):
345
354
  rel_filters[rel_filter.query_filter] = value
346
355
  return base_qs.filter(**rel_filters) if rel_filters else base_qs
347
356
 
@@ -406,8 +415,15 @@ class MatchCaseFilterViewSetMixin(APIViewSet):
406
415
  filter_match.cases.true if value else filter_match.cases.false
407
416
  )
408
417
  lookup = case_filter.query_filter
409
- if case_filter.include:
410
- base_qs = base_qs.filter(**lookup)
411
- else:
412
- base_qs = base_qs.exclude(**lookup)
418
+ # Validate all filter fields in the lookup dictionary for security
419
+ validated_lookup = {
420
+ k: v
421
+ for k, v in lookup.items()
422
+ if self._validate_filter_field(k)
423
+ }
424
+ if validated_lookup:
425
+ if case_filter.include:
426
+ base_qs = base_qs.filter(**validated_lookup)
427
+ else:
428
+ base_qs = base_qs.exclude(**validated_lookup)
413
429
  return base_qs