django-ninja-aio-crud 2.13.0__tar.gz → 2.14.0__tar.gz

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.

Files changed (104) hide show
  1. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.github/workflows/docs.yml +4 -0
  2. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/PKG-INFO +1 -1
  3. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/models/model_serializer.md +107 -5
  4. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/models/serializers.md +129 -0
  5. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/__init__.py +1 -1
  6. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/models/serializers.py +128 -13
  7. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_app/models.py +75 -0
  8. django_ninja_aio_crud-2.14.0/tests/test_app/serializers.py +118 -0
  9. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_serializers.py +313 -0
  10. django_ninja_aio_crud-2.13.0/tests/test_app/serializers.py +0 -47
  11. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.github/dependabot.yml +0 -0
  12. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.github/workflows/coverage.yml +0 -0
  13. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.github/workflows/publish.yml +0 -0
  14. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.gitignore +0 -0
  15. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/.pre-commit-config.yaml +0 -0
  16. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/LICENSE +0 -0
  17. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/README.md +0 -0
  18. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/CNAME +0 -0
  19. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/authentication.md +0 -0
  20. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/models/model_util.md +0 -0
  21. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/pagination.md +0 -0
  22. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/renderers/orjson_renderer.md +0 -0
  23. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/views/api_view.md +0 -0
  24. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/views/api_view_set.md +0 -0
  25. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/views/decorators.md +0 -0
  26. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/api/views/mixins.md +0 -0
  27. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/auth.md +0 -0
  28. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/contributing.md +0 -0
  29. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/extra.css +0 -0
  30. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  31. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  32. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  33. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  34. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  35. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  36. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/installation.md +0 -0
  37. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/getting_started/quick_start.md +0 -0
  38. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/images/bar-swagger.png +0 -0
  39. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/images/favicon.ico +0 -0
  40. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/images/foo-swagger.png +0 -0
  41. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/images/logo.png +0 -0
  42. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  43. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/index.md +0 -0
  44. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/release_notes.md +0 -0
  45. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/requirements.txt +0 -0
  46. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/tutorial/authentication.md +0 -0
  47. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/tutorial/crud.md +0 -0
  48. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/tutorial/filtering.md +0 -0
  49. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/docs/tutorial/model.md +0 -0
  50. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/main.py +0 -0
  51. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/mkdocs.yml +0 -0
  52. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/api.py +0 -0
  53. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/auth.py +0 -0
  54. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/decorators/__init__.py +0 -0
  55. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/decorators/operations.py +0 -0
  56. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/decorators/views.py +0 -0
  57. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/exceptions.py +0 -0
  58. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/factory/__init__.py +0 -0
  59. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/factory/operations.py +0 -0
  60. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/helpers/__init__.py +0 -0
  61. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/helpers/api.py +0 -0
  62. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/helpers/query.py +0 -0
  63. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/models/__init__.py +0 -0
  64. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/models/utils.py +0 -0
  65. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/parsers.py +0 -0
  66. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/renders.py +0 -0
  67. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/schemas/__init__.py +0 -0
  68. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/schemas/api.py +0 -0
  69. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/schemas/filters.py +0 -0
  70. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/schemas/generics.py +0 -0
  71. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/schemas/helpers.py +0 -0
  72. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/types.py +0 -0
  73. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/views/__init__.py +0 -0
  74. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/views/api.py +0 -0
  75. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/ninja_aio/views/mixins.py +0 -0
  76. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/pyproject.toml +0 -0
  77. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/requirements.dev.txt +0 -0
  78. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/run-local-coverage.sh +0 -0
  79. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/__init__.py +0 -0
  80. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/core/__init__.py +0 -0
  81. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/core/test_decorators.py +0 -0
  82. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/core/test_exceptions_api.py +0 -0
  83. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/core/test_renderer_parser.py +0 -0
  84. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/generics/__init__.py +0 -0
  85. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/generics/literals.py +0 -0
  86. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/generics/models.py +0 -0
  87. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/generics/request.py +0 -0
  88. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/generics/views.py +0 -0
  89. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/helpers/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/helpers/test_many_to_many_api.py +0 -0
  91. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/models/__init__.py +0 -0
  92. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/models/test_model_util.py +0 -0
  93. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/models/test_models_extra.py +0 -0
  94. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_app/__init__.py +0 -0
  95. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_app/schema.py +0 -0
  96. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_app/views.py +0 -0
  97. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_auth.py +0 -0
  98. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_decorators.py +0 -0
  99. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_exceptions.py +0 -0
  100. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_query_util.py +0 -0
  101. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/test_settings.py +0 -0
  102. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/views/__init__.py +0 -0
  103. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/views/test_views.py +0 -0
  104. {django_ninja_aio_crud-2.13.0 → django_ninja_aio_crud-2.14.0}/tests/views/test_viewset.py +0 -0
@@ -25,6 +25,8 @@ on:
25
25
  - "2.10"
26
26
  - "2.11"
27
27
  - "2.12"
28
+ - "2.13"
29
+ - "2.14"
28
30
  make_latest:
29
31
  description: 'Set as "latest" and default?'
30
32
  type: boolean
@@ -53,6 +55,8 @@ on:
53
55
  - "2.10"
54
56
  - "2.11"
55
57
  - "2.12"
58
+ - "2.13"
59
+ - "2.14"
56
60
  delete_confirm:
57
61
  description: 'Confirm deletion of the selected version'
58
62
  type: boolean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.13.0
3
+ Version: 2.14.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -100,11 +100,12 @@ Describes how to build a read (output) schema for a model.
100
100
 
101
101
  **Attributes**
102
102
 
103
- | Attribute | Type | Description |
104
- |-----------|-----------------|-------------|
105
- | `fields` | `list[str]` | **REQUIRED.** Model fields / related names explicitly included in the read (output) schema. |
106
- | `excludes` | `list[str]` | Fields / related names to always omit (takes precedence over `fields` and `optionals`). Use for sensitive or noisy data (e.g., passwords, internal flags). |
107
- | `customs` | `list[tuple]` | Computed / synthetic output values. Tuple formats:<br>• `(name, type)` = required resolvable attribute (object attribute or property). Serialization error if not resolvable.<br>• `(name, type, default)` = optional; default may be a callable (`lambda obj: ...`) or a literal value. |
103
+ | Attribute | Type | Description |
104
+ |------------------|-----------------|-------------|
105
+ | `fields` | `list[str]` | **REQUIRED.** Model fields / related names explicitly included in the read (output) schema. |
106
+ | `excludes` | `list[str]` | Fields / related names to always omit (takes precedence over `fields` and `optionals`). Use for sensitive or noisy data (e.g., passwords, internal flags). |
107
+ | `customs` | `list[tuple]` | Computed / synthetic output values. Tuple formats:<br>• `(name, type)` = required resolvable attribute (object attribute or property). Serialization error if not resolvable.<br>• `(name, type, default)` = optional; default may be a callable (`lambda obj: ...`) or a literal value. |
108
+ | `relations_as_id`| `list[str]` | Relation fields to serialize as IDs instead of nested objects. Works with forward FK, forward O2O, reverse FK, reverse O2O, and M2M relations. |
108
109
 
109
110
  **Example:**
110
111
 
@@ -405,6 +406,107 @@ class Book(ModelSerializer):
405
406
  }
406
407
  ```
407
408
 
409
+ #### Relations as ID
410
+
411
+ Use `relations_as_id` to serialize relation fields as IDs instead of nested objects. This is useful for:
412
+
413
+ - Reducing response payload size
414
+ - Avoiding circular serialization
415
+ - Performance optimization when nested data isn't needed
416
+ - API designs where clients fetch related data separately
417
+
418
+ **Supported Relations:**
419
+
420
+ | Relation Type | Output Type | Example Value |
421
+ |--------------------|-------------------|----------------------|
422
+ | Forward FK | `int \| None` | `5` or `null` |
423
+ | Forward O2O | `int \| None` | `3` or `null` |
424
+ | Reverse FK | `list[int]` | `[1, 2, 3]` |
425
+ | Reverse O2O | `int \| None` | `7` or `null` |
426
+ | M2M (forward) | `list[int]` | `[1, 2]` |
427
+ | M2M (reverse) | `list[int]` | `[4, 5, 6]` |
428
+
429
+ **Example:**
430
+
431
+ ```python
432
+ class Author(ModelSerializer):
433
+ name = models.CharField(max_length=200)
434
+
435
+ class ReadSerializer:
436
+ fields = ["id", "name", "books"]
437
+ relations_as_id = ["books"] # Serialize as list of IDs
438
+
439
+ class Book(ModelSerializer):
440
+ title = models.CharField(max_length=200)
441
+ author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
442
+
443
+ class ReadSerializer:
444
+ fields = ["id", "title", "author"]
445
+ relations_as_id = ["author"] # Serialize as ID
446
+ ```
447
+
448
+ **Output (Author):**
449
+
450
+ ```json
451
+ {
452
+ "id": 1,
453
+ "name": "J.K. Rowling",
454
+ "books": [1, 2, 3]
455
+ }
456
+ ```
457
+
458
+ **Output (Book):**
459
+
460
+ ```json
461
+ {
462
+ "id": 1,
463
+ "title": "Harry Potter",
464
+ "author": 1
465
+ }
466
+ ```
467
+
468
+ **M2M Example:**
469
+
470
+ ```python
471
+ class Tag(ModelSerializer):
472
+ name = models.CharField(max_length=50)
473
+
474
+ class ReadSerializer:
475
+ fields = ["id", "name", "articles"]
476
+ relations_as_id = ["articles"] # Reverse M2M as IDs
477
+
478
+ class Article(ModelSerializer):
479
+ title = models.CharField(max_length=200)
480
+ tags = models.ManyToManyField(Tag, related_name="articles")
481
+
482
+ class ReadSerializer:
483
+ fields = ["id", "title", "tags"]
484
+ relations_as_id = ["tags"] # Forward M2M as IDs
485
+ ```
486
+
487
+ **Output (Article):**
488
+
489
+ ```json
490
+ {
491
+ "id": 1,
492
+ "title": "Getting Started with Django",
493
+ "tags": [1, 2, 5]
494
+ }
495
+ ```
496
+
497
+ **Query Optimization Note:** When using `relations_as_id`, you should still use `select_related()` for forward relations and `prefetch_related()` for reverse/M2M relations to avoid N+1 queries:
498
+
499
+ ```python
500
+ class Article(ModelSerializer):
501
+ # ...
502
+
503
+ class QuerySet:
504
+ read = ModelQuerySetSchema(
505
+ select_related=["author"], # For forward FK
506
+ prefetch_related=["tags"], # For M2M
507
+ )
508
+ ```
509
+
408
510
  ## Async Extension Points
409
511
 
410
512
  ### `queryset_request(request)`
@@ -41,6 +41,7 @@ Define a Serializer subclass with a nested Meta:
41
41
  - **schema_detail**: SchemaModelConfig for detail outputs (retrieve endpoint)
42
42
  - **schema_update**: SchemaModelConfig for patch/update inputs
43
43
  - **relations_serializers**: Mapping of relation field name -> Serializer class, **string reference**, or **Union of serializers** (supports forward/circular dependencies and polymorphic relations)
44
+ - **relations_as_id**: List of relation field names to serialize as IDs instead of nested objects
44
45
 
45
46
  SchemaModelConfig fields:
46
47
 
@@ -415,6 +416,134 @@ Notes:
415
416
  - Union types are resolved lazily, so forward and circular references work seamlessly.
416
417
  - The schema generator will create a union of all possible schemas from the serializers in the Union.
417
418
 
419
+ ## Relations as ID
420
+
421
+ Use `relations_as_id` in Meta to serialize relation fields as IDs instead of nested objects. This is useful for:
422
+
423
+ - Reducing response payload size
424
+ - Avoiding circular serialization
425
+ - Performance optimization when nested data isn't needed
426
+ - API designs where clients fetch related data separately
427
+
428
+ **Supported Relations:**
429
+
430
+ | Relation Type | Output Type | Example Value |
431
+ |--------------------|-------------------|----------------------|
432
+ | Forward FK | `int \| None` | `5` or `null` |
433
+ | Forward O2O | `int \| None` | `3` or `null` |
434
+ | Reverse FK | `list[int]` | `[1, 2, 3]` |
435
+ | Reverse O2O | `int \| None` | `7` or `null` |
436
+ | M2M (forward) | `list[int]` | `[1, 2]` |
437
+ | M2M (reverse) | `list[int]` | `[4, 5, 6]` |
438
+
439
+ **Example:**
440
+
441
+ ```python
442
+ from ninja_aio.models import serializers
443
+ from . import models
444
+
445
+ class AuthorSerializer(serializers.Serializer):
446
+ class Meta:
447
+ model = models.Author
448
+ schema_out = serializers.SchemaModelConfig(
449
+ fields=["id", "name", "books"]
450
+ )
451
+ relations_as_id = ["books"] # Serialize reverse FK as list of IDs
452
+
453
+ class BookSerializer(serializers.Serializer):
454
+ class Meta:
455
+ model = models.Book
456
+ schema_out = serializers.SchemaModelConfig(
457
+ fields=["id", "title", "author"]
458
+ )
459
+ relations_as_id = ["author"] # Serialize forward FK as ID
460
+ ```
461
+
462
+ **Output (Author):**
463
+
464
+ ```json
465
+ {
466
+ "id": 1,
467
+ "name": "J.K. Rowling",
468
+ "books": [1, 2, 3]
469
+ }
470
+ ```
471
+
472
+ **Output (Book):**
473
+
474
+ ```json
475
+ {
476
+ "id": 1,
477
+ "title": "Harry Potter",
478
+ "author": 1
479
+ }
480
+ ```
481
+
482
+ **M2M Example:**
483
+
484
+ ```python
485
+ class TagSerializer(serializers.Serializer):
486
+ class Meta:
487
+ model = models.Tag
488
+ schema_out = serializers.SchemaModelConfig(
489
+ fields=["id", "name", "articles"]
490
+ )
491
+ relations_as_id = ["articles"] # Reverse M2M as list of IDs
492
+
493
+ class ArticleSerializer(serializers.Serializer):
494
+ class Meta:
495
+ model = models.Article
496
+ schema_out = serializers.SchemaModelConfig(
497
+ fields=["id", "title", "tags"]
498
+ )
499
+ relations_as_id = ["tags"] # Forward M2M as list of IDs
500
+ ```
501
+
502
+ **Output (Article):**
503
+
504
+ ```json
505
+ {
506
+ "id": 1,
507
+ "title": "Getting Started with Django",
508
+ "tags": [1, 2, 5]
509
+ }
510
+ ```
511
+
512
+ **Combining with relations_serializers:**
513
+
514
+ You can use both `relations_as_id` and `relations_serializers` in the same serializer. Fields in `relations_as_id` take precedence:
515
+
516
+ ```python
517
+ class ArticleSerializer(serializers.Serializer):
518
+ class Meta:
519
+ model = models.Article
520
+ schema_out = serializers.SchemaModelConfig(
521
+ fields=["id", "title", "author", "tags", "category"]
522
+ )
523
+ relations_serializers = {
524
+ "author": AuthorSerializer, # Nested object
525
+ "category": CategorySerializer, # Nested object
526
+ }
527
+ relations_as_id = ["tags"] # Just IDs
528
+ ```
529
+
530
+ **Query Optimization Note:** When using `relations_as_id`, you should still use `select_related()` for forward relations and `prefetch_related()` for reverse/M2M relations to avoid N+1 queries:
531
+
532
+ ```python
533
+ class ArticleSerializer(serializers.Serializer):
534
+ class Meta:
535
+ model = models.Article
536
+ schema_out = serializers.SchemaModelConfig(
537
+ fields=["id", "title", "author", "tags"]
538
+ )
539
+ relations_as_id = ["author", "tags"]
540
+
541
+ class QuerySet:
542
+ read = ModelQuerySetSchema(
543
+ select_related=["author"], # For forward FK
544
+ prefetch_related=["tags"], # For M2M
545
+ )
546
+
418
547
  ## Using with APIViewSet
419
548
 
420
549
  You can attach a Serializer to an APIViewSet to auto-generate schemas and leverage queryset_request when present:
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.13.0"
3
+ __version__ = "2.14.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -1,4 +1,14 @@
1
- from typing import Any, List, Literal, Optional, Union, get_args, get_origin, ForwardRef
1
+ from typing import (
2
+ Annotated,
3
+ Any,
4
+ List,
5
+ Literal,
6
+ Optional,
7
+ Union,
8
+ get_args,
9
+ get_origin,
10
+ ForwardRef,
11
+ )
2
12
  import warnings
3
13
  import sys
4
14
 
@@ -14,6 +24,7 @@ from django.db.models.fields.related_descriptors import (
14
24
  ForwardManyToOneDescriptor,
15
25
  ForwardOneToOneDescriptor,
16
26
  )
27
+ from pydantic import BeforeValidator, Field
17
28
 
18
29
  from ninja_aio.types import (
19
30
  S_TYPES,
@@ -28,6 +39,17 @@ from ninja_aio.schemas.helpers import (
28
39
  )
29
40
 
30
41
 
42
+ def _extract_pk(v: Any) -> Any:
43
+ """Extract primary key from a model instance or return value as-is."""
44
+ if hasattr(v, "pk"):
45
+ return v.pk
46
+ return v
47
+
48
+
49
+ # Annotated type for extracting PK from model instances during serialization
50
+ PkFromModel = Annotated[int, BeforeValidator(_extract_pk)]
51
+
52
+
31
53
  class BaseSerializer:
32
54
  """
33
55
  BaseSerializer
@@ -212,6 +234,11 @@ class BaseSerializer:
212
234
  # Optional in subclasses. Default to no explicit relation serializers.
213
235
  return {}
214
236
 
237
+ @classmethod
238
+ def _get_relations_as_id(cls) -> list[str]:
239
+ # Optional in subclasses. Default to no relations as ID.
240
+ return []
241
+
215
242
  @classmethod
216
243
  def _generate_union_schema(cls, resolved_union: Any) -> Any:
217
244
  """
@@ -350,10 +377,25 @@ class BaseSerializer:
350
377
  ) or cls._is_special_field("update", field, "optionals")
351
378
 
352
379
  @classmethod
353
- def _build_schema_reverse_rel(cls, field_name: str, descriptor: Any):
380
+ def _build_schema_reverse_rel(
381
+ cls, field_name: str, descriptor: Any, relations_as_id: list[str]
382
+ ):
354
383
  """
355
384
  Build a reverse relation schema component for 'Out' schema generation.
356
- Returns a custom field tuple or None to skip.
385
+
386
+ Parameters
387
+ ----------
388
+ field_name : str
389
+ Name of the relation field.
390
+ descriptor : Any
391
+ Django field descriptor (ManyToManyDescriptor, ReverseManyToOneDescriptor, etc.).
392
+ relations_as_id : list[str]
393
+ Pre-fetched list of fields to serialize as IDs.
394
+
395
+ Returns
396
+ -------
397
+ tuple | None
398
+ Custom field tuple for schema generation, or None to skip.
357
399
  """
358
400
  # Resolve related model and cardinality
359
401
  if isinstance(descriptor, ManyToManyDescriptor):
@@ -371,6 +413,15 @@ class BaseSerializer:
371
413
  rel_model = descriptor.related.related_model
372
414
  many = False
373
415
 
416
+ # Handle relations_as_id for reverse relations
417
+ if field_name in relations_as_id:
418
+ if many:
419
+ # For many relations, use PkFromModel to extract PKs from model instances
420
+ return (field_name, list[PkFromModel], Field(default_factory=list))
421
+ else:
422
+ # For single reverse relations (ReverseOneToOne), extract pk
423
+ return (field_name, PkFromModel | None, None)
424
+
374
425
  schema = cls._resolve_relation_schema(field_name, rel_model)
375
426
  if not schema:
376
427
  return None
@@ -379,14 +430,34 @@ class BaseSerializer:
379
430
  return (field_name, rel_schema_type | None, None)
380
431
 
381
432
  @classmethod
382
- def _build_schema_forward_rel(cls, field_name: str, descriptor: Any):
433
+ def _build_schema_forward_rel(
434
+ cls, field_name: str, descriptor: Any, relations_as_id: list[str]
435
+ ):
383
436
  """
384
437
  Build a forward relation schema component for 'Out' schema generation.
385
- Returns True to treat as plain field, a custom field tuple to include relation schema,
386
- or None to skip entirely.
438
+
439
+ Parameters
440
+ ----------
441
+ field_name : str
442
+ Name of the relation field.
443
+ descriptor : Any
444
+ Django field descriptor (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor).
445
+ relations_as_id : list[str]
446
+ Pre-fetched list of fields to serialize as IDs.
447
+
448
+ Returns
449
+ -------
450
+ True | tuple | None
451
+ True to treat as plain field, a custom field tuple to include relation schema,
452
+ or None to skip entirely.
387
453
  """
388
454
  rel_model = descriptor.field.related_model
389
455
 
456
+ # Handle relations_as_id: serialize as the raw FK ID
457
+ if field_name in relations_as_id:
458
+ # Use PkFromModel to extract pk from the related instance during serialization
459
+ return (field_name, PkFromModel | None, None)
460
+
390
461
  # Special case: ModelSerializer with no readable fields should be skipped entirely
391
462
  if isinstance(rel_model, ModelSerializerMeta):
392
463
  if not (
@@ -441,23 +512,44 @@ class BaseSerializer:
441
512
  field_name: str,
442
513
  model,
443
514
  relations_serializers: dict,
515
+ relations_as_id: list[str],
444
516
  ) -> tuple[str | None, tuple | None, tuple | None]:
445
517
  """
446
518
  Process a single field and determine its classification.
447
519
 
448
- Returns:
520
+ Parameters
521
+ ----------
522
+ field_name : str
523
+ Name of the field to process.
524
+ model : Model
525
+ Django model class.
526
+ relations_serializers : dict
527
+ Mapping of relation field names to serializer classes.
528
+ relations_as_id : list[str]
529
+ Pre-fetched list of fields to serialize as IDs.
530
+
531
+ Returns
532
+ -------
533
+ tuple
449
534
  (plain_field, reverse_rel, forward_rel) - only one will be non-None
450
535
  """
451
536
  field_obj = getattr(model, field_name)
452
537
 
453
538
  if cls._is_reverse_relation(field_obj):
454
- if field_name not in relations_serializers:
539
+ if (
540
+ field_name not in relations_serializers
541
+ and field_name not in relations_as_id
542
+ ):
455
543
  cls._warn_missing_relation_serializer(field_name, model)
456
- rel_tuple = cls._build_schema_reverse_rel(field_name, field_obj)
544
+ rel_tuple = cls._build_schema_reverse_rel(
545
+ field_name, field_obj, relations_as_id
546
+ )
457
547
  return (None, rel_tuple, None)
458
548
 
459
549
  if cls._is_forward_relation(field_obj):
460
- rel_tuple = cls._build_schema_forward_rel(field_name, field_obj)
550
+ rel_tuple = cls._build_schema_forward_rel(
551
+ field_name, field_obj, relations_as_id
552
+ )
461
553
  if rel_tuple is True:
462
554
  return (field_name, None, None)
463
555
  return (None, None, rel_tuple)
@@ -469,8 +561,15 @@ class BaseSerializer:
469
561
  """
470
562
  Collect components for output schema generation (Out or Detail).
471
563
 
472
- Returns:
473
- tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
564
+ Parameters
565
+ ----------
566
+ schema_type : Literal["Out", "Detail"]
567
+ Type of schema to generate.
568
+
569
+ Returns
570
+ -------
571
+ tuple
572
+ (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
474
573
  """
475
574
  if schema_type not in ("Out", "Detail"):
476
575
  raise ValueError(
@@ -480,6 +579,8 @@ class BaseSerializer:
480
579
  fields_type = "read" if schema_type == "Out" else "detail"
481
580
  model = cls._get_model()
482
581
  relations_serializers = cls._get_relations_serializers() or {}
582
+ # Fetch once to avoid repeated method calls during field processing
583
+ relations_as_id = cls._get_relations_as_id()
483
584
 
484
585
  fields: list[str] = []
485
586
  reverse_rels: list[tuple] = []
@@ -487,7 +588,7 @@ class BaseSerializer:
487
588
 
488
589
  for field_name in cls.get_fields(fields_type):
489
590
  plain, reverse, forward = cls._process_field(
490
- field_name, model, relations_serializers
591
+ field_name, model, relations_serializers, relations_as_id
491
592
  )
492
593
  if plain:
493
594
  fields.append(plain)
@@ -701,12 +802,15 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
701
802
  Computed / synthetic output attributes.
702
803
  optionals : list[tuple[str, type]]
703
804
  Optional output fields.
805
+ relations_as_id : list[str]
806
+ Relation fields to serialize as IDs instead of nested objects.
704
807
  """
705
808
 
706
809
  fields: list[str] = []
707
810
  customs: list[tuple[str, type, Any]] = []
708
811
  optionals: list[tuple[str, type]] = []
709
812
  excludes: list[str] = []
813
+ relations_as_id: list[str] = []
710
814
 
711
815
  class DetailSerializer:
712
816
  """Configuration describing detail (single object) read schema.
@@ -756,6 +860,11 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
756
860
  "detail": "DetailSerializer",
757
861
  }
758
862
 
863
+ @classmethod
864
+ def _get_relations_as_id(cls) -> list[str]:
865
+ """Return relation fields to serialize as IDs instead of nested objects."""
866
+ return getattr(cls.ReadSerializer, "relations_as_id", [])
867
+
759
868
  @classmethod
760
869
  def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
761
870
  """
@@ -960,6 +1069,12 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
960
1069
  schema_update: Optional[SchemaModelConfig] = None
961
1070
  schema_detail: Optional[SchemaModelConfig] = None
962
1071
  relations_serializers: dict[str, "Serializer"] = {}
1072
+ relations_as_id: list[str] = []
1073
+
1074
+ @classmethod
1075
+ def _get_relations_as_id(cls) -> list[str]:
1076
+ relations_as_id = cls._get_meta_data("relations_as_id")
1077
+ return relations_as_id or []
963
1078
 
964
1079
  @classmethod
965
1080
  def _get_meta_data(cls, attr_name: str) -> Any:
@@ -223,3 +223,78 @@ class TestModelSerializerWithBothSerializers(BaseTestModelSerializer):
223
223
  class DetailSerializer:
224
224
  fields = ["id", "name", "description"]
225
225
  # No customs defined - should NOT inherit from read
226
+
227
+
228
+ # ==========================================================
229
+ # RELATIONS AS ID TEST MODELS
230
+ # ==========================================================
231
+
232
+
233
+ class AuthorAsId(BaseTestModelSerializer):
234
+ """Author model for testing relations_as_id on reverse FK."""
235
+
236
+ class ReadSerializer:
237
+ fields = ["id", "name", "description", "books_as_id"]
238
+ relations_as_id = ["books_as_id"]
239
+
240
+
241
+ class BookAsId(BaseTestModelSerializer):
242
+ """Book model for testing relations_as_id on forward FK."""
243
+
244
+ author_as_id = models.ForeignKey(
245
+ AuthorAsId,
246
+ on_delete=models.CASCADE,
247
+ related_name="books_as_id",
248
+ null=True,
249
+ blank=True,
250
+ )
251
+
252
+ class ReadSerializer:
253
+ fields = ["id", "name", "description", "author_as_id"]
254
+ relations_as_id = ["author_as_id"]
255
+
256
+
257
+ class ProfileAsId(BaseTestModelSerializer):
258
+ """Profile model for testing relations_as_id on reverse O2O."""
259
+
260
+ class ReadSerializer:
261
+ fields = ["id", "name", "description", "user_profile_as_id"]
262
+ relations_as_id = ["user_profile_as_id"]
263
+
264
+
265
+ class UserAsId(BaseTestModelSerializer):
266
+ """User model for testing relations_as_id on forward O2O."""
267
+
268
+ profile_as_id = models.OneToOneField(
269
+ ProfileAsId,
270
+ on_delete=models.CASCADE,
271
+ related_name="user_profile_as_id",
272
+ null=True,
273
+ blank=True,
274
+ )
275
+
276
+ class ReadSerializer:
277
+ fields = ["id", "name", "description", "profile_as_id"]
278
+ relations_as_id = ["profile_as_id"]
279
+
280
+
281
+ class TagAsId(BaseTestModelSerializer):
282
+ """Tag model for testing relations_as_id on reverse M2M."""
283
+
284
+ class ReadSerializer:
285
+ fields = ["id", "name", "description", "articles_as_id"]
286
+ relations_as_id = ["articles_as_id"]
287
+
288
+
289
+ class ArticleAsId(BaseTestModelSerializer):
290
+ """Article model for testing relations_as_id on forward M2M."""
291
+
292
+ tags_as_id = models.ManyToManyField(
293
+ TagAsId,
294
+ related_name="articles_as_id",
295
+ blank=True,
296
+ )
297
+
298
+ class ReadSerializer:
299
+ fields = ["id", "name", "description", "tags_as_id"]
300
+ relations_as_id = ["tags_as_id"]