mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-11-22 17:47:04 +03:00
Support for tuple ordering in cursor pagination
This commit is contained in:
parent
38a2ed6f62
commit
83a82b44a5
|
@ -20,12 +20,12 @@ from rest_framework.utils.urls import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _strict_positive_int(integer_string, cutoff=None):
|
def _positive_int(integer_string, strict=False, cutoff=None):
|
||||||
"""
|
"""
|
||||||
Cast a string to a strictly positive integer.
|
Cast a string to a strictly positive integer.
|
||||||
"""
|
"""
|
||||||
ret = int(integer_string)
|
ret = int(integer_string)
|
||||||
if ret <= 0:
|
if ret < 0 or (ret == 0 and strict):
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
if cutoff:
|
if cutoff:
|
||||||
ret = min(ret, cutoff)
|
ret = min(ret, cutoff)
|
||||||
|
@ -126,6 +126,47 @@ def _get_page_links(page_numbers, current, url_func):
|
||||||
return page_links
|
return page_links
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_cursor(encoded):
|
||||||
|
"""
|
||||||
|
Given a string representing an encoded cursor, return a `Cursor` instance.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
querystring = b64decode(encoded.encode('ascii')).decode('ascii')
|
||||||
|
tokens = urlparse.parse_qs(querystring, keep_blank_values=True)
|
||||||
|
offset = _positive_int(tokens['offset'][0])
|
||||||
|
reverse = bool(int(tokens['reverse'][0]))
|
||||||
|
position = tokens.get('position', [None])[0]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Cursor(offset=offset, reverse=reverse, position=position)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_cursor(cursor):
|
||||||
|
"""
|
||||||
|
Given a Cursor instance, return an encoded string representation.
|
||||||
|
"""
|
||||||
|
tokens = {
|
||||||
|
'offset': str(cursor.offset),
|
||||||
|
'reverse': '1' if cursor.reverse else '0',
|
||||||
|
}
|
||||||
|
if cursor.position is not None:
|
||||||
|
tokens['position'] = cursor.position
|
||||||
|
|
||||||
|
querystring = urlparse.urlencode(tokens, doseq=True)
|
||||||
|
return b64encode(querystring.encode('ascii')).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse_ordering(ordering_tuple):
|
||||||
|
"""
|
||||||
|
Given an order_by tuple such as `('-created', 'uuid')` reverse the
|
||||||
|
ordering and return a new tuple, eg. `('created', '-uuid')`.
|
||||||
|
"""
|
||||||
|
invert = lambda x: x[1:] if (x.startswith('-')) else '-' + x
|
||||||
|
return tuple([invert(item) for item in ordering_tuple])
|
||||||
|
|
||||||
|
|
||||||
|
Cursor = namedtuple('Cursor', ['offset', 'reverse', 'position'])
|
||||||
PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break'])
|
PageLink = namedtuple('PageLink', ['url', 'number', 'is_active', 'is_break'])
|
||||||
|
|
||||||
PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True)
|
PAGE_BREAK = PageLink(url=None, number=None, is_active=False, is_break=True)
|
||||||
|
@ -228,8 +269,9 @@ class PageNumberPagination(BasePagination):
|
||||||
def get_page_size(self, request):
|
def get_page_size(self, request):
|
||||||
if self.paginate_by_param:
|
if self.paginate_by_param:
|
||||||
try:
|
try:
|
||||||
return _strict_positive_int(
|
return _positive_int(
|
||||||
request.query_params[self.paginate_by_param],
|
request.query_params[self.paginate_by_param],
|
||||||
|
strict=True,
|
||||||
cutoff=self.max_paginate_by
|
cutoff=self.max_paginate_by
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
|
@ -312,7 +354,7 @@ class LimitOffsetPagination(BasePagination):
|
||||||
def get_limit(self, request):
|
def get_limit(self, request):
|
||||||
if self.limit_query_param:
|
if self.limit_query_param:
|
||||||
try:
|
try:
|
||||||
return _strict_positive_int(
|
return _positive_int(
|
||||||
request.query_params[self.limit_query_param],
|
request.query_params[self.limit_query_param],
|
||||||
cutoff=self.max_limit
|
cutoff=self.max_limit
|
||||||
)
|
)
|
||||||
|
@ -323,7 +365,7 @@ class LimitOffsetPagination(BasePagination):
|
||||||
|
|
||||||
def get_offset(self, request):
|
def get_offset(self, request):
|
||||||
try:
|
try:
|
||||||
return _strict_positive_int(
|
return _positive_int(
|
||||||
request.query_params[self.offset_query_param],
|
request.query_params[self.offset_query_param],
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
|
@ -384,36 +426,10 @@ class LimitOffsetPagination(BasePagination):
|
||||||
return template.render(context)
|
return template.render(context)
|
||||||
|
|
||||||
|
|
||||||
Cursor = namedtuple('Cursor', ['offset', 'reverse', 'position'])
|
|
||||||
|
|
||||||
|
|
||||||
def decode_cursor(encoded):
|
|
||||||
try:
|
|
||||||
querystring = b64decode(encoded.encode('ascii')).decode('ascii')
|
|
||||||
tokens = urlparse.parse_qs(querystring, keep_blank_values=True)
|
|
||||||
offset = int(tokens['offset'][0])
|
|
||||||
reverse = bool(int(tokens['reverse'][0]))
|
|
||||||
position = tokens['position'][0]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return Cursor(offset=offset, reverse=reverse, position=position)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_cursor(cursor):
|
|
||||||
tokens = {
|
|
||||||
'offset': str(cursor.offset),
|
|
||||||
'reverse': '1' if cursor.reverse else '0',
|
|
||||||
'position': cursor.position
|
|
||||||
}
|
|
||||||
querystring = urlparse.urlencode(tokens, doseq=True)
|
|
||||||
return b64encode(querystring.encode('ascii')).decode('ascii')
|
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(BasePagination):
|
class CursorPagination(BasePagination):
|
||||||
# TODO: handle queries with '' as a legitimate position
|
|
||||||
# Support case where ordering is already negative
|
# Support case where ordering is already negative
|
||||||
# Support tuple orderings
|
# Support tuple orderings
|
||||||
|
# Determine how/if True, False and None positions work
|
||||||
cursor_query_param = 'cursor'
|
cursor_query_param = 'cursor'
|
||||||
page_size = api_settings.PAGINATE_BY
|
page_size = api_settings.PAGINATE_BY
|
||||||
invalid_cursor_message = _('Invalid cursor')
|
invalid_cursor_message = _('Invalid cursor')
|
||||||
|
@ -426,25 +442,26 @@ 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:
|
||||||
self.cursor = None
|
self.cursor = None
|
||||||
(offset, reverse, current_position) = (0, False, '')
|
(offset, reverse, current_position) = (0, False, None)
|
||||||
else:
|
else:
|
||||||
self.cursor = decode_cursor(encoded)
|
self.cursor = _decode_cursor(encoded)
|
||||||
if self.cursor is None:
|
if self.cursor is None:
|
||||||
raise NotFound(self.invalid_cursor_message)
|
raise NotFound(self.invalid_cursor_message)
|
||||||
(offset, reverse, current_position) = self.cursor
|
(offset, reverse, current_position) = self.cursor
|
||||||
|
|
||||||
# Cursor pagination always enforces an ordering.
|
# Cursor pagination always enforces an ordering.
|
||||||
if reverse:
|
if reverse:
|
||||||
queryset = queryset.order_by('-' + self.ordering)
|
queryset = queryset.order_by(_reverse_ordering(self.ordering))
|
||||||
else:
|
else:
|
||||||
queryset = queryset.order_by(self.ordering)
|
queryset = queryset.order_by(self.ordering)
|
||||||
|
|
||||||
# If we have a cursor with a fixed position then filter by that.
|
# If we have a cursor with a fixed position then filter by that.
|
||||||
if current_position != '':
|
if current_position is not None:
|
||||||
|
primary_ordering_attr = self.ordering[0].lstrip('-')
|
||||||
if self.cursor.reverse:
|
if self.cursor.reverse:
|
||||||
kwargs = {self.ordering + '__lt': current_position}
|
kwargs = {primary_ordering_attr + '__lt': current_position}
|
||||||
else:
|
else:
|
||||||
kwargs = {self.ordering + '__gt': current_position}
|
kwargs = {primary_ordering_attr + '__gt': current_position}
|
||||||
queryset = queryset.filter(**kwargs)
|
queryset = queryset.filter(**kwargs)
|
||||||
|
|
||||||
# If we have an offset cursor then offset the entire page by that amount.
|
# If we have an offset cursor then offset the entire page by that amount.
|
||||||
|
@ -468,7 +485,7 @@ class CursorPagination(BasePagination):
|
||||||
|
|
||||||
if reverse:
|
if reverse:
|
||||||
# Determine next and previous positions for reverse cursors.
|
# Determine next and previous positions for reverse cursors.
|
||||||
self.has_next = current_position != '' or offset > 0
|
self.has_next = (current_position is not None) or (offset > 0)
|
||||||
self.has_previous = has_following_postion
|
self.has_previous = has_following_postion
|
||||||
if self.has_next:
|
if self.has_next:
|
||||||
self.next_position = current_position
|
self.next_position = current_position
|
||||||
|
@ -477,7 +494,7 @@ class CursorPagination(BasePagination):
|
||||||
else:
|
else:
|
||||||
# Determine next and previous positions for forward cursors.
|
# Determine next and previous positions for forward cursors.
|
||||||
self.has_next = has_following_postion
|
self.has_next = has_following_postion
|
||||||
self.has_previous = current_position != '' or offset > 0
|
self.has_previous = (current_position is not None) or (offset > 0)
|
||||||
if self.has_next:
|
if self.has_next:
|
||||||
self.next_position = following_position
|
self.next_position = following_position
|
||||||
if self.has_previous:
|
if self.has_previous:
|
||||||
|
@ -518,7 +535,7 @@ class CursorPagination(BasePagination):
|
||||||
# Our cursor will have an offset equal to the page size,
|
# Our cursor will have an offset equal to the page size,
|
||||||
# but no position to filter against yet.
|
# but no position to filter against yet.
|
||||||
offset = self.page_size
|
offset = self.page_size
|
||||||
position = ''
|
position = None
|
||||||
elif self.cursor.reverse:
|
elif self.cursor.reverse:
|
||||||
# The change in direction will introduce a paging artifact,
|
# The change in direction will introduce a paging artifact,
|
||||||
# where we end up skipping forward a few extra items.
|
# where we end up skipping forward a few extra items.
|
||||||
|
@ -531,7 +548,7 @@ class CursorPagination(BasePagination):
|
||||||
position = self.previous_position
|
position = self.previous_position
|
||||||
|
|
||||||
cursor = Cursor(offset=offset, reverse=False, position=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)
|
||||||
|
|
||||||
def get_previous_link(self):
|
def get_previous_link(self):
|
||||||
|
@ -567,7 +584,7 @@ class CursorPagination(BasePagination):
|
||||||
# Our cursor will have an offset equal to the page size,
|
# Our cursor will have an offset equal to the page size,
|
||||||
# but no position to filter against yet.
|
# but no position to filter against yet.
|
||||||
offset = self.page_size
|
offset = self.page_size
|
||||||
position = ''
|
position = None
|
||||||
elif self.cursor.reverse:
|
elif self.cursor.reverse:
|
||||||
# Use the position from the existing cursor and increment
|
# Use the position from the existing cursor and increment
|
||||||
# it's offset by the page size.
|
# it's offset by the page size.
|
||||||
|
@ -580,11 +597,15 @@ class CursorPagination(BasePagination):
|
||||||
position = self.next_position
|
position = self.next_position
|
||||||
|
|
||||||
cursor = Cursor(offset=offset, reverse=True, position=position)
|
cursor = Cursor(offset=offset, reverse=True, 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)
|
||||||
|
|
||||||
def get_ordering(self):
|
def get_ordering(self):
|
||||||
return 'created'
|
"""
|
||||||
|
Return a tuple of strings, that may be used in an `order_by` method.
|
||||||
|
"""
|
||||||
|
return ('created',)
|
||||||
|
|
||||||
def _get_position_from_instance(self, instance, ordering):
|
def _get_position_from_instance(self, instance, ordering):
|
||||||
return str(getattr(instance, ordering))
|
attr = getattr(instance, ordering[0])
|
||||||
|
return six.text_type(attr)
|
||||||
|
|
|
@ -450,7 +450,7 @@ class TestCursorPagination:
|
||||||
])
|
])
|
||||||
|
|
||||||
def order_by(self, ordering):
|
def order_by(self, ordering):
|
||||||
if ordering.startswith('-'):
|
if ordering[0].startswith('-'):
|
||||||
return MockQuerySet(list(reversed(self.items)))
|
return MockQuerySet(list(reversed(self.items)))
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user