Przeglądaj źródła

Closes #22787: Improve GraphQL query efficiency when resolving assigned objects (#22792)

Elliott Balsley 2 tygodni temu
rodzic
commit
3d3bebcb78

+ 25 - 2
netbox/circuits/graphql/types.py

@@ -5,7 +5,9 @@ import strawberry_django
 
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
+from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
 from tenancy.graphql.types import TenantType
 
@@ -74,7 +76,19 @@ class ProviderNetworkType(PrimaryObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
 
-    @strawberry_django.field(prefetch_related='termination')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'termination',
+            [
+                Location,
+                Region,
+                SiteGroup,
+                Site,
+                models.ProviderNetwork,
+            ],
+        ),
+        only=['termination_type', 'termination_id'],
+    )
     def termination(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -133,7 +147,16 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
 
-    @strawberry_django.field(prefetch_related='member')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'member',
+            [
+                models.Circuit,
+                models.VirtualCircuit,
+            ],
+        ),
+        only=['member_type', 'member_id'],
+    )
     def member(self) -> Annotated[
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')],

+ 81 - 20
netbox/dcim/graphql/types.py

@@ -9,6 +9,7 @@ from core.graphql.mixins import ChangelogMixin
 from dcim import models
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import (
     BaseObjectType,
@@ -19,7 +20,7 @@ from netbox.graphql.types import (
 )
 from users.graphql.mixins import OwnerMixin
 from utilities.querysets import RestrictedPrefetch
-from virtualization.models import Cluster
+from virtualization.models import Cluster, VMInterface
 
 from .filters import *
 from .mixins import CabledObjectMixin, PathEndpointMixin
@@ -150,7 +151,25 @@ class CableBundleType(PrimaryObjectType):
 )
 class CableTerminationType(NetBoxObjectType):
     cable: Annotated['CableType', strawberry.lazy('dcim.graphql.types')] | None
-    termination: Annotated[
+
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'termination',
+            [
+                CircuitTermination,
+                models.ConsolePort,
+                models.ConsoleServerPort,
+                models.FrontPort,
+                models.Interface,
+                models.PowerFeed,
+                models.PowerOutlet,
+                models.PowerPort,
+                models.RearPort,
+            ],
+        ),
+        only=['termination_type', 'termination_id'],
+    )
+    def termination(self) -> Annotated[
         Annotated['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
@@ -161,7 +180,8 @@ class CableTerminationType(NetBoxObjectType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
-    ] | None
+    ] | None:
+        return self.termination
 
 
 @strawberry_django.type(
@@ -330,22 +350,38 @@ class InventoryItemTemplateType(ComponentTemplateType):
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
     child_items: list[Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')]]
 
-    component: Annotated[
-        Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['FrontPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['PowerOutletType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
-        | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'component',
+            [
+                models.ConsolePortTemplate,
+                models.ConsoleServerPortTemplate,
+                models.FrontPortTemplate,
+                models.InterfaceTemplate,
+                models.PowerOutletTemplate,
+                models.PowerPortTemplate,
+                models.RearPortTemplate,
+            ],
+        ),
+        only=['component_type', 'component_id'],
+    )
+    def component(self) -> Annotated[
+        Annotated['ConsolePortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['ConsoleServerPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['FrontPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['InterfaceTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['PowerOutletTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['PowerPortTemplateType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['RearPortTemplateType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('InventoryItemTemplateComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 @strawberry_django.type(
@@ -433,7 +469,16 @@ class FrontPortTemplateType(ModularComponentTemplateType):
 class MACAddressType(PrimaryObjectType):
     mac_address: str
 
-    @strawberry_django.field(prefetch_related='assigned_object')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'assigned_object',
+            [
+                models.Interface,
+                VMInterface,
+            ],
+        ),
+        only=['assigned_object_type', 'assigned_object_id'],
+    )
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -497,11 +542,26 @@ class InventoryItemType(ComponentType):
 
     child_items: list[Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
-    component: Annotated[
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'component',
+            [
+                models.ConsolePort,
+                models.ConsoleServerPort,
+                models.FrontPort,
+                models.Interface,
+                models.PowerOutlet,
+                models.PowerPort,
+                models.RearPort,
+            ],
+        ),
+        only=['component_type', 'component_id'],
+    )
+    def component(self) -> Annotated[
         Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FrontPortType', strawberry.lazy('dcim.graphql.types')]
@@ -510,7 +570,8 @@ class InventoryItemType(ComponentType):
         | Annotated['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('InventoryItemComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 @strawberry_django.type(
@@ -611,7 +672,7 @@ class ModuleBayType(ModularComponentType):
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
@@ -888,7 +949,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
@@ -965,7 +1026,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(prefetch_related='parent', only=['parent_id'])
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 

+ 66 - 6
netbox/ipam/graphql/types.py

@@ -5,10 +5,13 @@ import strawberry_django
 
 from circuits.graphql.types import ProviderType
 from dcim.graphql.types import SiteType
+from dcim.models import Device, Interface, Location, Rack, RackGroup, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin
 from ipam import models
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 from .filters import *
 from .mixins import IPAddressesMixin
@@ -18,6 +21,7 @@ if TYPE_CHECKING:
         DeviceType,
         InterfaceType,
         LocationType,
+        RackGroupType,
         RackType,
         RegionType,
         SiteGroupType,
@@ -126,7 +130,16 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
 
-    @strawberry_django.field(prefetch_related='interface')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'interface',
+            [
+                Interface,
+                VMInterface,
+            ],
+        ),
+        only=['interface_type', 'interface_id'],
+    )
     def interface(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -155,7 +168,17 @@ class IPAddressType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
-    @strawberry_django.field(prefetch_related='assigned_object')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'assigned_object',
+            [
+                models.FHRPGroup,
+                Interface,
+                VMInterface,
+            ],
+        ),
+        only=['assigned_object_type', 'assigned_object_id'],
+    )
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
@@ -197,7 +220,18 @@ class PrefixType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Region,
+                SiteGroup,
+                Site,
+                Location,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -259,7 +293,17 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
     ports: list[int]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='parent')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'parent',
+            [
+                Device,
+                VirtualMachine,
+                models.FHRPGroup,
+            ],
+        ),
+        only=['parent_object_type', 'parent_object_id'],
+    )
     def parent(self) -> Annotated[
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]
@@ -298,7 +342,7 @@ class VLANType(PrimaryObjectType):
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='qinq_svlan')
+    @strawberry_django.field(prefetch_related='qinq_svlan', only=['qinq_svlan_id'])
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
         return self.qinq_svlan
 
@@ -316,11 +360,27 @@ class VLANGroupType(OrganizationalObjectType):
     total_vlan_ids: BigInt
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Cluster,
+                ClusterGroup,
+                Location,
+                Rack,
+                RackGroup,
+                Region,
+                Site,
+                SiteGroup,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['RackGroupType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RackType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]

+ 43 - 0
netbox/netbox/graphql/optimization.py

@@ -0,0 +1,43 @@
+from collections.abc import Callable, Sequence
+
+from django.contrib.contenttypes.prefetch import GenericPrefetch
+from django.db.models import Model, QuerySet
+from strawberry.types import Info
+from strawberry_django.optimizer import optimize
+from strawberry_django.optimizer import optimizer as optimizer_ctx
+
+__all__ = (
+    'build_gfk_prefetch',
+    'optimize_prefetch_queryset',
+)
+
+
+def optimize_prefetch_queryset(queryset: QuerySet, info: Info) -> QuerySet:
+    """
+    Apply strawberry-django's query optimizer to a queryset used inside a GenericForeignKey prefetch.
+    """
+    if ext := optimizer_ctx.get():
+        return ext.optimize(queryset, info)
+
+    return optimize(queryset, info)
+
+
+def build_gfk_prefetch(
+    lookup: str,
+    models: Sequence[type[Model]],
+) -> Callable[[Info], GenericPrefetch]:
+    """
+    Return a selection-aware GenericPrefetch for a GenericForeignKey field.
+
+    Each model gets its own queryset, optimized according to the client's GraphQL selection set.
+    """
+
+    def prefetch(info: Info) -> GenericPrefetch:
+        querysets = [
+            optimize_prefetch_queryset(model.objects.all(), info)
+            for model in models
+        ]
+
+        return GenericPrefetch(lookup, querysets)
+
+    return prefetch

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

@@ -18,6 +18,7 @@ from dcim.models import (
     Device,
     DeviceRole,
     DeviceType,
+    Interface,
     Location,
     Manufacturer,
     Rack,
@@ -35,6 +36,12 @@ from utilities.tables import get_table_for_model
 from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 
 
+def count_primary_table_queries(queries, table):
+    """Count queries that read from `table` as the primary relation (not only as a join)."""
+    pattern = re.compile(rf'FROM "{re.escape(table)}"')
+    return sum(1 for query_record in queries if pattern.search(query_record['sql']))
+
+
 class GraphQLTestCase(TestCase):
 
     def _schema_extension_instances(self):
@@ -469,6 +476,149 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
         )
 
+    def test_graphql_ip_address_list_assigned_object(self):
+        """
+        Requesting assigned_object should batch prefetch related objects.
+        """
+        self.add_permissions('ipam.view_ipaddress', 'dcim.view_interface', 'dcim.view_device')
+
+        site = Site.objects.first()
+        manufacturer = Manufacturer.objects.create(name='Assigned Object Manufacturer', slug='assigned-object-mfg')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Assigned Object Model',
+            slug='assigned-object-model',
+        )
+        device_role = DeviceRole.objects.create(name='Assigned Object Role', slug='assigned-object-role')
+        device = Device.objects.create(
+            name='Assigned Object Device',
+            site=site,
+            device_type=device_type,
+            role=device_role,
+        )
+        interface = Interface.objects.create(name='eth0', device=device, type='1000baset')
+        ip_addresses = IPAddress.objects.bulk_create([
+            IPAddress(address=f'192.0.2.{index}/24', assigned_object=interface)
+            for index in range(1, 6)
+        ])
+        ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
+
+        query = f"""
+        {{
+            ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
+                address
+                assigned_object {{
+                    ... on InterfaceType {{
+                        name
+                        device {{
+                            name
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        """
+        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']['ip_address_list']), len(ip_addresses))
+
+        device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
+        self.assertLessEqual(
+            device_queries,
+            2,
+            msg=f'Expected batched assigned_object prefetch, got {device_queries} device queries for 5 IP addresses',
+        )
+
+    def test_graphql_ip_address_list_assigned_object_nested_site(self):
+        """
+        Nested assigned_object selections should be optimized on the GFK prefetch queryset.
+        """
+        self.add_permissions(
+            'ipam.view_ipaddress',
+            'dcim.view_interface',
+            'dcim.view_device',
+            'dcim.view_site',
+        )
+
+        site = Site.objects.first()
+        manufacturer = Manufacturer.objects.create(
+            name='Nested Site Manufacturer',
+            slug='nested-site-mfg',
+        )
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Nested Site Model',
+            slug='nested-site-model',
+        )
+        device_role = DeviceRole.objects.create(name='Nested Site Role', slug='nested-site-role')
+        interfaces = []
+        for index in range(5):
+            device = Device.objects.create(
+                name=f'Nested Site Device {index}',
+                site=site,
+                device_type=device_type,
+                role=device_role,
+            )
+            interfaces.append(Interface.objects.create(
+                name=f'eth{index}',
+                device=device,
+                type='1000baset',
+            ))
+        ip_addresses = IPAddress.objects.bulk_create([
+            IPAddress(address=f'192.0.2.{index}/24', assigned_object=interfaces[index - 1])
+            for index in range(1, 6)
+        ])
+        ip_ids = json.dumps([str(ip.pk) for ip in ip_addresses])
+
+        query = f"""
+        {{
+            ip_address_list(filters: {{id: {{in_list: {ip_ids}}}}}) {{
+                address
+                assigned_object {{
+                    ... on InterfaceType {{
+                        name
+                        device {{
+                            name
+                            site {{
+                                name
+                            }}
+                        }}
+                    }}
+                }}
+            }}
+        }}
+        """
+        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']['ip_address_list']), len(ip_addresses))
+        for ip_data in data['data']['ip_address_list']:
+            self.assertEqual(ip_data['assigned_object']['device']['site']['name'], site.name)
+
+        device_queries = count_primary_table_queries(context.captured_queries, 'dcim_device')
+        site_queries = count_primary_table_queries(context.captured_queries, 'dcim_site')
+        self.assertLessEqual(
+            device_queries,
+            2,
+            msg=f'Expected batched device prefetch, got {device_queries} device queries for 5 IP addresses',
+        )
+        self.assertLessEqual(
+            site_queries,
+            2,
+            msg=f'Expected optimized site join, got {site_queries} site queries for 5 IP addresses',
+        )
+
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')

+ 14 - 1
netbox/virtualization/graphql/types.py

@@ -3,8 +3,10 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 
+from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
 from users.graphql.mixins import OwnerMixin
@@ -59,7 +61,18 @@ class ClusterType(ContactsMixin, VLANGroupsMixin, PrimaryObjectType):
     virtual_machines: list[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Region,
+                SiteGroup,
+                Site,
+                Location,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]

+ 15 - 1
netbox/vpn/graphql/types.py

@@ -3,8 +3,12 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 
+from dcim.models import Interface
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
+from ipam.models import VLAN
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
+from virtualization.models import VMInterface
 from vpn import models
 
 from .filters import *
@@ -145,7 +149,17 @@ class L2VPNType(ContactsMixin, PrimaryObjectType):
 class L2VPNTerminationType(NetBoxObjectType):
     l2vpn: Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]
 
-    @strawberry_django.field(prefetch_related='assigned_object')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'assigned_object',
+            [
+                Interface,
+                VMInterface,
+                VLAN,
+            ],
+        ),
+        only=['assigned_object_type', 'assigned_object_id'],
+    )
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VLANType', strawberry.lazy('ipam.graphql.types')]

+ 14 - 1
netbox/wireless/graphql/types.py

@@ -3,6 +3,8 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry_django
 
+from dcim.models import Location, Region, Site, SiteGroup
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.types import NestedGroupObjectType, PrimaryObjectType
 from wireless import models
 
@@ -46,7 +48,18 @@ class WirelessLANType(PrimaryObjectType):
 
     interfaces: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
 
-    @strawberry_django.field(prefetch_related='scope')
+    @strawberry_django.field(
+        prefetch_related=build_gfk_prefetch(
+            'scope',
+            [
+                Region,
+                SiteGroup,
+                Site,
+                Location,
+            ],
+        ),
+        only=['scope_type', 'scope_id'],
+    )
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]