Fixes #22813: Fix extraneous database queries when fetching custom field data via GraphQL API

This commit is contained in:
Jeremy Stretch 2026-07-30 10:47:57 -04:00
parent 6a1b4afc43
commit a1df1b99b0
2 changed files with 105 additions and 2 deletions

View File

@ -45,7 +45,7 @@ class ConfigContextMixin:
@strawberry.type
class CustomFieldsMixin:
@strawberry_django.field
@strawberry_django.field(only=['custom_field_data'])
def custom_fields(self) -> strawberry.scalars.JSON:
return self.custom_field_data

View File

@ -7,10 +7,12 @@ from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from strawberry.extensions import QueryDepthLimiter
from strawberry.schema.config import StrawberryConfig
from core.models import ObjectType
from dcim.choices import LocationStatusChoices
from dcim.models import (
Device,
@ -23,9 +25,11 @@ from dcim.models import (
Site,
VirtualChassis,
)
from extras.models import TableConfig, Tag
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, TableConfig, Tag
from netbox.graphql.scalars import BigInt, BigIntScalar
from netbox.graphql.schema import Query, get_schema_extensions, schema
from users.models import Token
from utilities.tables import get_table_for_model
from utilities.testing import APITestCase, APIViewTestCases, TestCase, disable_warnings
@ -638,6 +642,105 @@ class GraphQLAPITestCase(APITestCase):
self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
class GraphQLDeferredColumnTestCase(APITestCase):
"""
A GraphQL field backed by a custom resolver is opaque to the query optimizer, which narrows column
selection with .only() based on the fields named in the GraphQL document. Any column such a resolver
reads must therefore be declared via an `only` hint; otherwise the column is deferred and reading it
reloads the row from the database once per object returned (see #22813).
Each test below asserts both that no single-row reload occurs and that the total query count does not
grow with the number of objects returned.
"""
OBJECT_COUNT = 10
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
# Devices, for CustomFieldsMixin.custom_fields
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
custom_field = CustomField.objects.create(name='cf1', type=CustomFieldTypeChoices.TYPE_TEXT)
custom_field.object_types.set([ObjectType.objects.get_for_model(Device)])
Device.objects.bulk_create([
Device(
name=f'Device {i}',
device_type=device_type,
role=device_role,
site=site,
custom_field_data={'cf1': f'value {i}'},
)
for i in range(cls.OBJECT_COUNT)
])
def _execute(self, query):
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)
return data['data'], context.captured_queries
def assertNoDeferredColumnReloads(self, query_template, list_field, table, validate):
"""
Execute `query_template` (which must accept a `limit` interpolation) for a single object and for
OBJECT_COUNT objects, asserting that no row of `table` is re-fetched by primary key and that the
total query count is identical for both. `validate` is called with the returned objects.
"""
# Token authentication updates Token.last_used at most once per minute, so the first API request
# made by a test issues an additional UPDATE (see netbox/api/authentication.py). Stamp the token
# up front to keep that one-off write out of the counts compared below.
Token.objects.filter(pk=self.token.pk).update(last_used=timezone.now())
query_counts = []
for limit in (1, self.OBJECT_COUNT):
data, queries = self._execute(query_template % {'limit': limit})
objects = data[list_field]
self.assertEqual(len(objects), limit)
validate(objects)
reloads = [q['sql'] for q in queries if f'FROM "{table}" WHERE "{table}"."id" = ' in q['sql']]
self.assertEqual(
reloads, [], msg=f'{len(reloads)} deferred-column reload(s) for {limit} object(s): {reloads[:1]}'
)
query_counts.append(len(queries))
self.assertEqual(
query_counts[0],
query_counts[1],
msg=(
f'Query count grew from {query_counts[0]} to {query_counts[1]} when the number of objects '
f'returned grew from 1 to {self.OBJECT_COUNT}'
)
)
def test_custom_fields(self):
"""
Regression test for #22813: CustomFieldsMixin.custom_fields must not defer `custom_field_data`.
"""
self.add_permissions('dcim.view_device')
query = """
{
device_list(pagination: {limit: %(limit)s}) {
id
custom_fields
}
}
"""
expected_values = {f'value {i}' for i in range(self.OBJECT_COUNT)}
def validate(devices):
for device in devices:
self.assertEqual(list(device['custom_fields']), ['cf1'])
self.assertIn(device['custom_fields']['cf1'], expected_values)
self.assertNoDeferredColumnReloads(query, 'device_list', 'dcim_device', validate)
class GraphQLSchemaCoverageTestCase(APIViewTestCases.GraphQLSchemaCoverageTestCase):
pass