From 2b3b9e62e7fa2e36067ab2df72060996dec801a2 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Mon, 31 Aug 2026 19:15:21 +0200 Subject: [PATCH] perf(api): Prefetch reverse many-to-many serializer fields (#23064) Recognize Django's ManyToManyRel in get_prefetches_for_serializer(). Because it is a sibling of ManyToOneRel rather than a subclass, reverse many-to-many accessors were omitted from the generated prefetch paths and fetched once per serialized object. Add regression coverage for both automatically generated fields and SerializedPKRelatedField(many=True), and regenerate the affected ASN and ObjectPermission API query-count baselines. Fixes #23060 --- netbox/ipam/tests/query_counts.json | 2 +- netbox/users/tests/query_counts.json | 2 +- netbox/utilities/api.py | 4 +-- netbox/utilities/tests/test_api.py | 37 +++++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/tests/query_counts.json b/netbox/ipam/tests/query_counts.json index b9e5b6752..29be74283 100644 --- a/netbox/ipam/tests/query_counts.json +++ b/netbox/ipam/tests/query_counts.json @@ -1,7 +1,7 @@ { "aggregate:api_list_objects": 13, "aggregate:list_objects_with_permission": 21, - "asn:api_list_objects": 17, + "asn:api_list_objects": 16, "asn:list_objects_with_permission": 28, "asnrange:api_list_objects": 14, "asnrange:list_objects_with_permission": 19, diff --git a/netbox/users/tests/query_counts.json b/netbox/users/tests/query_counts.json index cf625fca0..87b464bed 100644 --- a/netbox/users/tests/query_counts.json +++ b/netbox/users/tests/query_counts.json @@ -1,7 +1,7 @@ { "group:api_list_objects": 10, "group:list_objects_with_permission": 16, - "objectpermission:api_list_objects": 14, + "objectpermission:api_list_objects": 12, "objectpermission:list_objects_with_permission": 17, "owner:api_list_objects": 11, "owner:list_objects_with_permission": 18, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 8acf6e567..7f902b502 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -8,7 +8,7 @@ from django.core.exceptions import ( ObjectDoesNotExist, ValidationError, ) -from django.db.models.fields.related import ManyToOneRel, RelatedField +from django.db.models.fields.related import ManyToManyRel, ManyToOneRel, RelatedField from django.urls import reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ @@ -193,7 +193,7 @@ def get_prefetches_for_serializer(serializer_class, fields=None, omit=None, _ser # If the serializer field does not map to a discrete model field, skip it. try: field = model._meta.get_field(model_field_name) - if isinstance(field, (RelatedField, ManyToOneRel, GenericForeignKey)): + if isinstance(field, (RelatedField, ManyToOneRel, ManyToManyRel, GenericForeignKey)): prefetch_fields.append(field.name) except FieldDoesNotExist: continue diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 4c6e3de17..6ed5d9025 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -16,7 +16,7 @@ from netbox.api.serializers import BaseModelSerializer from netbox.config import get_config from netbox.plugins import register_serializer_resolver from netbox.registry import registry -from users.models import ObjectPermission +from users.models import Group, ObjectPermission from utilities.api import ( get_prefetches_for_serializer, get_serializer_for_model, @@ -754,6 +754,41 @@ class GetPrefetchesForSerializerTestCase(TestCase): ['parent', 'parent__sites', 'children', 'children__sites'], ) + def test_reverse_many_to_many_relation_is_prefetched(self): + class ObjectPermissionSerializer(BaseModelSerializer): + class Meta: + model = ObjectPermission + fields = ('groups',) + + self.assertListEqual( + get_prefetches_for_serializer(ObjectPermissionSerializer), + ['groups'], + ) + + def test_reverse_many_to_many_serialized_related_field_is_prefetched(self): + class GroupSerializer(BaseModelSerializer): + class Meta: + model = Group + fields = ('id', 'name') + + class ObjectPermissionSerializer(BaseModelSerializer): + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=GroupSerializer, + nested=True, + required=False, + many=True + ) + + class Meta: + model = ObjectPermission + fields = ('id', 'groups') + + self.assertListEqual( + get_prefetches_for_serializer(ObjectPermissionSerializer), + ['groups'], + ) + class _ResolvedSerializerA(Serializer): pass