55from django .utils .translation import gettext_lazy as _
66
77
8+ class CursorStrategy :
9+ """Base interface for cursor pagination strategies."""
10+
11+ def get_ordering (self , ordering , from_last = False ):
12+ """Transform ordering fields for database queries according to the strategy."""
13+ raise NotImplementedError
14+
15+ def build_cursor_filter (
16+ self , ordering , cursor_values , reverse = False , from_last = False
17+ ):
18+ """Build the cursor filter using the strategy's approach."""
19+ raise NotImplementedError
20+
21+
22+ class DefaultCursorStrategy (CursorStrategy ):
23+ """Default strategy maintaining current NULLS LAST behavior."""
24+
25+ def get_ordering (self , ordering , from_last = False ):
26+ """
27+ Transform ordering fields with explicit NULL handling for consistent behavior.
28+
29+ This clarifies that NULL values come at the end in the sort.
30+ When "from_last" is specified, NULL values come first since we return
31+ the results in reversed order.
32+ """
33+ nulls_ordering = []
34+ for key in ordering :
35+ is_reversed = key .startswith ('-' )
36+ column = key .lstrip ('-' )
37+ if is_reversed :
38+ if from_last :
39+ nulls_ordering .append (F (column ).desc (nulls_first = True ))
40+ else :
41+ nulls_ordering .append (F (column ).desc (nulls_last = True ))
42+ else :
43+ if from_last :
44+ nulls_ordering .append (F (column ).asc (nulls_first = True ))
45+ else :
46+ nulls_ordering .append (F (column ).asc (nulls_last = True ))
47+
48+ return nulls_ordering
49+
50+ def build_cursor_filter (
51+ self , ordering , cursor_values , reverse = False , from_last = False
52+ ):
53+ """
54+ Build the cursor filter using the current OR logic and NULL handling.
55+ This is the existing implementation from the apply_cursor method.
56+ """
57+ if not ordering or not cursor_values :
58+ return Q ()
59+
60+ if len (ordering ) != len (cursor_values ):
61+ raise ValueError ("Ordering and cursor values must match length" )
62+
63+ # Convert cursor values for comparison
64+ position_values = [
65+ Value (pos , output_field = TextField ()) if pos is not None else None
66+ for pos in cursor_values
67+ ]
68+
69+ # Build Q object with OR logic and NULL handling (current implementation)
70+ filtering = Q ()
71+ q_equality = {}
72+
73+ for ordering_field , value in zip (ordering , position_values ):
74+ is_reversed = ordering_field .startswith ('-' )
75+ o = ordering_field .lstrip ('-' )
76+ if value is None : # cursor value for the key was NULL
77+ key = "{}__isnull" .format (o )
78+ if (
79+ from_last is True
80+ ): # if from_last & cursor value is NULL, we need to get non Null for the key
81+ q = {key : False }
82+ q .update (q_equality )
83+ filtering |= Q (** q )
84+
85+ q_equality .update ({key : True })
86+ else : # cursor value for the key was non NULL
87+ if reverse != is_reversed :
88+ comparison_key = "{}__lt" .format (o )
89+ else :
90+ comparison_key = "{}__gt" .format (o )
91+
92+ q = Q (** {comparison_key : value })
93+ if not from_last : # if not from_last, NULL values are still candidates
94+ q |= Q (** {"{}__isnull" .format (o ): True })
95+ filtering |= (q ) & Q (** q_equality )
96+
97+ equality_key = "{}__exact" .format (o )
98+ q_equality .update ({equality_key : value })
99+
100+ return filtering
101+
102+
103+ class PreserveOrderingStrategy (DefaultCursorStrategy ):
104+ """
105+ Cursor strategy that preserves the ordering of the fields.
106+ """
107+
108+ def get_ordering (self , ordering , from_last = False ):
109+ """
110+ Return simple ordering fields.
111+ """
112+ return ordering
113+
114+
8115class InvalidCursor (Exception ):
9116 pass
10117
@@ -14,6 +121,7 @@ def reverse_ordering(ordering_tuple):
14121 Given an order_by tuple such as `('-created', 'uuid')` reverse the
15122 ordering and return a new tuple, eg. `('created', '-uuid')`.
16123 """
124+
17125 def invert (x ):
18126 return x [1 :] if (x .startswith ('-' )) else '-' + x
19127
@@ -34,41 +142,33 @@ def __getitem__(self, key):
34142 return self .items .__getitem__ (key )
35143
36144 def __repr__ (self ):
37- return '<Page: [%s%s]>' % (', ' .join (repr (i ) for i in self .items [:21 ]), ' (remaining truncated)' if len (self .items ) > 21 else '' )
145+ return '<Page: [%s%s]>' % (
146+ ', ' .join (repr (i ) for i in self .items [:21 ]),
147+ ' (remaining truncated)' if len (self .items ) > 21 else '' ,
148+ )
38149
39150
40151class CursorPaginator (object ):
41152 delimiter = '|'
42153 none_string = '::None'
43154 invalid_cursor_message = _ ('Invalid cursor' )
44155
45- def __init__ (self , queryset , ordering ):
46- self .queryset = queryset .order_by (* self ._nulls_ordering (ordering ))
156+ def __init__ (self , queryset , ordering , strategy = None ):
47157 self .ordering = ordering
158+ self .strategy = strategy or DefaultCursorStrategy ()
159+ self .queryset = queryset .order_by (* self ._get_ordering (ordering ))
48160
49- def _nulls_ordering (self , ordering , from_last = False ):
50- """
51- This clarifies that NULL value comes at the end in the sort.
52- When "from_last" is specified, NULL value comes first since we return the results in reversed order.
53- """
54- nulls_ordering = []
55- for key in ordering :
56- is_reversed = key .startswith ('-' )
57- column = key .lstrip ('-' )
58- if is_reversed :
59- if from_last :
60- nulls_ordering .append (F (column ).desc (nulls_first = True ))
61- else :
62- nulls_ordering .append (F (column ).desc (nulls_last = True ))
63- else :
64- if from_last :
65- nulls_ordering .append (F (column ).asc (nulls_first = True ))
66- else :
67- nulls_ordering .append (F (column ).asc (nulls_last = True ))
161+ def _get_ordering (self , ordering ):
162+ """Get database ordering using the current strategy."""
163+ return self .strategy .get_ordering (ordering )
68164
69- return nulls_ordering
165+ def _nulls_ordering (self , ordering , from_last = False ):
166+ """Deprecated: Use strategy.get_ordering instead."""
167+ return self .strategy .get_ordering (ordering , from_last )
70168
71- def _apply_paginator_arguments (self , qs , first = None , last = None , after = None , before = None ):
169+ def _apply_paginator_arguments (
170+ self , qs , first = None , last = None , after = None , before = None
171+ ):
72172 """
73173 Apply first/after, last/before filtering to the queryset
74174 """
@@ -81,9 +181,11 @@ def _apply_paginator_arguments(self, qs, first=None, last=None, after=None, befo
81181 if before is not None :
82182 qs = self .apply_cursor (before , qs , from_last = from_last , reverse = True )
83183 if first is not None :
84- qs = qs [:first + 1 ]
184+ qs = qs [: first + 1 ]
85185 if last is not None :
86- qs = qs .order_by (* self ._nulls_ordering (reverse_ordering (self .ordering ), from_last = True ))[:last + 1 ]
186+ qs = qs .order_by (
187+ * self ._nulls_ordering (reverse_ordering (self .ordering ), from_last = True )
188+ )[: last + 1 ]
87189
88190 return qs
89191
@@ -128,90 +230,28 @@ async def apage(self, first=None, last=None, after=None, before=None):
128230 return self ._get_cursor_page (items , has_additional , first , last , after , before )
129231
130232 def apply_cursor (self , cursor , queryset , from_last , reverse = False ):
233+ """Apply cursor using the current strategy."""
131234 position = self .decode_cursor (cursor )
132-
133- # this was previously implemented as tuple comparison done on postgres side
134- # Assume comparing 3-tuples a and b,
135- # the comparison a < b is equivalent to:
136- # (a.0 < b.0) || (a.0 == b.0 && (a.1 < b.1)) || (a.0 == b.0 && a.1 == b.1 && (a.2 < b.2))
137- # The expression above does not depend on short-circuit evalution support,
138- # which is usually unavailable on backend RDB
139-
140- # In order to reflect that in DB query,
141- # we need to generate a corresponding WHERE-clause.
142-
143- # Suppose we have ordering ("field1", "-field2", "field3")
144- # (note negation 2nd item),
145- # and corresponding cursor values are ("value1", "value2", "value3"),
146- # `reverse` is False.
147- # In order to apply cursor, we need to generate a following WHERE-clause:
148-
149- # WHERE ((field1 < value1 OR field1 IS NULL) OR
150- # (field1 = value1 AND (field2 > value2 OR field2 IS NULL)) OR
151- # (field1 = value1 AND field2 = value2 AND (field3 < value3 IS NULL)).
152- #
153- # Keep in mind, NULL is considered the last part of each field's order.
154- # We will use `__lt` lookup for `<`,
155- # `__gt` for `>` and `__exact` for `=`.
156- # (Using case-sensitive comparison as long as
157- # cursor values come from the DB against which it is going to be compared).
158- # The corresponding django ORM construct would look like:
159- # filter(
160- # Q(field1__lt=Value(value1) OR field1__isnull=True) |
161- # Q(field1__exact=Value(value1), (Q(field2__gt=Value(value2) | Q(field2__isnull=True)) |
162- # Q(field1__exact=Value(value1), field2__exact=Value(value2), (Q(field3__lt=Value(value3) | Q(field3__isnull=True)))
163- # )
164-
165- # In order to remember which keys we need to compare for equality on the next iteration,
166- # we need an accumulator in which we store all the previous keys.
167- # When we are generating a Q object for j-th position/ordering pair,
168- # our q_equality would contain equality lookups
169- # for previous pairs of 0-th to (j-1)-th pairs.
170- # That would allow us to generate a Q object like the following:
171- # Q(f1__exact=Value(v1), f2__exact=Value(v2), ..., fj_1__exact=Value(vj_1), fj__lt=Value(vj)),
172- # where the last item would depend on both "reverse" option and ordering key sign.
173-
174- filtering = Q ()
175- q_equality = {}
176-
177- position_values = [Value (pos , output_field = TextField ()) if pos is not None else None for pos in position ]
178-
179- for ordering , value in zip (self .ordering , position_values ):
180- is_reversed = ordering .startswith ('-' )
181- o = ordering .lstrip ('-' )
182- if value is None : # cursor value for the key was NULL
183- key = "{}__isnull" .format (o )
184- if from_last is True : # if from_last & cursor value is NULL, we need to get non Null for the key
185- q = {key : False }
186- q .update (q_equality )
187- filtering |= Q (** q )
188-
189- q_equality .update ({key : True })
190- else : # cursor value for the key was non NULL
191- if reverse != is_reversed :
192- comparison_key = "{}__lt" .format (o )
193- else :
194- comparison_key = "{}__gt" .format (o )
195-
196- q = Q (** {comparison_key : value })
197- if not from_last : # if not from_last, NULL values are still candidates
198- q |= Q (** {"{}__isnull" .format (o ): True })
199- filtering |= (q ) & Q (** q_equality )
200-
201- equality_key = "{}__exact" .format (o )
202- q_equality .update ({equality_key : value })
203-
204- return queryset .filter (filtering )
235+ return queryset .filter (
236+ self .strategy .build_cursor_filter (
237+ self .ordering , position , reverse , from_last
238+ )
239+ )
205240
206241 def decode_cursor (self , cursor ):
207242 try :
208243 orderings = b64decode (cursor .encode ('ascii' )).decode ('utf8' )
209- return [ordering if ordering != self .none_string else None for ordering in orderings .split (self .delimiter )]
244+ return [
245+ ordering if ordering != self .none_string else None
246+ for ordering in orderings .split (self .delimiter )
247+ ]
210248 except (TypeError , ValueError ):
211249 raise InvalidCursor (self .invalid_cursor_message )
212250
213251 def encode_cursor (self , position ):
214- encoded = b64encode (self .delimiter .join (position ).encode ('utf8' )).decode ('ascii' )
252+ encoded = b64encode (self .delimiter .join (position ).encode ('utf8' )).decode (
253+ 'ascii'
254+ )
215255 return encoded
216256
217257 def position_from_instance (self , instance ):
@@ -231,3 +271,12 @@ def position_from_instance(self, instance):
231271 def cursor (self , instance ):
232272 return self .encode_cursor (self .position_from_instance (instance ))
233273
274+ @classmethod
275+ def for_preserve_ordering (cls , queryset , ordering ):
276+ """Create cursor paginator using PreserveOrderingStrategy."""
277+ return cls (queryset , ordering , strategy = PreserveOrderingStrategy ())
278+
279+ @classmethod
280+ def with_strategy (cls , queryset , ordering , strategy ):
281+ """Create a cursor paginator with a custom strategy."""
282+ return cls (queryset , ordering , strategy = strategy )
0 commit comments