Parcourir la source

Add prefetch hint for GraphQL tags on list endpoints (#22570)

GraphQL list queries that request tags were issuing one tag lookup query
per object (N+1). TagsMixin declared the field without a prefetch hint;
django-taggit's M2M is not batched by DjangoOptimizerExtension the way
GenericRelations are. Add prefetch_related=['tags'] on the mixin field,
following the pattern from #22061 for journal entries and image
attachments.

Closes #22551
jkburges il y a 5 jours
Parent
commit
614d50a564
2 fichiers modifiés avec 62 ajouts et 1 suppressions
  1. 3 1
      netbox/extras/graphql/mixins.py
  2. 59 0
      netbox/netbox/tests/test_graphql.py

+ 3 - 1
netbox/extras/graphql/mixins.py

@@ -77,7 +77,9 @@ class JournalEntriesMixin:
 @strawberry.type
 class TagsMixin:
 
-    tags: list[Annotated['TagType', strawberry.lazy('.types')]]
+    tags: list[Annotated['TagType', strawberry.lazy('.types')]] = strawberry_django.field(
+        prefetch_related=['tags'],
+    )
 
 
 @strawberry.type

+ 59 - 0
netbox/netbox/tests/test_graphql.py

@@ -3,7 +3,9 @@ import re
 
 import strawberry
 from django.contrib.contenttypes.models import ContentType
+from django.db import connection
 from django.test import override_settings
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from rest_framework import status
 from strawberry.extensions import QueryDepthLimiter
@@ -333,6 +335,63 @@ class GraphQLAPITestCase(APITestCase):
         self.assertNotIn('errors', data)
         self.assertEqual(int(data['data']['table_config']['object_type']['id']), site_ct.pk)
 
+    @override_settings(LOGIN_REQUIRED=True)
+    def test_graphql_device_list_tags_are_prefetched(self):
+        """
+        Requesting tags on device_list must batch tag lookups (no N+1 per device).
+        """
+        self.add_permissions('dcim.view_device', 'extras.view_tag')
+
+        manufacturer = Manufacturer.objects.create(name='Prefetch Manufacturer', slug='prefetch-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Prefetch Model',
+            slug='prefetch-model',
+        )
+        device_role = DeviceRole.objects.create(name='Prefetch Role', slug='prefetch-role')
+        site = Site.objects.first()
+        tag_alpha = Tag.objects.create(name='Prefetch Alpha', slug='prefetch-alpha')
+        tag_beta = Tag.objects.create(name='Prefetch Beta', slug='prefetch-beta')
+
+        devices = Device.objects.bulk_create([
+            Device(
+                name=f'Prefetch Device {index}',
+                device_type=device_type,
+                role=device_role,
+                site=site,
+            )
+            for index in range(10)
+        ])
+        for device in devices:
+            device.tags.set([tag_alpha, tag_beta])
+
+        query = """
+        {
+            device_list(filters: {role: {slug: {exact: "prefetch-role"}}}) {
+                name
+                tags {
+                    slug
+                }
+            }
+        }
+        """
+        url = reverse('graphql')
+
+        with CaptureQueriesContext(connection) as context:
+            response = self.client.post(url, data={'query': query}, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['device_list']), 10)
+
+        tag_queries = sum(1 for query_record in context.captured_queries if 'extras_tag' in query_record['sql'])
+        self.assertLessEqual(
+            tag_queries,
+            2,
+            msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
+        )
+
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')