From f8e795000495f9feaffddb1b327026ced75276d4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 24 Jul 2026 17:26:15 -0700 Subject: [PATCH] 22766 - Map GraphQL array length lookup to Django's len transform --- netbox/netbox/graphql/filter_lookups.py | 22 +++++++++++--- netbox/netbox/tests/test_graphql.py | 39 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/graphql/filter_lookups.py b/netbox/netbox/graphql/filter_lookups.py index 3419931bf..3d0cc73e9 100644 --- a/netbox/netbox/graphql/filter_lookups.py +++ b/netbox/netbox/graphql/filter_lookups.py @@ -306,10 +306,24 @@ class ArrayLookup(Generic[T]): Class for Array field lookups """ - contains: list[T] | None = strawberry_django.filter_field(description='Contains the value') - contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value') - overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value') - length: int | None = strawberry_django.filter_field(description='Length of the array') + contains: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contains the value') + contained_by: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Contained by the value') + overlap: list[T] | None = strawberry.field(default=strawberry.UNSET, description='Overlaps with the value') + length: int | None = strawberry.field(default=strawberry.UNSET, description='Length of the array') + + @strawberry_django.filter_field + def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> tuple[QuerySet, Q]: + # Map the public GraphQL ``length`` field to Django's ``len`` array transform; the + # remaining lookups share their name with the corresponding ORM transform. + if self.contains is not strawberry.UNSET and self.contains is not None: + return queryset, Q(**{f'{prefix}contains': self.contains}) + if self.contained_by is not strawberry.UNSET and self.contained_by is not None: + return queryset, Q(**{f'{prefix}contained_by': self.contained_by}) + if self.overlap is not strawberry.UNSET and self.overlap is not None: + return queryset, Q(**{f'{prefix}overlap': self.overlap}) + if self.length is not strawberry.UNSET and self.length is not None: + return queryset, Q(**{f'{prefix}len': self.length}) + return queryset, Q() @strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.') diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 95f607ff0..2874354ef 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -12,7 +12,17 @@ from strawberry.extensions import QueryDepthLimiter from strawberry.schema.config import StrawberryConfig from dcim.choices import LocationStatusChoices -from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis +from dcim.models import ( + Device, + DeviceRole, + DeviceType, + Location, + Manufacturer, + Rack, + RackReservation, + Site, + VirtualChassis, +) from extras.models import TableConfig, Tag from netbox.graphql.scalars import BigInt, BigIntScalar from netbox.graphql.schema import Query, get_schema_extensions, schema @@ -315,6 +325,33 @@ class GraphQLAPITestCase(APITestCase): self.assertNotIn('errors', data) self.assertEqual(len(data['data']['device_list']), 3) + def test_graphql_array_length_lookup(self): + """ + The public GraphQL ``length`` array lookup must map to Django's ``len`` transform. + Regression test for #22766 using a standard array field (RackReservation.units). + """ + self.add_permissions('dcim.view_rackreservation') + url = reverse('graphql') + + site = Site.objects.first() + rack = Rack.objects.create(name='Reservation Rack', site=site) + RackReservation.objects.create(rack=rack, units=[1, 2], user=self.user, description='Two units') + RackReservation.objects.create(rack=rack, units=[3, 4, 5], user=self.user, description='Three units') + + query = """ + { + rack_reservation_list(filters: {units: {length: 2}}) { + id units + } + } + """ + 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']['rack_reservation_list']), 1) + self.assertEqual(data['data']['rack_reservation_list'][0]['units'], [1, 2]) + def test_graphql_tableconfig_object_type_exposes_id(self): """TableConfigType.object_type must expose ContentType fields (e.g. id).""" self.add_permissions('extras.view_tableconfig')