Closes #22837: Omit implicit pagination on GraphQL to-one prefetches

This commit is contained in:
Jeremy Stretch 2026-08-03 12:49:54 -04:00
parent 4afdb31b89
commit be72e841f5
2 changed files with 89 additions and 1 deletions

View File

@ -52,8 +52,20 @@ def apply_pagination(
# Enforce MAX_PAGE_SIZE on the pagination limit
max_page_size = get_config().MAX_PAGE_SIZE
if max_page_size:
# A limit is meaningless for a field which returns at most one object, and synthesizing one for a
# prefetched to-one relation is actively harmful. strawberry-django deliberately leaves `pagination`
# as None there so that the prefetch remains a plain `WHERE id IN (...)` query; making it non-None
# switches the prefetch to a window function partitioned by the parent ID. Every partition then
# holds exactly one row, so ROW_NUMBER() is 1 throughout and the row number filter discards nothing,
# causing the join back to the parent table to return every row which shares the related object.
# See strawberry-graphql/strawberry-django#719.
returns_single_object = not (self.is_list or self.is_paginated or self.is_connection)
if pagination is None:
pagination = OffsetPaginationInput(limit=max_page_size)
# Note that `pagination` is never None for a single-object field unless it is a prefetched
# relation: strawberry-django populates it with an implicit limit of its own beforehand.
if not returns_single_object:
pagination = OffsetPaginationInput(limit=max_page_size)
elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
pagination.limit = max_page_size
elif pagination.limit <= 0:

View File

@ -627,6 +627,82 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site_list']), 2)
def test_to_one_relation_prefetch(self):
"""
A prefetched to-one relation should be fetched with a plain `WHERE id IN (...)` query, rather than
with a window function partitioned by the parent ID (which returns every row sharing the related
object, regardless of the requested page size).
"""
self.add_permissions('dcim.view_device', 'dcim.view_site')
url = reverse('graphql')
site = Site.objects.first()
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')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
Device.objects.bulk_create([
Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
for i in range(1, 21)
])
# Request two of the twenty devices at the site
query = """
{
device_list(pagination: {limit: 2}) {
name
site { name }
}
}
"""
with CaptureQueriesContext(connection) as ctx:
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']), 2)
self.assertEqual(data['data']['device_list'][0]['site']['name'], site.name)
# The site should have been fetched by exactly one query. (Asserting that it exists keeps the
# assertions below from silently passing if the site is ever fetched some other way.)
site_queries = [q['sql'] for q in ctx.captured_queries if 'FROM "dcim_site"' in q['sql']]
self.assertEqual(len(site_queries), 1, msg=f'Expected one query against dcim_site, got {site_queries}')
# That query should not apply window pagination, nor join back to the devices table (which would
# return one row per device at the site)
self.assertNotIn('ROW_NUMBER', site_queries[0])
self.assertNotIn('dcim_device', site_queries[0])
@override_settings(MAX_PAGE_SIZE=3)
def test_max_page_size_nested_list(self):
"""
MAX_PAGE_SIZE should still be enforced on a nested list relation.
"""
self.add_permissions('dcim.view_device', 'dcim.view_site')
url = reverse('graphql')
site = Site.objects.first()
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')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
Device.objects.bulk_create([
Device(name=f'Device {i}', site=site, device_type=device_type, role=role)
for i in range(1, 6)
])
query = """
{
site_list(pagination: {limit: 1}) {
name
devices { name }
}
}
"""
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']['site_list'][0]['devices']), 3)
def test_pagination_conflict(self):
url = reverse('graphql')
query = """