mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-01-23 15:54:16 +03:00
Add offset support for cursor pagination
This commit is contained in:
parent
492f3c410d
commit
dbb684117f
|
@ -1,3 +1,4 @@
|
||||||
|
# coding: utf-8
|
||||||
"""
|
"""
|
||||||
Pagination serializers determine the structure of the output that should
|
Pagination serializers determine the structure of the output that should
|
||||||
be used for paginated responses.
|
be used for paginated responses.
|
||||||
|
@ -385,7 +386,7 @@ Cursor = namedtuple('Cursor', ['offset', 'reverse', 'position'])
|
||||||
|
|
||||||
|
|
||||||
def decode_cursor(encoded):
|
def decode_cursor(encoded):
|
||||||
tokens = urlparse.parse_qs(b64decode(encoded))
|
tokens = urlparse.parse_qs(b64decode(encoded), keep_blank_values=True)
|
||||||
try:
|
try:
|
||||||
offset = int(tokens['offset'][0])
|
offset = int(tokens['offset'][0])
|
||||||
reverse = bool(int(tokens['reverse'][0]))
|
reverse = bool(int(tokens['reverse'][0]))
|
||||||
|
@ -406,8 +407,7 @@ def encode_cursor(cursor):
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(BasePagination):
|
class CursorPagination(BasePagination):
|
||||||
# reverse
|
# TODO: reverse cursors
|
||||||
# limit
|
|
||||||
cursor_query_param = 'cursor'
|
cursor_query_param = 'cursor'
|
||||||
page_size = 5
|
page_size = 5
|
||||||
|
|
||||||
|
@ -417,26 +417,63 @@ class CursorPagination(BasePagination):
|
||||||
encoded = request.query_params.get(self.cursor_query_param)
|
encoded = request.query_params.get(self.cursor_query_param)
|
||||||
|
|
||||||
if encoded is None:
|
if encoded is None:
|
||||||
cursor = None
|
self.cursor = None
|
||||||
else:
|
else:
|
||||||
cursor = decode_cursor(encoded)
|
self.cursor = decode_cursor(encoded)
|
||||||
# TODO: Invalid cursors should 404
|
# TODO: Invalid cursors should 404
|
||||||
|
|
||||||
if cursor is not None:
|
if self.cursor is not None and self.cursor.position != '':
|
||||||
kwargs = {self.ordering + '__gt': cursor.position}
|
kwargs = {self.ordering + '__gt': self.cursor.position}
|
||||||
queryset = queryset.filter(**kwargs)
|
queryset = queryset.filter(**kwargs)
|
||||||
|
|
||||||
results = list(queryset[:self.page_size + 1])
|
# The offset is used in order to deal with cases where we have
|
||||||
|
# items with an identical position. This allows the cursors
|
||||||
|
# to gracefully deal with non-unique fields as the ordering.
|
||||||
|
offset = 0 if (self.cursor is None) else self.cursor.offset
|
||||||
|
|
||||||
|
# We fetch an extra item in order to determine if there is a next page.
|
||||||
|
results = list(queryset[offset:offset + self.page_size + 1])
|
||||||
self.page = results[:self.page_size]
|
self.page = results[:self.page_size]
|
||||||
self.has_next = len(results) > len(self.page)
|
self.has_next = len(results) > len(self.page)
|
||||||
|
self.next_item = results[-1] if self.has_next else None
|
||||||
return self.page
|
return self.page
|
||||||
|
|
||||||
def get_next_link(self):
|
def get_next_link(self):
|
||||||
if not self.has_next:
|
if not self.has_next:
|
||||||
return None
|
return None
|
||||||
last_item = self.page[-1]
|
|
||||||
position = self.get_position_from_instance(last_item, self.ordering)
|
compare = self.get_position_from_instance(self.next_item, self.ordering)
|
||||||
cursor = Cursor(offset=0, reverse=False, position=position)
|
offset = 0
|
||||||
|
for item in reversed(self.page):
|
||||||
|
position = self.get_position_from_instance(item, self.ordering)
|
||||||
|
if position != compare:
|
||||||
|
# The item in this position and the item following it
|
||||||
|
# have different positions. We can use this position as
|
||||||
|
# our marker.
|
||||||
|
break
|
||||||
|
|
||||||
|
# The item in this postion has the same position as the item
|
||||||
|
# following it, we can't use it as a marker position, so increment
|
||||||
|
# the offset and keep seeking to the previous item.
|
||||||
|
compare = position
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
if self.cursor is None:
|
||||||
|
# There were no unique positions in the page, and we were
|
||||||
|
# on the first page, ie. there was no existing cursor.
|
||||||
|
# Our cursor will have an offset equal to the page size,
|
||||||
|
# but no position to filter against yet.
|
||||||
|
offset = self.page_size
|
||||||
|
position = ''
|
||||||
|
else:
|
||||||
|
# There were no unique positions in the page.
|
||||||
|
# Use the position from the existing cursor and increment
|
||||||
|
# it's offset by the page size.
|
||||||
|
offset = self.cursor.offset + self.page_size
|
||||||
|
position = self.cursor.position
|
||||||
|
|
||||||
|
cursor = Cursor(offset=offset, reverse=False, position=position)
|
||||||
encoded = encode_cursor(cursor)
|
encoded = encode_cursor(cursor)
|
||||||
return replace_query_param(self.base_url, self.cursor_query_param, encoded)
|
return replace_query_param(self.base_url, self.cursor_query_param, encoded)
|
||||||
|
|
||||||
|
@ -445,11 +482,3 @@ class CursorPagination(BasePagination):
|
||||||
|
|
||||||
def get_position_from_instance(self, instance, ordering):
|
def get_position_from_instance(self, instance, ordering):
|
||||||
return str(getattr(instance, ordering))
|
return str(getattr(instance, ordering))
|
||||||
|
|
||||||
# def decode_cursor(self, encoded, ordering):
|
|
||||||
# items = urlparse.parse_qs(b64decode(encoded))
|
|
||||||
# return items.get(ordering)[0]
|
|
||||||
|
|
||||||
# def encode_cursor(self, cursor, ordering):
|
|
||||||
# items = [(ordering, cursor)]
|
|
||||||
# return b64encode(urlparse.urlencode(items, doseq=True))
|
|
||||||
|
|
|
@ -447,7 +447,7 @@ class TestCursorPagination:
|
||||||
|
|
||||||
self.pagination = pagination.CursorPagination()
|
self.pagination = pagination.CursorPagination()
|
||||||
self.queryset = MockQuerySet(
|
self.queryset = MockQuerySet(
|
||||||
[MockObject(idx) for idx in range(1, 21)]
|
[MockObject(idx) for idx in range(1, 16)]
|
||||||
)
|
)
|
||||||
|
|
||||||
def paginate_queryset(self, request):
|
def paginate_queryset(self, request):
|
||||||
|
@ -479,16 +479,74 @@ class TestCursorPagination:
|
||||||
queryset = self.paginate_queryset(request)
|
queryset = self.paginate_queryset(request)
|
||||||
assert [item.created for item in queryset] == [11, 12, 13, 14, 15]
|
assert [item.created for item in queryset] == [11, 12, 13, 14, 15]
|
||||||
|
|
||||||
|
next_url = self.pagination.get_next_link()
|
||||||
|
assert next_url is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrazyCursorPagination:
|
||||||
|
"""
|
||||||
|
Unit tests for `pagination.CursorPagination`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
class MockObject(object):
|
||||||
|
def __init__(self, idx):
|
||||||
|
self.created = idx
|
||||||
|
|
||||||
|
class MockQuerySet(object):
|
||||||
|
def __init__(self, items):
|
||||||
|
self.items = items
|
||||||
|
|
||||||
|
def filter(self, created__gt):
|
||||||
|
return [
|
||||||
|
item for item in self.items
|
||||||
|
if item.created > int(created__gt)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __getitem__(self, sliced):
|
||||||
|
return self.items[sliced]
|
||||||
|
|
||||||
|
self.pagination = pagination.CursorPagination()
|
||||||
|
self.queryset = MockQuerySet([
|
||||||
|
MockObject(idx) for idx in [
|
||||||
|
1, 1, 1, 1, 1,
|
||||||
|
1, 1, 1, 1, 1,
|
||||||
|
1, 1, 2, 3, 4,
|
||||||
|
5, 6, 7, 8, 9
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
def paginate_queryset(self, request):
|
||||||
|
return list(self.pagination.paginate_queryset(self.queryset, request))
|
||||||
|
|
||||||
|
def test_following_cursor_identical_items(self):
|
||||||
|
request = Request(factory.get('/'))
|
||||||
|
queryset = self.paginate_queryset(request)
|
||||||
|
assert [item.created for item in queryset] == [1, 1, 1, 1, 1]
|
||||||
|
|
||||||
next_url = self.pagination.get_next_link()
|
next_url = self.pagination.get_next_link()
|
||||||
assert next_url
|
assert next_url
|
||||||
|
|
||||||
request = Request(factory.get(next_url))
|
request = Request(factory.get(next_url))
|
||||||
queryset = self.paginate_queryset(request)
|
queryset = self.paginate_queryset(request)
|
||||||
assert [item.created for item in queryset] == [16, 17, 18, 19, 20]
|
assert [item.created for item in queryset] == [1, 1, 1, 1, 1]
|
||||||
|
|
||||||
|
next_url = self.pagination.get_next_link()
|
||||||
|
assert next_url
|
||||||
|
|
||||||
|
request = Request(factory.get(next_url))
|
||||||
|
queryset = self.paginate_queryset(request)
|
||||||
|
assert [item.created for item in queryset] == [1, 1, 2, 3, 4]
|
||||||
|
|
||||||
|
next_url = self.pagination.get_next_link()
|
||||||
|
assert next_url
|
||||||
|
|
||||||
|
request = Request(factory.get(next_url))
|
||||||
|
queryset = self.paginate_queryset(request)
|
||||||
|
assert [item.created for item in queryset] == [5, 6, 7, 8, 9]
|
||||||
|
|
||||||
next_url = self.pagination.get_next_link()
|
next_url = self.pagination.get_next_link()
|
||||||
assert next_url is None
|
assert next_url is None
|
||||||
|
|
||||||
# assert content == {
|
# assert content == {
|
||||||
# 'results': [1, 2, 3, 4, 5],
|
# 'results': [1, 2, 3, 4, 5],
|
||||||
# 'previous': None,
|
# 'previous': None,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user