Support for tuple ordering in cursor pagination

This commit is contained in:
Tom Christie 2015-01-22 15:07:01 +00:00
parent 38a2ed6f62
commit 83a82b44a5
2 changed files with 68 additions and 47 deletions

View File

@ -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)

View File

@ -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