ソースを参照

chore(deps): Upgrade strawberry-graphql-django to v0.93.0

Upgrades strawberry-graphql-django to v0.93.0 and strawberry-graphql to
v0.334.2, which must be upgraded together.

strawberry-graphql-django v0.93.0 expects a list field's queryset to
remain a QuerySet after pagination, but slicing a queryset which has
already been evaluated by a prefetch_related hint returns a list. Update
apply_pagination() to handle evaluated querysets:

- Paginate an unevaluated clone using strawberry-django's apply() rather
  than re-implementing its slicing logic. This rejects a negative offset
  (as Django does for any other queryset) and applies the limits to the
  clone's query, so that re-evaluating it cannot return unpaginated
  results.
- Return the queryset unchanged when pagination would not exclude any
  results, avoiding a clone per object in the common case.

This preserves both explicit pagination and MAX_PAGE_SIZE enforcement on
prefetched list fields (e.g. tags). Tests cover multiple objects with
differing tags, query batching, and rejection of a negative offset.

Closes #23383

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jeremy Stretch 14 時間 前
親
コミット
7d666588d7

+ 2 - 4
base_requirements.txt

@@ -167,14 +167,12 @@ social-auth-core<6.0.0
 sorl-thumbnail
 
 # Strawberry GraphQL
-# Pinned below v0.333.0, which is incompatible with strawberry-graphql-django <v0.93.0 (see #23383)
 # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
-strawberry-graphql<0.333.0
+strawberry-graphql
 
 # Strawberry GraphQL Django extension
-# Pinned below v0.92.3, which breaks pagination of prefetched list fields (see #23383)
 # https://github.com/strawberry-graphql/strawberry-django/releases
-strawberry-graphql-django<0.92.3
+strawberry-graphql-django
 
 # SVG image rendering (used for rack elevations)
 # https://github.com/mozman/svgwrite/blob/master/NEWS.rst

+ 32 - 0
netbox/netbox/graphql/pagination.py

@@ -136,4 +136,36 @@ def apply_pagination(
             limit=pagination.limit,
         )
 
+    if pagination is not None and related_field_id is None and queryset._result_cache is not None:
+        return _apply_evaluated_pagination(pagination, queryset)
+
     return apply(pagination, queryset, related_field_id=related_field_id)
+
+
+def _apply_evaluated_pagination(pagination: OffsetPaginationInput, queryset: _QS) -> _QS:
+    """
+    Paginate a queryset which has already been evaluated (e.g. by a `prefetch_related` hint on the field).
+
+    strawberry-django's `apply()` slices the queryset directly, which for an evaluated queryset returns a list
+    rather than a QuerySet, and strawberry-django's list hook cannot handle a list. Instead, paginate an
+    unevaluated clone of the queryset, and populate its result cache by applying the same slice to the cached
+    results.
+    """
+    # TODO: Remove this once strawberry-django's `apply()` returns a QuerySet for an evaluated queryset.
+    results = queryset._result_cache
+    offset = pagination.offset
+    limit = _resolve_limit(pagination.limit)
+    stop = offset + limit if limit is not None and limit >= 0 else None
+
+    # Avoid cloning the queryset if the pagination would not exclude any results (the common case, where
+    # only the implicit MAX_PAGE_SIZE limit has been applied).
+    if offset == 0 and (stop is None or stop >= len(results)):
+        return queryset
+
+    # Slicing the unevaluated clone validates the pagination (e.g. rejecting a negative offset) and applies the
+    # limits to its query, so that the results remain consistent should the queryset be cloned again later.
+    paginated = queryset.all()[offset:stop]
+    paginated._result_cache = results[offset:stop]
+    paginated._prefetch_done = True
+
+    return paginated

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

@@ -619,6 +619,92 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
         )
 
+    def test_graphql_device_list_tags_pagination(self):
+        """
+        Pagination is applied independently to a prefetched list field (tags) for each object, without
+        resorting to a query per object.
+        """
+        self.add_permissions('dcim.view_device', 'extras.view_tag')
+
+        manufacturer = Manufacturer.objects.create(name='Pagination Manufacturer', slug='pagination-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Pagination Model',
+            slug='pagination-model',
+        )
+        device_role = DeviceRole.objects.create(name='Pagination Role', slug='pagination-role')
+        site = Site.objects.first()
+        devices = Device.objects.bulk_create([
+            Device(name=f'Pagination Device {index}', device_type=device_type, role=device_role, site=site)
+            for index in range(5)
+        ])
+        tags = [
+            Tag.objects.create(name=f'Pagination Tag {index}', slug=f'pagination-tag-{index}')
+            for index in range(8)
+        ]
+        devices[0].tags.set(tags[:5])
+        devices[1].tags.set(tags[3:])
+        url = reverse('graphql')
+
+        def get_device_tags(tags_field):
+            query = """
+            {
+                device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
+                    name
+                    TAGS_FIELD {
+                        slug
+                    }
+                }
+            }
+            """.replace('TAGS_FIELD', tags_field)
+            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)
+            tag_queries = sum(1 for record in context.captured_queries if 'extras_tag' in record['sql'])
+            self.assertLessEqual(tag_queries, 2, msg=f'Expected batched tag prefetch, got {tag_queries} tag queries')
+            return {
+                device['name']: [tag['slug'] for tag in device['tags']]
+                for device in data['data']['device_list']
+                # The remaining devices have no tags, and exist only to detect a tag query per device
+                if device['name'] in ('Pagination Device 0', 'Pagination Device 1')
+            }
+
+        # Explicit pagination
+        self.assertEqual(get_device_tags('tags(pagination: {offset: 1, limit: 2})'), {
+            'Pagination Device 0': ['pagination-tag-1', 'pagination-tag-2'],
+            'Pagination Device 1': ['pagination-tag-4', 'pagination-tag-5'],
+        })
+
+        # No pagination: all tags are returned
+        self.assertEqual(get_device_tags('tags'), {
+            'Pagination Device 0': [tag.slug for tag in tags[:5]],
+            'Pagination Device 1': [tag.slug for tag in tags[3:]],
+        })
+
+        # MAX_PAGE_SIZE is enforced when no pagination is specified
+        with override_settings(MAX_PAGE_SIZE=3):
+            self.assertEqual(get_device_tags('tags'), {
+                'Pagination Device 0': [tag.slug for tag in tags[:3]],
+                'Pagination Device 1': [tag.slug for tag in tags[3:6]],
+            })
+
+        # A negative offset is rejected
+        query = """
+        {
+            device_list(filters: {role: {slug: {exact: "pagination-role"}}}) {
+                tags(pagination: {offset: -2}) {
+                    slug
+                }
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        data = json.loads(response.content)
+        self.assertIn('errors', data)
+        self.assertIn('Negative indexing is not supported', data['errors'][0]['message'])
+
     def test_graphql_ip_address_list_assigned_object(self):
         """
         Requesting assigned_object should batch prefetch related objects.

+ 2 - 2
requirements.txt

@@ -38,8 +38,8 @@ rq==2.12.0
 social-auth-app-django==6.1.0
 social-auth-core==5.2.0
 sorl-thumbnail==13.1.0
-strawberry-graphql==0.332.1
-strawberry-graphql-django==0.92.2
+strawberry-graphql==0.334.2
+strawberry-graphql-django==0.93.0
 svgwrite==1.4.3
 tablib==3.10.0
 tzdata==2026.5