Parcourir la source

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

Elliott Balsley il y a 2 semaines
Parent
commit
3d3bebcb78

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

@@ -5,7 +5,9 @@ import strawberry_django
 
 
 from circuits import models
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from dcim.graphql.mixins import CabledObjectMixin
+from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 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 netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
 from tenancy.graphql.types import TenantType
 from tenancy.graphql.types import TenantType
 
 
@@ -74,7 +76,19 @@ class ProviderNetworkType(PrimaryObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
     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[
     def termination(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -133,7 +147,16 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
     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[
     def member(self) -> Annotated[
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['VirtualCircuitType', 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 dcim import models
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import (
 from netbox.graphql.types import (
     BaseObjectType,
     BaseObjectType,
@@ -19,7 +20,7 @@ from netbox.graphql.types import (
 )
 )
 from users.graphql.mixins import OwnerMixin
 from users.graphql.mixins import OwnerMixin
 from utilities.querysets import RestrictedPrefetch
 from utilities.querysets import RestrictedPrefetch
-from virtualization.models import Cluster
+from virtualization.models import Cluster, VMInterface
 
 
 from .filters import *
 from .filters import *
 from .mixins import CabledObjectMixin, PathEndpointMixin
 from .mixins import CabledObjectMixin, PathEndpointMixin
@@ -150,7 +151,25 @@ class CableBundleType(PrimaryObjectType):
 )
 )
 class CableTerminationType(NetBoxObjectType):
 class CableTerminationType(NetBoxObjectType):
     cable: Annotated['CableType', strawberry.lazy('dcim.graphql.types')] | None
     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['CircuitTerminationType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', 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['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('CableTerminationTerminationType'),
         strawberry.union('CableTerminationTerminationType'),
-    ] | None
+    ] | None:
+        return self.termination
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -330,22 +350,38 @@ class InventoryItemTemplateType(ComponentTemplateType):
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
     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:
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
     child_items: list[Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')]]
     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'),
         strawberry.union('InventoryItemTemplateComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -433,7 +469,16 @@ class FrontPortTemplateType(ModularComponentTemplateType):
 class MACAddressType(PrimaryObjectType):
 class MACAddressType(PrimaryObjectType):
     mac_address: str
     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[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.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')]]
     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:
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         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['ConsolePortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['ConsoleServerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FrontPortType', 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['PowerPortType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         | Annotated['RearPortType', strawberry.lazy('dcim.graphql.types')],
         strawberry.union('InventoryItemComponentType'),
         strawberry.union('InventoryItemComponentType'),
-    ] | None
+    ] | None:
+        return self.component
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -611,7 +672,7 @@ class ModuleBayType(ModularComponentType):
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
     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:
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -888,7 +949,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", 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:
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -965,7 +1026,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", 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:
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 

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

@@ -5,10 +5,13 @@ import strawberry_django
 
 
 from circuits.graphql.types import ProviderType
 from circuits.graphql.types import ProviderType
 from dcim.graphql.types import SiteType
 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 extras.graphql.mixins import ContactsMixin
 from ipam import models
 from ipam import models
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
 from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
+from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 
 from .filters import *
 from .filters import *
 from .mixins import IPAddressesMixin
 from .mixins import IPAddressesMixin
@@ -18,6 +21,7 @@ if TYPE_CHECKING:
         DeviceType,
         DeviceType,
         InterfaceType,
         InterfaceType,
         LocationType,
         LocationType,
+        RackGroupType,
         RackType,
         RackType,
         RegionType,
         RegionType,
         SiteGroupType,
         SiteGroupType,
@@ -126,7 +130,16 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
     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[
     def interface(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -155,7 +168,17 @@ class IPAddressType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
         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[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
@@ -197,7 +220,18 @@ class PrefixType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
         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[
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -259,7 +293,17 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
     ports: list[int]
     ports: list[int]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
     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[
     def parent(self) -> Annotated[
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.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')]]
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.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:
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
         return self.qinq_svlan
         return self.qinq_svlan
 
 
@@ -316,11 +360,27 @@ class VLANGroupType(OrganizationalObjectType):
     total_vlan_ids: BigInt
     total_vlan_ids: BigInt
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
     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[
     def scope(self) -> Annotated[
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['RackGroupType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RackType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RackType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['SiteType', 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,
     Device,
     DeviceRole,
     DeviceRole,
     DeviceType,
     DeviceType,
+    Interface,
     Location,
     Location,
     Manufacturer,
     Manufacturer,
     Rack,
     Rack,
@@ -35,6 +36,12 @@ from utilities.tables import get_table_for_model
 from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
 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):
 class GraphQLTestCase(TestCase):
 
 
     def _schema_extension_instances(self):
     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',
             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):
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')
         url = reverse('graphql')

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

@@ -3,8 +3,10 @@ from typing import TYPE_CHECKING, Annotated
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 
+from dcim.models import Location, Region, Site, SiteGroup
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
+from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
 from netbox.graphql.types import NetBoxObjectType, OrganizationalObjectType, PrimaryObjectType
 from users.graphql.mixins import OwnerMixin
 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')]]
     virtual_machines: list[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.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[
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', 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
 import strawberry_django
 import strawberry_django
 
 
+from dcim.models import Interface
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 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 netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType, PrimaryObjectType
+from virtualization.models import VMInterface
 from vpn import models
 from vpn import models
 
 
 from .filters import *
 from .filters import *
@@ -145,7 +149,17 @@ class L2VPNType(ContactsMixin, PrimaryObjectType):
 class L2VPNTerminationType(NetBoxObjectType):
 class L2VPNTerminationType(NetBoxObjectType):
     l2vpn: Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]
     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[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VLANType', strawberry.lazy('ipam.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
 import strawberry_django
 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 netbox.graphql.types import NestedGroupObjectType, PrimaryObjectType
 from wireless import models
 from wireless import models
 
 
@@ -46,7 +48,18 @@ class WirelessLANType(PrimaryObjectType):
 
 
     interfaces: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     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[
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]