Coverage for netbox/graphql/pagination.py: 25%
46 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import strawberry
2from django.db import DEFAULT_DB_ALIAS
3from django.db.models.functions import DenseRank
4from strawberry.types.unset import UNSET
5from strawberry_django.pagination import _QS, _PaginationWindow, _resolve_limit, apply
7from netbox.config import get_config
9__all__ = (
10 'OffsetPaginationInfo',
11 'OffsetPaginationInput',
12 'apply_distinct_window_pagination',
13 'apply_pagination',
14)
17@strawberry.type
18class OffsetPaginationInfo:
19 offset: int = 0
20 limit: int | None = UNSET
21 start: int | None = UNSET
24@strawberry.input
25class OffsetPaginationInput(OffsetPaginationInfo):
26 """
27 Customized implementation of OffsetPaginationInput to support cursor-based pagination.
28 """
29 pass
32def apply_distinct_window_pagination(
33 queryset: _QS,
34 *,
35 related_field_id: str,
36 offset: int = 0,
37 limit: int | None = UNSET,
38) -> _QS:
39 """
40 Replacement for strawberry-django's `apply_window_pagination()` for a queryset which has `DISTINCT`
41 enabled, as is the case when a list field is filtered across a to-many relation with `DISTINCT: true`.
43 SQL evaluates window functions before `DISTINCT`, so the `ROW_NUMBER()` annotation which
44 strawberry-django uses to paginate a prefetched relation assigns a unique value to each of the
45 duplicate rows produced by the join, and `DISTINCT` can never collapse them. `DENSE_RANK()` instead
46 assigns the same rank to every row which compares equal under the window ordering, leaving the
47 duplicate rows identical so that `DISTINCT` deduplicates them as intended. And because the rank is
48 incremented only once per distinct row, the rows are numbered as if the duplicates were never there,
49 keeping the pagination limit meaningful.
50 """
51 limit = _resolve_limit(limit)
53 order_by = [
54 expr
55 for expr, _ in queryset.query.get_compiler(
56 using=queryset._db or DEFAULT_DB_ALIAS
57 ).get_order_by()
58 ]
59 # Order by the primary key as well, to ensure that two rows representing *different* objects can
60 # never be assigned the same rank (and hence be counted only once against the limit).
61 order_by.append('pk')
63 # Note that we omit the `_strawberry_total_count` annotation which strawberry-django adds, as it
64 # cannot be made accurate here: window functions are evaluated before `DISTINCT`, so it would count
65 # the duplicate rows. strawberry-django's `get_total_count()` already disregards the annotation for
66 # a queryset with `DISTINCT` enabled and falls back to `count()`, so computing it would be wasted
67 # work: an extra window aggregate over every joined row.
68 queryset = queryset.annotate(
69 _strawberry_row_number=_PaginationWindow(
70 DenseRank(),
71 partition_by=related_field_id,
72 order_by=order_by,
73 ),
74 )
76 if offset:
77 queryset = queryset.filter(_strawberry_row_number__gt=offset)
78 if limit is not None and limit >= 0:
79 queryset = queryset.filter(_strawberry_row_number__lte=offset + limit)
81 return queryset
84def apply_pagination(
85 self,
86 queryset: _QS,
87 pagination: OffsetPaginationInput | None = None,
88 *,
89 related_field_id: str | None = None,
90) -> _QS:
91 """
92 Replacement for the `apply_pagination()` method on StrawberryDjangoField to support cursor-based pagination.
93 """
94 if pagination is not None and pagination.start not in (None, UNSET):
95 if pagination.offset:
96 raise ValueError('Cannot specify both `start` and `offset` in pagination.')
97 if pagination.start < 0:
98 raise ValueError('`start` must be greater than or equal to zero.')
100 # Filter the queryset to include only records with a primary key greater than or equal to the start value,
101 # and force ordering by primary key to ensure consistent pagination across all records.
102 queryset = queryset.filter(pk__gte=pagination.start).order_by('pk')
104 # Ignore `offset` when `start` is set
105 pagination.offset = 0
107 # Enforce MAX_PAGE_SIZE on the pagination limit
108 max_page_size = get_config().MAX_PAGE_SIZE
109 if max_page_size:
110 # A limit is meaningless for a field which returns at most one object, and synthesizing one for a
111 # prefetched to-one relation is actively harmful. strawberry-django deliberately leaves `pagination`
112 # as None there so that the prefetch remains a plain `WHERE id IN (...)` query; making it non-None
113 # switches the prefetch to a window function partitioned by the parent ID. Every partition then
114 # holds exactly one row, so ROW_NUMBER() is 1 throughout and the row number filter discards nothing,
115 # causing the join back to the parent table to return every row which shares the related object.
116 # See strawberry-graphql/strawberry-django#719.
117 returns_single_object = not (self.is_list or self.is_paginated or self.is_connection)
119 if pagination is None:
120 # Note that `pagination` is never None for a single-object field unless it is a prefetched
121 # relation: strawberry-django populates it with an implicit limit of its own beforehand.
122 if not returns_single_object:
123 pagination = OffsetPaginationInput(limit=max_page_size)
124 elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
125 pagination.limit = max_page_size
126 elif pagination.limit <= 0:
127 pagination.limit = max_page_size
129 # A prefetched relation is paginated with a window function, which is incompatible with the
130 # `DISTINCT` applied by the filter layer. Fall back to our own implementation in that case.
131 if pagination is not None and related_field_id is not None and queryset.query.distinct:
132 return apply_distinct_window_pagination(
133 queryset,
134 related_field_id=related_field_id,
135 offset=pagination.offset,
136 limit=pagination.limit,
137 )
139 return apply(pagination, queryset, related_field_id=related_field_id)