django-ninja-aio-crud 2.16.2__py3-none-any.whl → 2.18.0__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.
@@ -25,6 +25,7 @@ from django.db.models.fields.related_descriptors import (
25
25
  ForwardOneToOneDescriptor,
26
26
  )
27
27
  from pydantic import BeforeValidator, Field
28
+ from pydantic._internal._decorators import PydanticDescriptorProxy
28
29
 
29
30
  from ninja_aio.types import (
30
31
  S_TYPES,
@@ -102,14 +103,116 @@ class BaseSerializer:
102
103
  queryset_request = ModelQuerySetSchema()
103
104
  extras: list[ModelQuerySetExtraSchema] = []
104
105
 
106
+ @classmethod
107
+ def _collect_validators(cls, source_class) -> dict:
108
+ """
109
+ Collect Pydantic validator descriptors from a class.
110
+
111
+ Iterates over the class attributes looking for ``PydanticDescriptorProxy``
112
+ instances (created by ``@field_validator`` and ``@model_validator`` decorators).
113
+
114
+ Parameters
115
+ ----------
116
+ source_class : type | None
117
+ The class to scan for validators.
118
+
119
+ Returns
120
+ -------
121
+ dict
122
+ Mapping of attribute name to ``PydanticDescriptorProxy`` instance.
123
+ """
124
+ validators = {}
125
+ if source_class is None:
126
+ return validators
127
+ for attr_name, attr_value in vars(source_class).items():
128
+ if isinstance(attr_value, PydanticDescriptorProxy):
129
+ validators[attr_name] = attr_value
130
+ return validators
131
+
132
+ @classmethod
133
+ def _apply_validators(cls, schema, validators: dict):
134
+ """
135
+ Create a subclass of the given schema with validators attached.
136
+
137
+ Pydantic discovers validators via ``PydanticDescriptorProxy`` instances
138
+ during class creation, so placing them on a subclass is sufficient.
139
+
140
+ Parameters
141
+ ----------
142
+ schema : Schema | None
143
+ The base schema class generated by ``create_schema``.
144
+ validators : dict
145
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
146
+
147
+ Returns
148
+ -------
149
+ Schema | None
150
+ A subclass with validators applied, or the original schema if no
151
+ validators are provided.
152
+ """
153
+ if not schema or not validators:
154
+ return schema
155
+ return type(schema.__name__, (schema,), validators)
156
+
157
+ @classmethod
158
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
159
+ """
160
+ Return collected validators for the given schema type.
161
+
162
+ Subclasses must implement this to map schema types to the appropriate
163
+ validator source class.
164
+
165
+ Parameters
166
+ ----------
167
+ schema_type : SCHEMA_TYPES
168
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
169
+
170
+ Returns
171
+ -------
172
+ dict
173
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
174
+ """
175
+ return {}
176
+
105
177
  @classmethod
106
178
  def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
107
- # Subclasses provide implementation.
179
+ """
180
+ Return raw configuration list for the given serializer/field category.
181
+
182
+ Parameters
183
+ ----------
184
+ s_type : S_TYPES
185
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
186
+ f_type : F_TYPES
187
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
188
+
189
+ Returns
190
+ -------
191
+ list
192
+ Raw configuration list for the requested category.
193
+
194
+ Raises
195
+ ------
196
+ NotImplementedError
197
+ Subclasses must provide an implementation.
198
+ """
108
199
  raise NotImplementedError
109
200
 
110
201
  @classmethod
111
202
  def _get_model(cls) -> models.Model:
112
- # Subclasses provide implementation.
203
+ """
204
+ Return the Django model class associated with this serializer.
205
+
206
+ Returns
207
+ -------
208
+ models.Model
209
+ The Django model class.
210
+
211
+ Raises
212
+ ------
213
+ NotImplementedError
214
+ Subclasses must provide an implementation.
215
+ """
113
216
  raise NotImplementedError
114
217
 
115
218
  @classmethod
@@ -246,12 +349,32 @@ class BaseSerializer:
246
349
 
247
350
  @classmethod
248
351
  def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
249
- # Optional in subclasses. Default to no explicit relation serializers.
352
+ """
353
+ Return mapping of relation field names to their serializer classes.
354
+
355
+ Subclasses may override to provide explicit serializer mappings for
356
+ relation fields used during read schema generation.
357
+
358
+ Returns
359
+ -------
360
+ dict[str, Serializer]
361
+ Mapping of field name to serializer class. Empty by default.
362
+ """
250
363
  return {}
251
364
 
252
365
  @classmethod
253
366
  def _get_relations_as_id(cls) -> list[str]:
254
- # Optional in subclasses. Default to no relations as ID.
367
+ """
368
+ Return relation field names that should be serialized as IDs.
369
+
370
+ Subclasses may override to specify which relation fields should be
371
+ represented as primary key values instead of nested objects.
372
+
373
+ Returns
374
+ -------
375
+ list[str]
376
+ Field names to serialize as IDs. Empty by default.
377
+ """
255
378
  return []
256
379
 
257
380
  @classmethod
@@ -305,7 +428,9 @@ class BaseSerializer:
305
428
  """
306
429
  # Auto-resolve ModelSerializer with readable fields
307
430
  if isinstance(rel_model, ModelSerializerMeta):
308
- has_readable_fields = rel_model.get_fields("read") or rel_model.get_custom_fields("read")
431
+ has_readable_fields = rel_model.get_fields(
432
+ "read"
433
+ ) or rel_model.get_custom_fields("read")
309
434
  return rel_model.generate_related_s() if has_readable_fields else None
310
435
 
311
436
  # Resolve from explicit serializer mapping
@@ -328,20 +453,53 @@ class BaseSerializer:
328
453
  def _is_special_field(
329
454
  cls, s_type: type[S_TYPES], field: str, f_type: type[F_TYPES]
330
455
  ) -> bool:
331
- """Return True if field appears in the given category for s_type."""
456
+ """
457
+ Check whether a field appears in the given category for a serializer type.
458
+
459
+ Parameters
460
+ ----------
461
+ s_type : S_TYPES
462
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
463
+ field : str
464
+ The field name to look up.
465
+ f_type : F_TYPES
466
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
467
+
468
+ Returns
469
+ -------
470
+ bool
471
+ ``True`` if the field is found in the specified category.
472
+ """
332
473
  special_fields = cls._get_fields(s_type, f_type)
333
474
  return any(field in special_f for special_f in special_fields)
334
475
 
335
476
  @classmethod
336
477
  def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple[str, type, Any]]:
337
478
  """
338
- Normalize declared custom field specs into (name, py_type, default).
479
+ Normalize declared custom field specs into ``(name, py_type, default)`` tuples.
480
+
339
481
  Accepted tuple shapes:
340
- - (name, py_type, default)
341
- - (name, py_type) -> default Ellipsis (required)
482
+
483
+ - ``(name, py_type, default)`` -- field with explicit default.
484
+ - ``(name, py_type)`` -- required field (default set to ``Ellipsis``).
485
+
486
+ Parameters
487
+ ----------
488
+ s_type : S_TYPES
489
+ Serializer type whose custom fields to retrieve.
490
+
491
+ Returns
492
+ -------
493
+ list[tuple[str, type, Any]]
494
+ Normalized list of ``(name, type, default)`` tuples.
495
+
496
+ Raises
497
+ ------
498
+ ValueError
499
+ If a custom field spec is not a tuple or has an invalid length.
342
500
  """
343
501
  raw_customs = cls._get_fields(s_type, "customs") or []
344
- normalized: list[tuple[str, type, Any]] = []
502
+ normalized: list[tuple[str, Any, Any]] = []
345
503
  for spec in raw_customs:
346
504
  if not isinstance(spec, tuple):
347
505
  raise ValueError(f"Custom field spec must be a tuple, got {type(spec)}")
@@ -360,7 +518,19 @@ class BaseSerializer:
360
518
 
361
519
  @classmethod
362
520
  def get_optional_fields(cls, s_type: type[S_TYPES]):
363
- """Return optional field specs normalized to (name, type, None)."""
521
+ """
522
+ Return optional field specs normalized to ``(name, type, None)`` tuples.
523
+
524
+ Parameters
525
+ ----------
526
+ s_type : S_TYPES
527
+ Serializer type whose optional fields to retrieve.
528
+
529
+ Returns
530
+ -------
531
+ list[tuple[str, type, None]]
532
+ Normalized list where each entry defaults to ``None``.
533
+ """
364
534
  return [
365
535
  (field, field_type, None)
366
536
  for field, field_type in cls._get_fields(s_type, "optionals")
@@ -368,24 +538,108 @@ class BaseSerializer:
368
538
 
369
539
  @classmethod
370
540
  def get_excluded_fields(cls, s_type: S_TYPES):
371
- """Return excluded field names for the serializer type."""
541
+ """
542
+ Return excluded field names for the given serializer type.
543
+
544
+ Parameters
545
+ ----------
546
+ s_type : S_TYPES
547
+ Serializer type whose exclusions to retrieve.
548
+
549
+ Returns
550
+ -------
551
+ list[str]
552
+ Field names excluded from schema generation.
553
+ """
372
554
  return cls._get_fields(s_type, "excludes")
373
555
 
374
556
  @classmethod
375
557
  def get_fields(cls, s_type: S_TYPES):
376
- """Return explicit declared fields for the serializer type."""
377
- return cls._get_fields(s_type, "fields")
558
+ """
559
+ Return explicit declared field names for the serializer type.
560
+
561
+ Filters out inline custom field tuples from the fields list, returning
562
+ only string field names.
563
+
564
+ Parameters
565
+ ----------
566
+ s_type : S_TYPES
567
+ Serializer type whose fields to retrieve.
568
+
569
+ Returns
570
+ -------
571
+ list[str]
572
+ Model field names (excludes inline custom tuples).
573
+ """
574
+ fields = cls._get_fields(s_type, "fields")
575
+ # Filter out inline custom field tuples, return only string field names
576
+ return [f for f in fields if isinstance(f, str)]
577
+
578
+ @classmethod
579
+ def get_inline_customs(cls, s_type: S_TYPES) -> list[tuple[str, Any, Any]]:
580
+ """
581
+ Return inline custom field tuples declared directly in the fields list.
582
+
583
+ These are tuples in the format (name, type, default) or (name, type) mixed
584
+ with regular string field names in the fields list.
585
+
586
+ Returns
587
+ -------
588
+ list[tuple[str, Any, Any]]
589
+ Normalized list of (name, type, default) tuples.
590
+ """
591
+ fields = cls._get_fields(s_type, "fields")
592
+ inline_customs: list[tuple[str, Any, Any]] = []
593
+ for spec in fields:
594
+ if isinstance(spec, tuple):
595
+ match len(spec):
596
+ case 3:
597
+ inline_customs.append(spec)
598
+ case 2:
599
+ name, py_type = spec
600
+ inline_customs.append((name, py_type, ...))
601
+ case _:
602
+ raise ValueError(
603
+ f"Inline custom field tuple must have length 2 or 3 (name, type[, default]); got {len(spec)}"
604
+ )
605
+ return inline_customs
378
606
 
379
607
  @classmethod
380
608
  def is_custom(cls, field: str) -> bool:
381
- """True if field is declared as a custom input in create or update."""
609
+ """
610
+ Check if a field is declared as a custom input in create or update.
611
+
612
+ Parameters
613
+ ----------
614
+ field : str
615
+ The field name to check.
616
+
617
+ Returns
618
+ -------
619
+ bool
620
+ ``True`` if the field appears in the customs category for either
621
+ ``create`` or ``update`` serializer types.
622
+ """
382
623
  return cls._is_special_field(
383
624
  "create", field, "customs"
384
625
  ) or cls._is_special_field("update", field, "customs")
385
626
 
386
627
  @classmethod
387
628
  def is_optional(cls, field: str) -> bool:
388
- """True if field is declared as optional in create or update."""
629
+ """
630
+ Check if a field is declared as optional in create or update.
631
+
632
+ Parameters
633
+ ----------
634
+ field : str
635
+ The field name to check.
636
+
637
+ Returns
638
+ -------
639
+ bool
640
+ ``True`` if the field appears in the optionals category for either
641
+ ``create`` or ``update`` serializer types.
642
+ """
389
643
  return cls._is_special_field(
390
644
  "create", field, "optionals"
391
645
  ) or cls._is_special_field("update", field, "optionals")
@@ -430,10 +684,15 @@ class BaseSerializer:
430
684
  # Handle relations_as_id for reverse relations
431
685
  if field_name in relations_as_id:
432
686
  from ninja_aio.models.utils import ModelUtil
687
+
433
688
  pk_field_type = ModelUtil(rel_model).pk_field_type
434
689
  if many:
435
690
  # For many relations, use PkFromModel to extract PKs from model instances
436
- return (field_name, list[PkFromModel[pk_field_type]], Field(default_factory=list))
691
+ return (
692
+ field_name,
693
+ list[PkFromModel[pk_field_type]],
694
+ Field(default_factory=list),
695
+ )
437
696
  else:
438
697
  # For single reverse relations (ReverseOneToOne), extract pk
439
698
  return (field_name, PkFromModel[pk_field_type] | None, None)
@@ -472,6 +731,7 @@ class BaseSerializer:
472
731
  # Handle relations_as_id: serialize as the raw FK ID
473
732
  if field_name in relations_as_id:
474
733
  from ninja_aio.models.utils import ModelUtil
734
+
475
735
  pk_field_type = ModelUtil(rel_model).pk_field_type
476
736
  # Use PkFromModel to extract pk from the related instance during serialization
477
737
  return (field_name, PkFromModel[pk_field_type] | None, None)
@@ -492,7 +752,20 @@ class BaseSerializer:
492
752
 
493
753
  @classmethod
494
754
  def _is_reverse_relation(cls, field_obj) -> bool:
495
- """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
755
+ """
756
+ Check if a field descriptor represents a reverse relation.
757
+
758
+ Parameters
759
+ ----------
760
+ field_obj : Any
761
+ Django model field descriptor.
762
+
763
+ Returns
764
+ -------
765
+ bool
766
+ ``True`` if the descriptor is a ``ManyToManyDescriptor``,
767
+ ``ReverseManyToOneDescriptor``, or ``ReverseOneToOneDescriptor``.
768
+ """
496
769
  return isinstance(
497
770
  field_obj,
498
771
  (
@@ -504,14 +777,39 @@ class BaseSerializer:
504
777
 
505
778
  @classmethod
506
779
  def _is_forward_relation(cls, field_obj) -> bool:
507
- """Check if field is a forward relation (FK, O2O)."""
780
+ """
781
+ Check if a field descriptor represents a forward relation.
782
+
783
+ Parameters
784
+ ----------
785
+ field_obj : Any
786
+ Django model field descriptor.
787
+
788
+ Returns
789
+ -------
790
+ bool
791
+ ``True`` if the descriptor is a ``ForwardOneToOneDescriptor``
792
+ or ``ForwardManyToOneDescriptor``.
793
+ """
508
794
  return isinstance(
509
795
  field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
510
796
  )
511
797
 
512
798
  @classmethod
513
799
  def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
514
- """Emit warning for reverse relations without explicit serializer mapping."""
800
+ """
801
+ Emit a warning for reverse relations without an explicit serializer mapping.
802
+
803
+ Only warns when the related model is not a ``ModelSerializer`` and the
804
+ ``NINJA_AIO_RAISE_SERIALIZATION_WARNINGS`` setting is enabled (default).
805
+
806
+ Parameters
807
+ ----------
808
+ field_name : str
809
+ Name of the reverse relation field.
810
+ model : type
811
+ The Django model class owning the field.
812
+ """
515
813
  if not isinstance(model, ModelSerializerMeta) and getattr(
516
814
  settings, "NINJA_AIO_RAISE_SERIALIZATION_WARNINGS", True
517
815
  ):
@@ -614,11 +912,18 @@ class BaseSerializer:
614
912
  if forward:
615
913
  forward_rels.append(forward)
616
914
 
915
+ # Combine explicit customs, inline customs, and forward relation schemas
916
+ all_customs = (
917
+ cls.get_custom_fields(fields_type)
918
+ + cls.get_inline_customs(fields_type)
919
+ + forward_rels
920
+ )
921
+
617
922
  return (
618
923
  fields,
619
924
  reverse_rels,
620
925
  cls.get_excluded_fields(fields_type),
621
- cls.get_custom_fields(fields_type) + forward_rels,
926
+ all_customs,
622
927
  cls.get_optional_fields(fields_type),
623
928
  )
624
929
 
@@ -629,10 +934,26 @@ class BaseSerializer:
629
934
  depth: int = None,
630
935
  ) -> Schema:
631
936
  """
632
- Core schema factory bridging configuration to ninja.orm.create_schema.
633
- Handles In/Patch/Out/Related.
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.
634
954
  """
635
955
  model = cls._get_model()
956
+ validators = cls._get_validators(schema_type)
636
957
 
637
958
  # Handle special schema types with custom logic
638
959
  if schema_type == "Out" or schema_type == "Detail":
@@ -642,7 +963,7 @@ class BaseSerializer:
642
963
  if not any([fields, reverse_rels, excludes, customs]):
643
964
  return None
644
965
  schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
645
- return create_schema(
966
+ schema = create_schema(
646
967
  model=model,
647
968
  name=f"{model._meta.model_name}{schema_name}",
648
969
  depth=depth,
@@ -650,23 +971,27 @@ class BaseSerializer:
650
971
  custom_fields=reverse_rels + customs + optionals,
651
972
  exclude=excludes,
652
973
  )
974
+ return cls._apply_validators(schema, validators)
653
975
 
654
976
  if schema_type == "Related":
655
977
  fields, customs = cls.get_related_schema_data()
656
978
  if not fields and not customs:
657
979
  return None
658
- return create_schema(
980
+ schema = create_schema(
659
981
  model=model,
660
982
  name=f"{model._meta.model_name}SchemaRelated",
661
983
  fields=fields,
662
984
  custom_fields=customs,
663
985
  )
986
+ return cls._apply_validators(schema, validators)
664
987
 
665
988
  # Handle standard In/Patch schema types
666
989
  s_type = "create" if schema_type == "In" else "update"
667
990
  fields = cls.get_fields(s_type)
668
991
  optionals = cls.get_optional_fields(s_type)
669
- customs = cls.get_custom_fields(s_type) + optionals
992
+ customs = (
993
+ cls.get_custom_fields(s_type) + optionals + cls.get_inline_customs(s_type)
994
+ )
670
995
  excludes = cls.get_excluded_fields(s_type)
671
996
 
672
997
  # If no explicit fields and no excludes specified
@@ -686,29 +1011,32 @@ class BaseSerializer:
686
1011
  if not any([fields, customs, excludes]):
687
1012
  return None
688
1013
 
689
- return create_schema(
1014
+ schema = create_schema(
690
1015
  model=model,
691
1016
  name=f"{model._meta.model_name}Schema{schema_type}",
692
1017
  fields=fields,
693
1018
  custom_fields=customs,
694
1019
  exclude=excludes,
695
1020
  )
1021
+ return cls._apply_validators(schema, validators)
696
1022
 
697
1023
  @classmethod
698
1024
  def get_related_schema_data(cls):
699
1025
  """
700
1026
  Build field/custom lists for 'Related' schema, flattening non-relational fields.
1027
+
1028
+ Custom fields (both explicit and inline) are always included since they
1029
+ are computed/synthetic and not relation descriptors.
701
1030
  """
702
1031
  fields = cls.get_fields("read")
703
- custom_f = {
704
- name: (value, default)
705
- for name, value, default in cls.get_custom_fields("read")
706
- }
707
- _related_fields = []
1032
+ customs = cls.get_custom_fields("read") + cls.get_inline_customs("read")
708
1033
  model = cls._get_model()
709
- for f in fields + list(custom_f.keys()):
710
- field_obj = getattr(model, f)
711
- if not isinstance(
1034
+
1035
+ # Filter out relation fields from model fields
1036
+ non_relation_fields = []
1037
+ for f in fields:
1038
+ field_obj = getattr(model, f, None)
1039
+ if field_obj is None or not isinstance(
712
1040
  field_obj,
713
1041
  (
714
1042
  ManyToManyDescriptor,
@@ -718,38 +1046,88 @@ class BaseSerializer:
718
1046
  ForwardOneToOneDescriptor,
719
1047
  ),
720
1048
  ):
721
- _related_fields.append(f)
722
- if not _related_fields:
1049
+ non_relation_fields.append(f)
1050
+
1051
+ # No fields or customs means nothing to include
1052
+ if not non_relation_fields and not customs:
723
1053
  return None, None
724
- custom_related_fields = [
725
- (f, *custom_f[f]) for f in _related_fields if f in custom_f
726
- ]
727
- related_fields = [f for f in _related_fields if f not in custom_f]
728
- return related_fields, custom_related_fields
1054
+
1055
+ return non_relation_fields, customs
729
1056
 
730
1057
  @classmethod
731
1058
  def generate_read_s(cls, depth: int = 1) -> Schema:
732
- """Generate read (Out) schema."""
1059
+ """
1060
+ Generate the read (Out) schema for list responses.
1061
+
1062
+ Parameters
1063
+ ----------
1064
+ depth : int, optional
1065
+ Nesting depth for related models. Defaults to ``1``.
1066
+
1067
+ Returns
1068
+ -------
1069
+ Schema | None
1070
+ Generated Pydantic schema, or ``None`` if no read fields are configured.
1071
+ """
733
1072
  return cls._generate_model_schema("Out", depth)
734
1073
 
735
1074
  @classmethod
736
1075
  def generate_detail_s(cls, depth: int = 1) -> Schema:
737
- """Generate detail (single object Out) schema."""
1076
+ """
1077
+ Generate the detail (single-object) read schema.
1078
+
1079
+ Falls back to the standard read schema if no detail-specific
1080
+ configuration is defined.
1081
+
1082
+ Parameters
1083
+ ----------
1084
+ depth : int, optional
1085
+ Nesting depth for related models. Defaults to ``1``.
1086
+
1087
+ Returns
1088
+ -------
1089
+ Schema
1090
+ Generated Pydantic schema (never ``None``; falls back to read schema).
1091
+ """
738
1092
  return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
739
1093
 
740
1094
  @classmethod
741
1095
  def generate_create_s(cls) -> Schema:
742
- """Generate create (In) schema."""
1096
+ """
1097
+ Generate the create (In) schema for input validation.
1098
+
1099
+ Returns
1100
+ -------
1101
+ Schema | None
1102
+ Generated Pydantic schema, or ``None`` if no create fields are configured.
1103
+ """
743
1104
  return cls._generate_model_schema("In")
744
1105
 
745
1106
  @classmethod
746
1107
  def generate_update_s(cls) -> Schema:
747
- """Generate update (Patch) schema."""
1108
+ """
1109
+ Generate the update (Patch) schema for partial updates.
1110
+
1111
+ Returns
1112
+ -------
1113
+ Schema | None
1114
+ Generated Pydantic schema, or ``None`` if no update fields are configured.
1115
+ """
748
1116
  return cls._generate_model_schema("Patch")
749
1117
 
750
1118
  @classmethod
751
1119
  def generate_related_s(cls) -> Schema:
752
- """Generate related (nested) schema."""
1120
+ """
1121
+ Generate the related (nested) schema for embedding in parent schemas.
1122
+
1123
+ Includes only non-relational model fields and custom fields, preventing
1124
+ infinite nesting of related objects.
1125
+
1126
+ Returns
1127
+ -------
1128
+ Schema | None
1129
+ Generated Pydantic schema, or ``None`` if no fields are configured.
1130
+ """
753
1131
  return cls._generate_model_schema("Related")
754
1132
 
755
1133
  @classmethod
@@ -810,9 +1188,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
810
1188
  Disallowed model fields on create (e.g., id, timestamps).
811
1189
  """
812
1190
 
813
- fields: list[str] = []
814
- customs: list[tuple[str, type, Any]] = []
815
- optionals: list[tuple[str, type]] = []
1191
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1192
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
1193
+ optionals: list[tuple[str, Any]] = []
816
1194
  excludes: list[str] = []
817
1195
 
818
1196
  class ReadSerializer:
@@ -832,9 +1210,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
832
1210
  Relation fields to serialize as IDs instead of nested objects.
833
1211
  """
834
1212
 
835
- fields: list[str] = []
836
- customs: list[tuple[str, type, Any]] = []
837
- optionals: list[tuple[str, type]] = []
1213
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1214
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
1215
+ optionals: list[tuple[str, Any]] = []
838
1216
  excludes: list[str] = []
839
1217
  relations_as_id: list[str] = []
840
1218
 
@@ -853,9 +1231,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
853
1231
  Optional output fields.
854
1232
  """
855
1233
 
856
- fields: list[str] = []
857
- customs: list[tuple[str, type, Any]] = []
858
- optionals: list[tuple[str, type]] = []
1234
+ fields: list[str | tuple[str, Any, Any] | tuple[str, Any]] = []
1235
+ customs: list[tuple[str, Any, Any] | tuple[str, Any]] = []
1236
+ optionals: list[tuple[str, Any]] = []
859
1237
  excludes: list[str] = []
860
1238
 
861
1239
  class UpdateSerializer:
@@ -873,9 +1251,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
873
1251
  Immutable / blocked fields.
874
1252
  """
875
1253
 
876
- fields: list[str] = []
877
- customs: list[tuple[str, type, Any]] = []
878
- optionals: list[tuple[str, type]] = []
1254
+ fields: list[str | tuple[str, Any, Any]] = []
1255
+ customs: list[tuple[str, Any, Any]] = []
1256
+ optionals: list[tuple[str, Any]] = []
879
1257
  excludes: list[str] = []
880
1258
 
881
1259
  # Serializer type to configuration class mapping
@@ -886,9 +1264,47 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
886
1264
  "detail": "DetailSerializer",
887
1265
  }
888
1266
 
1267
+ # Schema type to serializer type mapping for validator resolution
1268
+ _SCHEMA_TO_S_TYPE = {
1269
+ "In": "create",
1270
+ "Patch": "update",
1271
+ "Out": "read",
1272
+ "Detail": "detail",
1273
+ "Related": "read",
1274
+ }
1275
+
1276
+ @classmethod
1277
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
1278
+ """
1279
+ Collect validators from the inner serializer class for the given schema type.
1280
+
1281
+ Parameters
1282
+ ----------
1283
+ schema_type : SCHEMA_TYPES
1284
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1285
+
1286
+ Returns
1287
+ -------
1288
+ dict
1289
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
1290
+ """
1291
+ s_type = cls._SCHEMA_TO_S_TYPE.get(schema_type)
1292
+ config_name = cls._SERIALIZER_CONFIG_MAP.get(s_type)
1293
+ config_class = getattr(cls, config_name, None) if config_name else None
1294
+ return cls._collect_validators(config_class)
1295
+
889
1296
  @classmethod
890
1297
  def _get_relations_as_id(cls) -> list[str]:
891
- """Return relation fields to serialize as IDs instead of nested objects."""
1298
+ """
1299
+ Return relation fields to serialize as primary key values.
1300
+
1301
+ Reads the ``relations_as_id`` attribute from ``ReadSerializer``.
1302
+
1303
+ Returns
1304
+ -------
1305
+ list[str]
1306
+ Field names whose related objects should be serialized as IDs.
1307
+ """
892
1308
  return getattr(cls.ReadSerializer, "relations_as_id", [])
893
1309
 
894
1310
  @classmethod
@@ -919,17 +1335,30 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
919
1335
 
920
1336
  @classmethod
921
1337
  def _get_model(cls) -> "ModelSerializer":
922
- """Return the model class itself."""
1338
+ """
1339
+ Return the model class itself.
1340
+
1341
+ Since ``ModelSerializer`` is mixed directly into the model, the model
1342
+ class is the serializer class.
1343
+
1344
+ Returns
1345
+ -------
1346
+ ModelSerializer
1347
+ The model/serializer class.
1348
+ """
923
1349
  return cls
924
1350
 
925
1351
  @classmethod
926
1352
  def verbose_name_path_resolver(cls) -> str:
927
1353
  """
928
- Slugify plural verbose name for URL path segment.
1354
+ Slugify the plural verbose name for use as a URL path segment.
1355
+
1356
+ Replaces spaces with hyphens in the model's ``verbose_name_plural``.
929
1357
 
930
1358
  Returns
931
1359
  -------
932
1360
  str
1361
+ Hyphen-separated URL-safe path segment.
933
1362
  """
934
1363
  return "-".join(cls._meta.verbose_name_plural.split(" "))
935
1364
 
@@ -1044,20 +1473,24 @@ class SchemaModelConfig(Schema):
1044
1473
  Configuration container for declarative schema definitions.
1045
1474
  Attributes
1046
1475
  ----------
1047
- fields : Optional[List[str]]
1048
- Explicit model fields to include.
1476
+ fields : Optional[List[str | tuple]]
1477
+ Explicit model fields to include. Can also contain inline custom field tuples:
1478
+ - 2-tuple: (name, type) - required field
1479
+ - 3-tuple: (name, type, default) - optional field with default
1049
1480
  optionals : Optional[List[tuple[str, Any]]]
1050
1481
  Optional model fields. Type can be any valid type annotation including Union.
1051
1482
  exclude : Optional[List[str]]
1052
1483
  Model fields to exclude.
1053
- customs : Optional[List[tuple[str, Any, Any]]]
1484
+ customs : Optional[List[tuple[str, Any, Any] | tuple[str, Any]]]
1054
1485
  Custom / synthetic fields. Type can be any valid type annotation including Union.
1486
+ - 2-tuple: (name, type) - required field
1487
+ - 3-tuple: (name, type, default) - optional field with default
1055
1488
  """
1056
1489
 
1057
- fields: Optional[List[str]] = None
1490
+ fields: Optional[List[str | tuple[str, Any, Any] | tuple[str, Any]]] = None
1058
1491
  optionals: Optional[List[tuple[str, Any]]] = None
1059
1492
  exclude: Optional[List[str]] = None
1060
- customs: Optional[List[tuple[str, Any, Any]]] = None
1493
+ customs: Optional[List[tuple[str, Any, Any] | tuple[str, Any]]] = None
1061
1494
 
1062
1495
 
1063
1496
  class Serializer(BaseSerializer, metaclass=SerializerMeta):
@@ -1078,6 +1511,15 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1078
1511
  "detail": "detail",
1079
1512
  }
1080
1513
 
1514
+ # Schema type to validators inner class mapping
1515
+ _VALIDATORS_CLASS_MAP = {
1516
+ "In": "CreateValidators",
1517
+ "Patch": "UpdateValidators",
1518
+ "Out": "ReadValidators",
1519
+ "Detail": "DetailValidators",
1520
+ "Related": "ReadValidators",
1521
+ }
1522
+
1081
1523
  def __init_subclass__(cls, **kwargs):
1082
1524
  super().__init_subclass__(**kwargs)
1083
1525
  from ninja_aio.models.utils import ModelUtil
@@ -1097,26 +1539,118 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1097
1539
  relations_serializers: dict[str, "Serializer"] = {}
1098
1540
  relations_as_id: list[str] = []
1099
1541
 
1542
+ def _parse_payload(self, payload: dict[str, Any] | Schema) -> dict[str, Any]:
1543
+ """
1544
+ Parse and return the input payload.
1545
+
1546
+ Can be overridden to implement custom parsing logic.
1547
+
1548
+ Parameters
1549
+ ----------
1550
+ payload : dict | Schema
1551
+ Input data.
1552
+
1553
+ Returns
1554
+ -------
1555
+ dict
1556
+ Parsed payload.
1557
+ """
1558
+ return payload.model_dump() if isinstance(payload, Schema) else payload
1559
+
1560
+ @classmethod
1561
+ def _get_validators(cls, schema_type: type[SCHEMA_TYPES]) -> dict:
1562
+ """
1563
+ Collect validators from the inner validators class for the given schema type.
1564
+
1565
+ Looks for inner classes named ``CreateValidators``, ``ReadValidators``,
1566
+ ``UpdateValidators``, or ``DetailValidators`` on the serializer.
1567
+
1568
+ Parameters
1569
+ ----------
1570
+ schema_type : SCHEMA_TYPES
1571
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1572
+
1573
+ Returns
1574
+ -------
1575
+ dict
1576
+ Mapping of validator names to ``PydanticDescriptorProxy`` instances.
1577
+ """
1578
+ class_name = cls._VALIDATORS_CLASS_MAP.get(schema_type)
1579
+ validators_class = getattr(cls, class_name, None) if class_name else None
1580
+ return cls._collect_validators(validators_class)
1581
+
1100
1582
  @classmethod
1101
1583
  def _get_relations_as_id(cls) -> list[str]:
1584
+ """
1585
+ Return relation fields to serialize as primary key values.
1586
+
1587
+ Reads the ``relations_as_id`` attribute from ``Meta``.
1588
+
1589
+ Returns
1590
+ -------
1591
+ list[str]
1592
+ Field names whose related objects should be serialized as IDs.
1593
+ """
1102
1594
  relations_as_id = cls._get_meta_data("relations_as_id")
1103
1595
  return relations_as_id or []
1104
1596
 
1105
1597
  @classmethod
1106
1598
  def _get_meta_data(cls, attr_name: str) -> Any:
1599
+ """
1600
+ Retrieve an attribute from the nested ``Meta`` class.
1601
+
1602
+ Parameters
1603
+ ----------
1604
+ attr_name : str
1605
+ Name of the ``Meta`` attribute to look up.
1606
+
1607
+ Returns
1608
+ -------
1609
+ Any
1610
+ The attribute value, or ``None`` if not defined.
1611
+ """
1107
1612
  return getattr(cls.Meta, attr_name, None)
1108
1613
 
1109
1614
  @classmethod
1110
1615
  def _get_model(cls) -> models.Model:
1616
+ """
1617
+ Return the Django model class from ``Meta.model``.
1618
+
1619
+ Returns
1620
+ -------
1621
+ models.Model
1622
+ The validated Django model class.
1623
+ """
1111
1624
  return cls._validate_model()
1112
1625
 
1113
1626
  @classmethod
1114
1627
  def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
1628
+ """
1629
+ Return the explicit relation-to-serializer mapping from ``Meta``.
1630
+
1631
+ Returns
1632
+ -------
1633
+ dict[str, Serializer]
1634
+ Mapping of relation field names to serializer classes.
1635
+ """
1115
1636
  relations_serializers = cls._get_meta_data("relations_serializers")
1116
1637
  return relations_serializers or {}
1117
1638
 
1118
1639
  @classmethod
1119
1640
  def _get_schema_meta(cls, schema_type: str) -> SchemaModelConfig | None:
1641
+ """
1642
+ Retrieve the ``SchemaModelConfig`` for the given schema type.
1643
+
1644
+ Parameters
1645
+ ----------
1646
+ schema_type : str
1647
+ One of ``"in"``, ``"out"``, ``"update"``, or ``"detail"``.
1648
+
1649
+ Returns
1650
+ -------
1651
+ SchemaModelConfig | None
1652
+ The configuration object, or ``None`` if not defined.
1653
+ """
1120
1654
  match schema_type:
1121
1655
  case "in":
1122
1656
  return cls._get_meta_data("schema_in")
@@ -1131,6 +1665,19 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1131
1665
 
1132
1666
  @classmethod
1133
1667
  def _validate_model(cls):
1668
+ """
1669
+ Validate and return the model defined in ``Meta.model``.
1670
+
1671
+ Returns
1672
+ -------
1673
+ models.Model
1674
+ The validated Django model class.
1675
+
1676
+ Raises
1677
+ ------
1678
+ ValueError
1679
+ If ``Meta.model`` is not defined or is not a Django model subclass.
1680
+ """
1134
1681
  model = cls._get_meta_data("model")
1135
1682
  if not model:
1136
1683
  raise ValueError("Meta.model must be defined for Serializer.")
@@ -1140,7 +1687,23 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1140
1687
 
1141
1688
  @classmethod
1142
1689
  def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
1143
- """Internal accessor for raw configuration lists from Meta schemas."""
1690
+ """
1691
+ Return raw configuration list from the Meta schema for the given categories.
1692
+
1693
+ Falls back to the ``out`` schema when ``detail`` is requested but not defined.
1694
+
1695
+ Parameters
1696
+ ----------
1697
+ s_type : S_TYPES
1698
+ Serializer type (``"create"`` | ``"update"`` | ``"read"`` | ``"detail"``).
1699
+ f_type : F_TYPES
1700
+ Field category (``"fields"`` | ``"optionals"`` | ``"customs"`` | ``"excludes"``).
1701
+
1702
+ Returns
1703
+ -------
1704
+ list
1705
+ Raw configuration list, or empty list if not configured.
1706
+ """
1144
1707
  schema_key = cls._SCHEMA_META_MAP.get(s_type)
1145
1708
  if not schema_key:
1146
1709
  return []
@@ -1195,13 +1758,13 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1195
1758
  self.after_save(instance)
1196
1759
  return instance
1197
1760
 
1198
- async def create(self, payload: dict[str, Any]) -> models.Model:
1761
+ async def create(self, payload: dict[str, Any] | Schema) -> models.Model:
1199
1762
  """
1200
1763
  Create a new model instance from the provided payload.
1201
1764
 
1202
1765
  Parameters
1203
1766
  ----------
1204
- payload : dict
1767
+ payload : dict | Schema
1205
1768
  Input data.
1206
1769
 
1207
1770
  Returns
@@ -1209,11 +1772,11 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1209
1772
  models.Model
1210
1773
  Created model instance.
1211
1774
  """
1212
- instance: models.Model = self.model(**payload)
1775
+ instance: models.Model = self.model(**self._parse_payload(payload))
1213
1776
  return await self.save(instance)
1214
1777
 
1215
1778
  async def update(
1216
- self, instance: models.Model, payload: dict[str, Any]
1779
+ self, instance: models.Model, payload: dict[str, Any] | Schema
1217
1780
  ) -> models.Model:
1218
1781
  """
1219
1782
  Update an existing model instance with the provided payload.
@@ -1222,7 +1785,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1222
1785
  ----------
1223
1786
  instance : models.Model
1224
1787
  The model instance to update.
1225
- payload : dict
1788
+ payload : dict | Schema
1226
1789
  Input data.
1227
1790
 
1228
1791
  Returns
@@ -1230,7 +1793,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1230
1793
  models.Model
1231
1794
  Updated model instance.
1232
1795
  """
1233
- for attr, value in payload.items():
1796
+ for attr, value in self._parse_payload(payload).items():
1234
1797
  setattr(instance, attr, value)
1235
1798
  return await self.save(instance)
1236
1799