django-ninja-aio-crud 2.17.0__py3-none-any.whl → 2.18.1__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.
@@ -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
@@ -25,6 +27,7 @@ from django.db.models.fields.related_descriptors import (
25
27
  ForwardOneToOneDescriptor,
26
28
  )
27
29
  from pydantic import BeforeValidator, Field
30
+ from pydantic._internal._decorators import PydanticDescriptorProxy
28
31
 
29
32
  from ninja_aio.types import (
30
33
  S_TYPES,
@@ -102,14 +105,166 @@ class BaseSerializer:
102
105
  queryset_request = ModelQuerySetSchema()
103
106
  extras: list[ModelQuerySetExtraSchema] = []
104
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
+
158
+ @classmethod
159
+ def _collect_validators(cls, source_class) -> dict:
160
+ """
161
+ Collect Pydantic validator descriptors from a class.
162
+
163
+ Iterates over the class attributes looking for ``PydanticDescriptorProxy``
164
+ instances (created by ``@field_validator`` and ``@model_validator`` decorators).
165
+
166
+ Parameters
167
+ ----------
168
+ source_class : type | None
169
+ The class to scan for validators.
170
+
171
+ Returns
172
+ -------
173
+ dict
174
+ Mapping of attribute name to ``PydanticDescriptorProxy`` instance.
175
+ """
176
+ validators = {}
177
+ if source_class is None:
178
+ return validators
179
+ for attr_name, attr_value in vars(source_class).items():
180
+ if isinstance(attr_value, PydanticDescriptorProxy):
181
+ validators[attr_name] = attr_value
182
+ return validators
183
+
184
+ @classmethod
185
+ def _apply_validators(cls, schema, validators: dict):
186
+ """
187
+ Create a subclass of the given schema with validators attached.
188
+
189
+ Pydantic discovers validators via ``PydanticDescriptorProxy`` instances
190
+ during class creation, so placing them on a subclass is sufficient.
191
+
192
+ Parameters
193
+ ----------
194
+ schema : Schema | None
195
+ The base schema class generated by ``create_schema``.
196
+ validators : dict
197
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
198
+
199
+ Returns
200
+ -------
201
+ Schema | None
202
+ A subclass with validators applied, or the original schema if no
203
+ validators are provided.
204
+ """
205
+ if not schema or not validators:
206
+ return schema
207
+ return type(schema.__name__, (schema,), validators)
208
+
209
+ @classmethod
210
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
211
+ """
212
+ Return collected validators for the given schema type.
213
+
214
+ Subclasses must implement this to map schema types to the appropriate
215
+ validator source class.
216
+
217
+ Parameters
218
+ ----------
219
+ schema_type : SCHEMA_TYPES
220
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
221
+
222
+ Returns
223
+ -------
224
+ dict
225
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
226
+ """
227
+ return {}
228
+
105
229
  @classmethod
106
230
  def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
107
- # Subclasses provide implementation.
231
+ """
232
+ Return raw configuration list for the given serializer/field category.
233
+
234
+ Parameters
235
+ ----------
236
+ s_type : S_TYPES
237
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
238
+ f_type : F_TYPES
239
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
240
+
241
+ Returns
242
+ -------
243
+ list
244
+ Raw configuration list for the requested category.
245
+
246
+ Raises
247
+ ------
248
+ NotImplementedError
249
+ Subclasses must provide an implementation.
250
+ """
108
251
  raise NotImplementedError
109
252
 
110
253
  @classmethod
111
254
  def _get_model(cls) -> models.Model:
112
- # Subclasses provide implementation.
255
+ """
256
+ Return the Django model class associated with this serializer.
257
+
258
+ Returns
259
+ -------
260
+ models.Model
261
+ The Django model class.
262
+
263
+ Raises
264
+ ------
265
+ NotImplementedError
266
+ Subclasses must provide an implementation.
267
+ """
113
268
  raise NotImplementedError
114
269
 
115
270
  @classmethod
@@ -246,12 +401,32 @@ class BaseSerializer:
246
401
 
247
402
  @classmethod
248
403
  def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
249
- # Optional in subclasses. Default to no explicit relation serializers.
404
+ """
405
+ Return mapping of relation field names to their serializer classes.
406
+
407
+ Subclasses may override to provide explicit serializer mappings for
408
+ relation fields used during read schema generation.
409
+
410
+ Returns
411
+ -------
412
+ dict[str, Serializer]
413
+ Mapping of field name to serializer class. Empty by default.
414
+ """
250
415
  return {}
251
416
 
252
417
  @classmethod
253
418
  def _get_relations_as_id(cls) -> list[str]:
254
- # Optional in subclasses. Default to no relations as ID.
419
+ """
420
+ Return relation field names that should be serialized as IDs.
421
+
422
+ Subclasses may override to specify which relation fields should be
423
+ represented as primary key values instead of nested objects.
424
+
425
+ Returns
426
+ -------
427
+ list[str]
428
+ Field names to serialize as IDs. Empty by default.
429
+ """
255
430
  return []
256
431
 
257
432
  @classmethod
@@ -302,45 +477,100 @@ class BaseSerializer:
302
477
  -------
303
478
  Schema | Union[Schema, ...] | None
304
479
  Generated schema, Union of schemas, or None if cannot be resolved.
305
- """
306
- # Auto-resolve ModelSerializer with readable fields
307
- if isinstance(rel_model, ModelSerializerMeta):
308
- has_readable_fields = rel_model.get_fields(
309
- "read"
310
- ) or rel_model.get_custom_fields("read")
311
- return rel_model.generate_related_s() if has_readable_fields else None
312
-
313
- # Resolve from explicit serializer mapping
314
- rel_serializers = cls._get_relations_serializers() or {}
315
- serializer_ref = rel_serializers.get(field_name)
316
480
 
317
- 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
+ )
318
495
  return None
319
496
 
320
- 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)
321
515
 
322
- # Handle Union of serializers
323
- if get_origin(resolved) is Union:
324
- 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)
325
519
 
326
- # Handle single serializer
327
- 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()
328
525
 
329
526
  @classmethod
330
527
  def _is_special_field(
331
528
  cls, s_type: type[S_TYPES], field: str, f_type: type[F_TYPES]
332
529
  ) -> bool:
333
- """Return True if field appears in the given category for s_type."""
530
+ """
531
+ Check whether a field appears in the given category for a serializer type.
532
+
533
+ Parameters
534
+ ----------
535
+ s_type : S_TYPES
536
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
537
+ field : str
538
+ The field name to look up.
539
+ f_type : F_TYPES
540
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
541
+
542
+ Returns
543
+ -------
544
+ bool
545
+ ``True`` if the field is found in the specified category.
546
+ """
334
547
  special_fields = cls._get_fields(s_type, f_type)
335
548
  return any(field in special_f for special_f in special_fields)
336
549
 
337
550
  @classmethod
338
551
  def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple[str, type, Any]]:
339
552
  """
340
- Normalize declared custom field specs into (name, py_type, default).
553
+ Normalize declared custom field specs into ``(name, py_type, default)`` tuples.
554
+
341
555
  Accepted tuple shapes:
342
- - (name, py_type, default)
343
- - (name, py_type) -> default Ellipsis (required)
556
+
557
+ - ``(name, py_type, default)`` -- field with explicit default.
558
+ - ``(name, py_type)`` -- required field (default set to ``Ellipsis``).
559
+
560
+ Parameters
561
+ ----------
562
+ s_type : S_TYPES
563
+ Serializer type whose custom fields to retrieve.
564
+
565
+ Returns
566
+ -------
567
+ list[tuple[str, type, Any]]
568
+ Normalized list of ``(name, type, default)`` tuples.
569
+
570
+ Raises
571
+ ------
572
+ ValueError
573
+ If a custom field spec is not a tuple or has an invalid length.
344
574
  """
345
575
  raw_customs = cls._get_fields(s_type, "customs") or []
346
576
  normalized: list[tuple[str, Any, Any]] = []
@@ -362,7 +592,19 @@ class BaseSerializer:
362
592
 
363
593
  @classmethod
364
594
  def get_optional_fields(cls, s_type: type[S_TYPES]):
365
- """Return optional field specs normalized to (name, type, None)."""
595
+ """
596
+ Return optional field specs normalized to ``(name, type, None)`` tuples.
597
+
598
+ Parameters
599
+ ----------
600
+ s_type : S_TYPES
601
+ Serializer type whose optional fields to retrieve.
602
+
603
+ Returns
604
+ -------
605
+ list[tuple[str, type, None]]
606
+ Normalized list where each entry defaults to ``None``.
607
+ """
366
608
  return [
367
609
  (field, field_type, None)
368
610
  for field, field_type in cls._get_fields(s_type, "optionals")
@@ -370,12 +612,39 @@ class BaseSerializer:
370
612
 
371
613
  @classmethod
372
614
  def get_excluded_fields(cls, s_type: S_TYPES):
373
- """Return excluded field names for the serializer type."""
615
+ """
616
+ Return excluded field names for the given serializer type.
617
+
618
+ Parameters
619
+ ----------
620
+ s_type : S_TYPES
621
+ Serializer type whose exclusions to retrieve.
622
+
623
+ Returns
624
+ -------
625
+ list[str]
626
+ Field names excluded from schema generation.
627
+ """
374
628
  return cls._get_fields(s_type, "excludes")
375
629
 
376
630
  @classmethod
377
631
  def get_fields(cls, s_type: S_TYPES):
378
- """Return explicit declared field names for the serializer type (excludes inline customs)."""
632
+ """
633
+ Return explicit declared field names for the serializer type.
634
+
635
+ Filters out inline custom field tuples from the fields list, returning
636
+ only string field names.
637
+
638
+ Parameters
639
+ ----------
640
+ s_type : S_TYPES
641
+ Serializer type whose fields to retrieve.
642
+
643
+ Returns
644
+ -------
645
+ list[str]
646
+ Model field names (excludes inline custom tuples).
647
+ """
379
648
  fields = cls._get_fields(s_type, "fields")
380
649
  # Filter out inline custom field tuples, return only string field names
381
650
  return [f for f in fields if isinstance(f, str)]
@@ -411,14 +680,40 @@ class BaseSerializer:
411
680
 
412
681
  @classmethod
413
682
  def is_custom(cls, field: str) -> bool:
414
- """True if field is declared as a custom input in create or update."""
683
+ """
684
+ Check if a field is declared as a custom input in create or update.
685
+
686
+ Parameters
687
+ ----------
688
+ field : str
689
+ The field name to check.
690
+
691
+ Returns
692
+ -------
693
+ bool
694
+ ``True`` if the field appears in the customs category for either
695
+ ``create`` or ``update`` serializer types.
696
+ """
415
697
  return cls._is_special_field(
416
698
  "create", field, "customs"
417
699
  ) or cls._is_special_field("update", field, "customs")
418
700
 
419
701
  @classmethod
420
702
  def is_optional(cls, field: str) -> bool:
421
- """True if field is declared as optional in create or update."""
703
+ """
704
+ Check if a field is declared as optional in create or update.
705
+
706
+ Parameters
707
+ ----------
708
+ field : str
709
+ The field name to check.
710
+
711
+ Returns
712
+ -------
713
+ bool
714
+ ``True`` if the field appears in the optionals category for either
715
+ ``create`` or ``update`` serializer types.
716
+ """
422
717
  return cls._is_special_field(
423
718
  "create", field, "optionals"
424
719
  ) or cls._is_special_field("update", field, "optionals")
@@ -531,7 +826,20 @@ class BaseSerializer:
531
826
 
532
827
  @classmethod
533
828
  def _is_reverse_relation(cls, field_obj) -> bool:
534
- """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
829
+ """
830
+ Check if a field descriptor represents a reverse relation.
831
+
832
+ Parameters
833
+ ----------
834
+ field_obj : Any
835
+ Django model field descriptor.
836
+
837
+ Returns
838
+ -------
839
+ bool
840
+ ``True`` if the descriptor is a ``ManyToManyDescriptor``,
841
+ ``ReverseManyToOneDescriptor``, or ``ReverseOneToOneDescriptor``.
842
+ """
535
843
  return isinstance(
536
844
  field_obj,
537
845
  (
@@ -543,14 +851,39 @@ class BaseSerializer:
543
851
 
544
852
  @classmethod
545
853
  def _is_forward_relation(cls, field_obj) -> bool:
546
- """Check if field is a forward relation (FK, O2O)."""
854
+ """
855
+ Check if a field descriptor represents a forward relation.
856
+
857
+ Parameters
858
+ ----------
859
+ field_obj : Any
860
+ Django model field descriptor.
861
+
862
+ Returns
863
+ -------
864
+ bool
865
+ ``True`` if the descriptor is a ``ForwardOneToOneDescriptor``
866
+ or ``ForwardManyToOneDescriptor``.
867
+ """
547
868
  return isinstance(
548
869
  field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
549
870
  )
550
871
 
551
872
  @classmethod
552
873
  def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
553
- """Emit warning for reverse relations without explicit serializer mapping."""
874
+ """
875
+ Emit a warning for reverse relations without an explicit serializer mapping.
876
+
877
+ Only warns when the related model is not a ``ModelSerializer`` and the
878
+ ``NINJA_AIO_RAISE_SERIALIZATION_WARNINGS`` setting is enabled (default).
879
+
880
+ Parameters
881
+ ----------
882
+ field_name : str
883
+ Name of the reverse relation field.
884
+ model : type
885
+ The Django model class owning the field.
886
+ """
554
887
  if not isinstance(model, ModelSerializerMeta) and getattr(
555
888
  settings, "NINJA_AIO_RAISE_SERIALIZATION_WARNINGS", True
556
889
  ):
@@ -669,53 +1002,50 @@ class BaseSerializer:
669
1002
  )
670
1003
 
671
1004
  @classmethod
672
- def _generate_model_schema(
673
- cls,
674
- schema_type: type[SCHEMA_TYPES],
675
- depth: int = None,
676
- ) -> Schema:
677
- """
678
- Core schema factory bridging configuration to ninja.orm.create_schema.
679
- Handles In/Patch/Out/Related.
680
- """
681
- model = cls._get_model()
682
-
683
- # Handle special schema types with custom logic
684
- if schema_type == "Out" or schema_type == "Detail":
685
- fields, reverse_rels, excludes, customs, optionals = (
686
- cls.get_schema_out_data(schema_type)
687
- )
688
- if not any([fields, reverse_rels, excludes, customs]):
689
- return None
690
- schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
691
- return create_schema(
692
- model=model,
693
- name=f"{model._meta.model_name}{schema_name}",
694
- depth=depth,
695
- fields=fields,
696
- custom_fields=reverse_rels + customs + optionals,
697
- exclude=excludes,
698
- )
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)
699
1024
 
700
- if schema_type == "Related":
701
- fields, customs = cls.get_related_schema_data()
702
- if not fields and not customs:
703
- return None
704
- return create_schema(
705
- model=model,
706
- name=f"{model._meta.model_name}SchemaRelated",
707
- fields=fields,
708
- custom_fields=customs,
709
- )
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)
710
1038
 
711
- # 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."""
712
1044
  s_type = "create" if schema_type == "In" else "update"
713
1045
  fields = cls.get_fields(s_type)
714
1046
  optionals = cls.get_optional_fields(s_type)
715
1047
  customs = (
716
- cls.get_custom_fields(s_type)
717
- + optionals
718
- + cls.get_inline_customs(s_type)
1048
+ cls.get_custom_fields(s_type) + optionals + cls.get_inline_customs(s_type)
719
1049
  )
720
1050
  excludes = cls.get_excluded_fields(s_type)
721
1051
 
@@ -736,13 +1066,50 @@ class BaseSerializer:
736
1066
  if not any([fields, customs, excludes]):
737
1067
  return None
738
1068
 
739
- return create_schema(
1069
+ schema = create_schema(
740
1070
  model=model,
741
1071
  name=f"{model._meta.model_name}Schema{schema_type}",
742
1072
  fields=fields,
743
1073
  custom_fields=customs,
744
1074
  exclude=excludes,
745
1075
  )
1076
+ return cls._apply_validators(schema, validators)
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)
746
1113
 
747
1114
  @classmethod
748
1115
  def get_related_schema_data(cls):
@@ -779,28 +1146,94 @@ class BaseSerializer:
779
1146
  return non_relation_fields, customs
780
1147
 
781
1148
  @classmethod
1149
+ @lru_cache(maxsize=128)
782
1150
  def generate_read_s(cls, depth: int = 1) -> Schema:
783
- """Generate read (Out) schema."""
1151
+ """
1152
+ Generate the read (Out) schema for list responses.
1153
+
1154
+ Performance: Results are cached per (class, depth) combination.
1155
+
1156
+ Parameters
1157
+ ----------
1158
+ depth : int, optional
1159
+ Nesting depth for related models. Defaults to ``1``.
1160
+
1161
+ Returns
1162
+ -------
1163
+ Schema | None
1164
+ Generated Pydantic schema, or ``None`` if no read fields are configured.
1165
+ """
784
1166
  return cls._generate_model_schema("Out", depth)
785
1167
 
786
1168
  @classmethod
1169
+ @lru_cache(maxsize=128)
787
1170
  def generate_detail_s(cls, depth: int = 1) -> Schema:
788
- """Generate detail (single object Out) schema."""
1171
+ """
1172
+ Generate the detail (single-object) read schema.
1173
+
1174
+ Falls back to the standard read schema if no detail-specific
1175
+ configuration is defined.
1176
+
1177
+ Performance: Results are cached per (class, depth) combination.
1178
+
1179
+ Parameters
1180
+ ----------
1181
+ depth : int, optional
1182
+ Nesting depth for related models. Defaults to ``1``.
1183
+
1184
+ Returns
1185
+ -------
1186
+ Schema
1187
+ Generated Pydantic schema (never ``None``; falls back to read schema).
1188
+ """
789
1189
  return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
790
1190
 
791
1191
  @classmethod
1192
+ @lru_cache(maxsize=128)
792
1193
  def generate_create_s(cls) -> Schema:
793
- """Generate create (In) schema."""
1194
+ """
1195
+ Generate the create (In) schema for input validation.
1196
+
1197
+ Performance: Results are cached per class.
1198
+
1199
+ Returns
1200
+ -------
1201
+ Schema | None
1202
+ Generated Pydantic schema, or ``None`` if no create fields are configured.
1203
+ """
794
1204
  return cls._generate_model_schema("In")
795
1205
 
796
1206
  @classmethod
1207
+ @lru_cache(maxsize=128)
797
1208
  def generate_update_s(cls) -> Schema:
798
- """Generate update (Patch) schema."""
1209
+ """
1210
+ Generate the update (Patch) schema for partial updates.
1211
+
1212
+ Performance: Results are cached per class.
1213
+
1214
+ Returns
1215
+ -------
1216
+ Schema | None
1217
+ Generated Pydantic schema, or ``None`` if no update fields are configured.
1218
+ """
799
1219
  return cls._generate_model_schema("Patch")
800
1220
 
801
1221
  @classmethod
1222
+ @lru_cache(maxsize=128)
802
1223
  def generate_related_s(cls) -> Schema:
803
- """Generate related (nested) schema."""
1224
+ """
1225
+ Generate the related (nested) schema for embedding in parent schemas.
1226
+
1227
+ Includes only non-relational model fields and custom fields, preventing
1228
+ infinite nesting of related objects.
1229
+
1230
+ Performance: Results are cached per class.
1231
+
1232
+ Returns
1233
+ -------
1234
+ Schema | None
1235
+ Generated Pydantic schema, or ``None`` if no fields are configured.
1236
+ """
804
1237
  return cls._generate_model_schema("Related")
805
1238
 
806
1239
  @classmethod
@@ -861,8 +1294,8 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
861
1294
  Disallowed model fields on create (e.g., id, timestamps).
862
1295
  """
863
1296
 
864
- fields: list[str | tuple[str, Any, Any]] = []
865
- customs: list[tuple[str, Any, Any]] = []
1297
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1298
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
866
1299
  optionals: list[tuple[str, Any]] = []
867
1300
  excludes: list[str] = []
868
1301
 
@@ -883,8 +1316,8 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
883
1316
  Relation fields to serialize as IDs instead of nested objects.
884
1317
  """
885
1318
 
886
- fields: list[str | tuple[str, Any, Any]] = []
887
- customs: list[tuple[str, Any, Any]] = []
1319
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1320
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
888
1321
  optionals: list[tuple[str, Any]] = []
889
1322
  excludes: list[str] = []
890
1323
  relations_as_id: list[str] = []
@@ -904,8 +1337,8 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
904
1337
  Optional output fields.
905
1338
  """
906
1339
 
907
- fields: list[str | tuple[str, Any, Any]] = []
908
- customs: list[tuple[str, Any, Any]] = []
1340
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1341
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
909
1342
  optionals: list[tuple[str, Any]] = []
910
1343
  excludes: list[str] = []
911
1344
 
@@ -937,9 +1370,47 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
937
1370
  "detail": "DetailSerializer",
938
1371
  }
939
1372
 
1373
+ # Schema type to serializer type mapping for validator resolution
1374
+ _SCHEMA_TO_S_TYPE = {
1375
+ "In": "create",
1376
+ "Patch": "update",
1377
+ "Out": "read",
1378
+ "Detail": "detail",
1379
+ "Related": "read",
1380
+ }
1381
+
1382
+ @classmethod
1383
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
1384
+ """
1385
+ Collect validators from the inner serializer class for the given schema type.
1386
+
1387
+ Parameters
1388
+ ----------
1389
+ schema_type : SCHEMA_TYPES
1390
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1391
+
1392
+ Returns
1393
+ -------
1394
+ dict
1395
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
1396
+ """
1397
+ s_type = cls._SCHEMA_TO_S_TYPE.get(schema_type)
1398
+ config_name = cls._SERIALIZER_CONFIG_MAP.get(s_type)
1399
+ config_class = getattr(cls, config_name, None) if config_name else None
1400
+ return cls._collect_validators(config_class)
1401
+
940
1402
  @classmethod
941
1403
  def _get_relations_as_id(cls) -> list[str]:
942
- """Return relation fields to serialize as IDs instead of nested objects."""
1404
+ """
1405
+ Return relation fields to serialize as primary key values.
1406
+
1407
+ Reads the ``relations_as_id`` attribute from ``ReadSerializer``.
1408
+
1409
+ Returns
1410
+ -------
1411
+ list[str]
1412
+ Field names whose related objects should be serialized as IDs.
1413
+ """
943
1414
  return getattr(cls.ReadSerializer, "relations_as_id", [])
944
1415
 
945
1416
  @classmethod
@@ -970,17 +1441,30 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
970
1441
 
971
1442
  @classmethod
972
1443
  def _get_model(cls) -> "ModelSerializer":
973
- """Return the model class itself."""
1444
+ """
1445
+ Return the model class itself.
1446
+
1447
+ Since ``ModelSerializer`` is mixed directly into the model, the model
1448
+ class is the serializer class.
1449
+
1450
+ Returns
1451
+ -------
1452
+ ModelSerializer
1453
+ The model/serializer class.
1454
+ """
974
1455
  return cls
975
1456
 
976
1457
  @classmethod
977
1458
  def verbose_name_path_resolver(cls) -> str:
978
1459
  """
979
- Slugify plural verbose name for URL path segment.
1460
+ Slugify the plural verbose name for use as a URL path segment.
1461
+
1462
+ Replaces spaces with hyphens in the model's ``verbose_name_plural``.
980
1463
 
981
1464
  Returns
982
1465
  -------
983
1466
  str
1467
+ Hyphen-separated URL-safe path segment.
984
1468
  """
985
1469
  return "-".join(cls._meta.verbose_name_plural.split(" "))
986
1470
 
@@ -1103,14 +1587,16 @@ class SchemaModelConfig(Schema):
1103
1587
  Optional model fields. Type can be any valid type annotation including Union.
1104
1588
  exclude : Optional[List[str]]
1105
1589
  Model fields to exclude.
1106
- customs : Optional[List[tuple[str, Any, Any]]]
1590
+ customs : Optional[List[tuple[str, Any, Any] | tuple[str, Any]]]
1107
1591
  Custom / synthetic fields. Type can be any valid type annotation including Union.
1592
+ - 2-tuple: (name, type) - required field
1593
+ - 3-tuple: (name, type, default) - optional field with default
1108
1594
  """
1109
1595
 
1110
1596
  fields: Optional[List[str | tuple[str, Any, Any] | tuple[str, Any]]] = None
1111
1597
  optionals: Optional[List[tuple[str, Any]]] = None
1112
1598
  exclude: Optional[List[str]] = None
1113
- customs: Optional[List[tuple[str, Any, Any]]] = None
1599
+ customs: Optional[List[tuple[str, Any, Any] | tuple[str, Any]]] = None
1114
1600
 
1115
1601
 
1116
1602
  class Serializer(BaseSerializer, metaclass=SerializerMeta):
@@ -1131,6 +1617,15 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1131
1617
  "detail": "detail",
1132
1618
  }
1133
1619
 
1620
+ # Schema type to validators inner class mapping
1621
+ _VALIDATORS_CLASS_MAP = {
1622
+ "In": "CreateValidators",
1623
+ "Patch": "UpdateValidators",
1624
+ "Out": "ReadValidators",
1625
+ "Detail": "DetailValidators",
1626
+ "Related": "ReadValidators",
1627
+ }
1628
+
1134
1629
  def __init_subclass__(cls, **kwargs):
1135
1630
  super().__init_subclass__(**kwargs)
1136
1631
  from ninja_aio.models.utils import ModelUtil
@@ -1150,26 +1645,118 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1150
1645
  relations_serializers: dict[str, "Serializer"] = {}
1151
1646
  relations_as_id: list[str] = []
1152
1647
 
1648
+ def _parse_payload(self, payload: dict[str, Any] | Schema) -> dict[str, Any]:
1649
+ """
1650
+ Parse and return the input payload.
1651
+
1652
+ Can be overridden to implement custom parsing logic.
1653
+
1654
+ Parameters
1655
+ ----------
1656
+ payload : dict | Schema
1657
+ Input data.
1658
+
1659
+ Returns
1660
+ -------
1661
+ dict
1662
+ Parsed payload.
1663
+ """
1664
+ return payload.model_dump() if isinstance(payload, Schema) else payload
1665
+
1666
+ @classmethod
1667
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
1668
+ """
1669
+ Collect validators from the inner validators class for the given schema type.
1670
+
1671
+ Looks for inner classes named ``CreateValidators``, ``ReadValidators``,
1672
+ ``UpdateValidators``, or ``DetailValidators`` on the serializer.
1673
+
1674
+ Parameters
1675
+ ----------
1676
+ schema_type : SCHEMA_TYPES
1677
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1678
+
1679
+ Returns
1680
+ -------
1681
+ dict
1682
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
1683
+ """
1684
+ class_name = cls._VALIDATORS_CLASS_MAP.get(schema_type)
1685
+ validators_class = getattr(cls, class_name, None) if class_name else None
1686
+ return cls._collect_validators(validators_class)
1687
+
1153
1688
  @classmethod
1154
1689
  def _get_relations_as_id(cls) -> list[str]:
1690
+ """
1691
+ Return relation fields to serialize as primary key values.
1692
+
1693
+ Reads the ``relations_as_id`` attribute from ``Meta``.
1694
+
1695
+ Returns
1696
+ -------
1697
+ list[str]
1698
+ Field names whose related objects should be serialized as IDs.
1699
+ """
1155
1700
  relations_as_id = cls._get_meta_data("relations_as_id")
1156
1701
  return relations_as_id or []
1157
1702
 
1158
1703
  @classmethod
1159
1704
  def _get_meta_data(cls, attr_name: str) -> Any:
1705
+ """
1706
+ Retrieve an attribute from the nested ``Meta`` class.
1707
+
1708
+ Parameters
1709
+ ----------
1710
+ attr_name : str
1711
+ Name of the ``Meta`` attribute to look up.
1712
+
1713
+ Returns
1714
+ -------
1715
+ Any
1716
+ The attribute value, or ``None`` if not defined.
1717
+ """
1160
1718
  return getattr(cls.Meta, attr_name, None)
1161
1719
 
1162
1720
  @classmethod
1163
1721
  def _get_model(cls) -> models.Model:
1722
+ """
1723
+ Return the Django model class from ``Meta.model``.
1724
+
1725
+ Returns
1726
+ -------
1727
+ models.Model
1728
+ The validated Django model class.
1729
+ """
1164
1730
  return cls._validate_model()
1165
1731
 
1166
1732
  @classmethod
1167
1733
  def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
1734
+ """
1735
+ Return the explicit relation-to-serializer mapping from ``Meta``.
1736
+
1737
+ Returns
1738
+ -------
1739
+ dict[str, Serializer]
1740
+ Mapping of relation field names to serializer classes.
1741
+ """
1168
1742
  relations_serializers = cls._get_meta_data("relations_serializers")
1169
1743
  return relations_serializers or {}
1170
1744
 
1171
1745
  @classmethod
1172
1746
  def _get_schema_meta(cls, schema_type: str) -> SchemaModelConfig | None:
1747
+ """
1748
+ Retrieve the ``SchemaModelConfig`` for the given schema type.
1749
+
1750
+ Parameters
1751
+ ----------
1752
+ schema_type : str
1753
+ One of ``"in"``, ``"out"``, ``"update"``, or ``"detail"``.
1754
+
1755
+ Returns
1756
+ -------
1757
+ SchemaModelConfig | None
1758
+ The configuration object, or ``None`` if not defined.
1759
+ """
1173
1760
  match schema_type:
1174
1761
  case "in":
1175
1762
  return cls._get_meta_data("schema_in")
@@ -1184,6 +1771,19 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1184
1771
 
1185
1772
  @classmethod
1186
1773
  def _validate_model(cls):
1774
+ """
1775
+ Validate and return the model defined in ``Meta.model``.
1776
+
1777
+ Returns
1778
+ -------
1779
+ models.Model
1780
+ The validated Django model class.
1781
+
1782
+ Raises
1783
+ ------
1784
+ ValueError
1785
+ If ``Meta.model`` is not defined or is not a Django model subclass.
1786
+ """
1187
1787
  model = cls._get_meta_data("model")
1188
1788
  if not model:
1189
1789
  raise ValueError("Meta.model must be defined for Serializer.")
@@ -1193,7 +1793,23 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1193
1793
 
1194
1794
  @classmethod
1195
1795
  def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
1196
- """Internal accessor for raw configuration lists from Meta schemas."""
1796
+ """
1797
+ Return raw configuration list from the Meta schema for the given categories.
1798
+
1799
+ Falls back to the ``out`` schema when ``detail`` is requested but not defined.
1800
+
1801
+ Parameters
1802
+ ----------
1803
+ s_type : S_TYPES
1804
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
1805
+ f_type : F_TYPES
1806
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
1807
+
1808
+ Returns
1809
+ -------
1810
+ list
1811
+ Raw configuration list, or empty list if not configured.
1812
+ """
1197
1813
  schema_key = cls._SCHEMA_META_MAP.get(s_type)
1198
1814
  if not schema_key:
1199
1815
  return []
@@ -1248,13 +1864,13 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1248
1864
  self.after_save(instance)
1249
1865
  return instance
1250
1866
 
1251
- async def create(self, payload: dict[str, Any]) -> models.Model:
1867
+ async def create(self, payload: dict[str, Any] | Schema) -> models.Model:
1252
1868
  """
1253
1869
  Create a new model instance from the provided payload.
1254
1870
 
1255
1871
  Parameters
1256
1872
  ----------
1257
- payload : dict
1873
+ payload : dict | Schema
1258
1874
  Input data.
1259
1875
 
1260
1876
  Returns
@@ -1262,11 +1878,11 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1262
1878
  models.Model
1263
1879
  Created model instance.
1264
1880
  """
1265
- instance: models.Model = self.model(**payload)
1881
+ instance: models.Model = self.model(**self._parse_payload(payload))
1266
1882
  return await self.save(instance)
1267
1883
 
1268
1884
  async def update(
1269
- self, instance: models.Model, payload: dict[str, Any]
1885
+ self, instance: models.Model, payload: dict[str, Any] | Schema
1270
1886
  ) -> models.Model:
1271
1887
  """
1272
1888
  Update an existing model instance with the provided payload.
@@ -1275,7 +1891,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1275
1891
  ----------
1276
1892
  instance : models.Model
1277
1893
  The model instance to update.
1278
- payload : dict
1894
+ payload : dict | Schema
1279
1895
  Input data.
1280
1896
 
1281
1897
  Returns
@@ -1283,7 +1899,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1283
1899
  models.Model
1284
1900
  Updated model instance.
1285
1901
  """
1286
- for attr, value in payload.items():
1902
+ for attr, value in self._parse_payload(payload).items():
1287
1903
  setattr(instance, attr, value)
1288
1904
  return await self.save(instance)
1289
1905