Skip to content

Commit 4eaf33a

Browse files
committed
Implement the ability to preserve ordering of cursor
1 parent 910fb3b commit 4eaf33a

File tree

5 files changed

+548
-118
lines changed

5 files changed

+548
-118
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ build/
22
dist/
33
*.egg-info/
44
*.pyc
5+
.venv/

cursor_pagination.py

Lines changed: 150 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,113 @@
55
from 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+
8115
class 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

40151
class 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)

runtests.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from django.conf import settings
77
from django.test.utils import get_runner
88

9-
109
if __name__ == '__main__':
1110
os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
1211
django.setup()

setup.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from setuptools import setup
22

3-
43
with open("README.md", "r") as fh:
54
long_description = fh.read()
65

0 commit comments

Comments
 (0)