├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cursor_pagination.py ├── runtests.py ├── setup.py └── tests ├── __init__.py ├── models.py ├── settings.py └── tests.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | concurrency: 4 | group: ${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [master] 10 | pull_request: 11 | branches: [master] 12 | 13 | jobs: 14 | unit-tests: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | python-version: ["3.10", "3.11"] 20 | django-version: [5.0, 4.2] 21 | 22 | services: 23 | postgres: 24 | image: postgres 25 | ports: ["5432:5432"] 26 | 27 | env: 28 | POSTGRES_PASSWORD: postgres 29 | POSTGRES_DB: django-cursor-pagination 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Set up Python 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: ${{ matrix.python-version }} 37 | - name: Install Django 38 | run: pip install django==${{ matrix.django-version }} 39 | - run: pip install psycopg2 40 | - run: pip install -e . 41 | - run: python runtests.py 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.egg-info/ 4 | *.pyc 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.3.0] - 2022-12-07 11 | 12 | - Added support for async querysets by @bradleyoesch https://github.com/photocrowd/django-cursor-pagination/pull/49 13 | 14 | ## [0.2.1] - 2022-12-07 15 | 16 | ### Added 17 | 18 | - Added support for NULL value ordering by @untidy-hair https://github.com/photocrowd/django-cursor-pagination/pull/45 19 | - Fixes https://github.com/photocrowd/django-cursor-pagination/issues/44 20 | - Fixes https://github.com/photocrowd/django-cursor-pagination/issues/18 21 | - Added Python 3.11 to test matrix 22 | 23 | ## [0.2.0] - 2022-07-07 24 | 25 | ### Added 26 | 27 | - Support for different ordering in different directions by @yeahframeoff https://github.com/photocrowd/django-cursor-pagination/pull/27 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Photocrowd and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Photocrowd nor the names of its contributors may be 15 | used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django cursor pagination [![Build Status](https://travis-ci.org/photocrowd/django-cursor-pagination.svg?branch=master)](https://travis-ci.org/photocrowd/django-cursor-pagination) 2 | ======================== 3 | 4 | A cursor based pagination system for Django. Instead of refering to specific 5 | pages by number, we give every item in the queryset a cursor based on its 6 | ordering values. We then ask for subsequent records by asking for records 7 | *after* the cursor of the last item we currently have. Similarly we can ask for 8 | records *before* the cursor of the first item to navigate back through the 9 | list. 10 | 11 | This approach has two major advantages over traditional pagination. Firstly, it 12 | ensures that when new data is written into the table, records cannot be moved 13 | onto the next page. Secondly, it is much faster to query against the database 14 | as we are not using very large offset values. 15 | 16 | There are some significant drawbacks over "traditional" pagination. The data 17 | must be ordered by some database field(s) which are unique across all records. 18 | A typical use case would be ordering by a creation timestamp and an id. It is 19 | also more difficult to get the range of possible pages for the data. 20 | 21 | The inspiration for this project is largely taken from [this 22 | post](http://cra.mr/2011/03/08/building-cursors-for-the-disqus-api) by David 23 | Cramer, and the connection spec for [Relay 24 | GraphQL](https://facebook.github.io/relay/graphql/connections.htm). Much of the 25 | implementation is inspired by [Django rest framework's Cursor 26 | pagination.](https://github.com/tomchristie/django-rest-framework/blob/9b56dda91850a07cfaecbe972e0f586434b965c3/rest_framework/pagination.py#L407-L707). 27 | The main difference between the Disqus approach and the one used here is that 28 | we require the ordering to be totally determinate instead of using offsets. 29 | 30 | 31 | Installation 32 | ------------ 33 | 34 | ``` 35 | pip install django-cursor-pagination 36 | ``` 37 | 38 | Usage 39 | ----- 40 | 41 | ```python 42 | from cursor_pagination import CursorPaginator 43 | 44 | from myapp.models import Post 45 | 46 | 47 | def posts_api(request, after=None): 48 | qs = Post.objects.all() 49 | page_size = 10 50 | paginator = CursorPaginator(qs, ordering=('-created', '-id')) 51 | page = paginator.page(first=page_size, after=after) 52 | data = { 53 | 'objects': [serialize_page(p) for p in page], 54 | 'has_next_page': page.has_next, 55 | 'last_cursor': paginator.cursor(page[-1]) 56 | } 57 | return data 58 | 59 | 60 | async def posts_api_async(request, after=None): 61 | qs = Post.objects.all() 62 | page_size = 10 63 | paginator = CursorPaginator(qs, ordering=('-created', '-id')) 64 | page = await paginator.apage(first=page_size, after=after) 65 | data = { 66 | 'objects': [serialize_page(p) for p in page], 67 | 'has_next_page': page.has_next, 68 | 'last_cursor': paginator.cursor(page[-1]) 69 | } 70 | return data 71 | ``` 72 | 73 | Reverse pagination can be achieved by using the `last` and `before` arguments 74 | to `paginator.page`. 75 | 76 | Caveats 77 | ------- 78 | 79 | - The ordering specified **must** uniquely identify the object. 80 | - If a cursor is given and it does not refer to a valid object, the values of 81 | `has_previous` (for `after`) or `has_next` (for `before`) will always return 82 | `True`. 83 | - `NULL` comes at the end in query results with `ORDER BY` both for `ASC` and `DESC`. 84 | -------------------------------------------------------------------------------- /cursor_pagination.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | from collections.abc import Sequence 3 | 4 | from django.db.models import F, Q, TextField, Value 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class InvalidCursor(Exception): 9 | pass 10 | 11 | 12 | def reverse_ordering(ordering_tuple): 13 | """ 14 | Given an order_by tuple such as `('-created', 'uuid')` reverse the 15 | ordering and return a new tuple, eg. `('created', '-uuid')`. 16 | """ 17 | def invert(x): 18 | return x[1:] if (x.startswith('-')) else '-' + x 19 | 20 | return tuple([invert(item) for item in ordering_tuple]) 21 | 22 | 23 | class CursorPage(Sequence): 24 | def __init__(self, items, paginator, has_next=False, has_previous=False): 25 | self.items = items 26 | self.paginator = paginator 27 | self.has_next = has_next 28 | self.has_previous = has_previous 29 | 30 | def __len__(self): 31 | return len(self.items) 32 | 33 | def __getitem__(self, key): 34 | return self.items.__getitem__(key) 35 | 36 | def __repr__(self): 37 | return '' % (', '.join(repr(i) for i in self.items[:21]), ' (remaining truncated)' if len(self.items) > 21 else '') 38 | 39 | 40 | class CursorPaginator(object): 41 | delimiter = '|' 42 | none_string = '::None' 43 | invalid_cursor_message = _('Invalid cursor') 44 | 45 | def __init__(self, queryset, ordering): 46 | self.queryset = queryset.order_by(*self._nulls_ordering(ordering)) 47 | self.ordering = ordering 48 | 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)) 68 | 69 | return nulls_ordering 70 | 71 | def _apply_paginator_arguments(self, qs, first=None, last=None, after=None, before=None): 72 | """ 73 | Apply first/after, last/before filtering to the queryset 74 | """ 75 | from_last = last is not None 76 | if from_last and first is not None: 77 | raise ValueError('Cannot process first and last') 78 | 79 | if after is not None: 80 | qs = self.apply_cursor(after, qs, from_last=from_last) 81 | if before is not None: 82 | qs = self.apply_cursor(before, qs, from_last=from_last, reverse=True) 83 | if first is not None: 84 | qs = qs[:first + 1] 85 | if last is not None: 86 | qs = qs.order_by(*self._nulls_ordering(reverse_ordering(self.ordering), from_last=True))[:last + 1] 87 | 88 | return qs 89 | 90 | def _get_cursor_page(self, items, has_additional, first, last, after, before): 91 | """ 92 | Create and return the cursor page for the given items 93 | """ 94 | additional_kwargs = {} 95 | if first is not None: 96 | additional_kwargs['has_next'] = has_additional 97 | additional_kwargs['has_previous'] = bool(after) 98 | elif last is not None: 99 | additional_kwargs['has_previous'] = has_additional 100 | additional_kwargs['has_next'] = bool(before) 101 | return CursorPage(items, self, **additional_kwargs) 102 | 103 | def page(self, first=None, last=None, after=None, before=None): 104 | qs = self.queryset 105 | qs = self._apply_paginator_arguments(qs, first, last, after, before) 106 | 107 | qs = list(qs) 108 | page_size = first if first is not None else last 109 | items = qs[:page_size] 110 | if last is not None: 111 | items.reverse() 112 | has_additional = len(qs) > len(items) 113 | 114 | return self._get_cursor_page(items, has_additional, first, last, after, before) 115 | 116 | async def apage(self, first=None, last=None, after=None, before=None): 117 | qs = self.queryset 118 | qs = self._apply_paginator_arguments(qs, first, last, after, before) 119 | 120 | page_size = first if first is not None else last 121 | items = [] 122 | async for item in qs[:page_size].aiterator(): 123 | items.append(item) 124 | if last is not None: 125 | items.reverse() 126 | has_additional = (await qs.acount()) > len(items) 127 | 128 | return self._get_cursor_page(items, has_additional, first, last, after, before) 129 | 130 | def apply_cursor(self, cursor, queryset, from_last, reverse=False): 131 | 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) 205 | 206 | def decode_cursor(self, cursor): 207 | try: 208 | orderings = b64decode(cursor.encode('ascii')).decode('utf8') 209 | return [ordering if ordering != self.none_string else None for ordering in orderings.split(self.delimiter)] 210 | except (TypeError, ValueError): 211 | raise InvalidCursor(self.invalid_cursor_message) 212 | 213 | def encode_cursor(self, position): 214 | encoded = b64encode(self.delimiter.join(position).encode('utf8')).decode('ascii') 215 | return encoded 216 | 217 | def position_from_instance(self, instance): 218 | position = [] 219 | for order in self.ordering: 220 | parts = order.lstrip('-').split('__') 221 | attr = instance 222 | while parts: 223 | attr = getattr(attr, parts[0]) 224 | parts.pop(0) 225 | if attr is None: 226 | position.append(self.none_string) 227 | else: 228 | position.append(str(attr)) 229 | return position 230 | 231 | def cursor(self, instance): 232 | return self.encode_cursor(self.position_from_instance(instance)) 233 | 234 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | if __name__ == '__main__': 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | failures = test_runner.run_tests(sys.argv[1:]) 16 | sys.exit(bool(failures)) 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | 8 | setup( 9 | name="django-cursor-pagination", 10 | py_modules=["cursor_pagination"], 11 | version="0.3.0", 12 | description="Cursor based pagination for Django", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | author="Photocrowd", 16 | author_email="devteam@photocrowd.com", 17 | url="https://github.com/photocrowd/django-cursor-pagination", 18 | license="BSD", 19 | classifiers=[ 20 | "Development Status :: 5 - Production/Stable", 21 | "Framework :: Django", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: BSD License", 24 | "Natural Language :: English", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.7", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/photocrowd/django-cursor-pagination/910fb3b917629cdc5ca987d2241e36832077411f/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | 5 | class Author(models.Model): 6 | name = models.CharField(max_length=20) 7 | age = models.IntegerField(blank=True, null=True) 8 | created = models.DateTimeField(default=timezone.now) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | 14 | class Post(models.Model): 15 | author = models.ForeignKey(Author, blank=True, null=True, on_delete=models.CASCADE) 16 | name = models.CharField(max_length=20) 17 | created = models.DateTimeField(default=timezone.now) 18 | 19 | def __str__(self): 20 | return self.name 21 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 4 | 'NAME': 'django-cursor-pagination', 5 | 'USER': 'postgres', 6 | 'PASSWORD': 'postgres', 7 | 'HOST': 'localhost', 8 | 'PORT': '5432', 9 | } 10 | } 11 | 12 | INSTALLED_APPS = ['tests'] 13 | 14 | SECRET_KEY = 'secret' 15 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | 5 | from django.test import TestCase 6 | from django.utils import timezone 7 | 8 | from cursor_pagination import CursorPaginator 9 | 10 | from .models import Author, Post 11 | 12 | 13 | class TestNoArgs(TestCase): 14 | def test_empty(self): 15 | paginator = CursorPaginator(Post.objects.all(), ('id',)) 16 | page = paginator.page() 17 | self.assertEqual(len(page), 0) 18 | self.assertFalse(page.has_next) 19 | self.assertFalse(page.has_previous) 20 | 21 | async def test_async_empty(self): 22 | paginator = CursorPaginator(Post.objects.all(), ('id',)) 23 | page = await paginator.apage() 24 | self.assertEqual(len(page), 0) 25 | self.assertFalse(page.has_next) 26 | self.assertFalse(page.has_previous) 27 | 28 | def test_with_items(self): 29 | for i in range(20): 30 | Post.objects.create(name='Name %s' % i) 31 | paginator = CursorPaginator(Post.objects.all(), ('id',)) 32 | page = paginator.page() 33 | self.assertEqual(len(page), 20) 34 | self.assertFalse(page.has_next) 35 | self.assertFalse(page.has_previous) 36 | 37 | async def test_async_with_items(self): 38 | for i in range(20): 39 | await Post.objects.acreate(name='Name %s' % i) 40 | paginator = CursorPaginator(Post.objects.all(), ('id',)) 41 | page = await paginator.apage() 42 | self.assertEqual(len(page), 20) 43 | self.assertFalse(page.has_next) 44 | self.assertFalse(page.has_previous) 45 | 46 | 47 | class TestForwardPagination(TestCase): 48 | 49 | @classmethod 50 | def setUpTestData(cls): 51 | now = timezone.now() 52 | cls.items = [] 53 | for i in range(20): 54 | post = Post.objects.create(name='Name %s' % i, created=now - datetime.timedelta(hours=i)) 55 | cls.items.append(post) 56 | cls.paginator = CursorPaginator(Post.objects.all(), ('-created',)) 57 | 58 | def test_first_page_zero(self): 59 | page = self.paginator.page(first=0) 60 | self.assertSequenceEqual(page, []) 61 | self.assertTrue(page.has_next) 62 | self.assertFalse(page.has_previous) 63 | 64 | async def test_async_first_page_zero(self): 65 | page = await self.paginator.apage(first=0) 66 | self.assertSequenceEqual(page, []) 67 | self.assertTrue(page.has_next) 68 | self.assertFalse(page.has_previous) 69 | 70 | def test_first_page(self): 71 | page = self.paginator.page(first=2) 72 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 73 | self.assertTrue(page.has_next) 74 | self.assertFalse(page.has_previous) 75 | 76 | async def test_async_first_page(self): 77 | page = await self.paginator.apage(first=2) 78 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 79 | self.assertTrue(page.has_next) 80 | self.assertFalse(page.has_previous) 81 | 82 | def test_second_page(self): 83 | previous_page = self.paginator.page(first=2) 84 | cursor = self.paginator.cursor(previous_page[-1]) 85 | page = self.paginator.page(first=2, after=cursor) 86 | self.assertSequenceEqual(page, [self.items[2], self.items[3]]) 87 | self.assertTrue(page.has_next) 88 | self.assertTrue(page.has_previous) 89 | 90 | async def test_async_second_page(self): 91 | previous_page = await self.paginator.apage(first=2) 92 | cursor = self.paginator.cursor(previous_page[-1]) 93 | page = await self.paginator.apage(first=2, after=cursor) 94 | self.assertSequenceEqual(page, [self.items[2], self.items[3]]) 95 | self.assertTrue(page.has_next) 96 | self.assertTrue(page.has_previous) 97 | 98 | def test_last_page(self): 99 | previous_page = self.paginator.page(first=18) 100 | cursor = self.paginator.cursor(previous_page[-1]) 101 | page = self.paginator.page(first=2, after=cursor) 102 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 103 | self.assertFalse(page.has_next) 104 | self.assertTrue(page.has_previous) 105 | 106 | async def test_async_last_page(self): 107 | previous_page = await self.paginator.apage(first=18) 108 | cursor = self.paginator.cursor(previous_page[-1]) 109 | page = await self.paginator.apage(first=2, after=cursor) 110 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 111 | self.assertFalse(page.has_next) 112 | self.assertTrue(page.has_previous) 113 | 114 | def test_incomplete_last_page(self): 115 | previous_page = self.paginator.page(first=18) 116 | cursor = self.paginator.cursor(previous_page[-1]) 117 | page = self.paginator.page(first=100, after=cursor) 118 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 119 | self.assertFalse(page.has_next) 120 | self.assertTrue(page.has_previous) 121 | 122 | async def test_async_incomplete_last_page(self): 123 | previous_page = await self.paginator.apage(first=18) 124 | cursor = self.paginator.cursor(previous_page[-1]) 125 | page = await self.paginator.apage(first=100, after=cursor) 126 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 127 | self.assertFalse(page.has_next) 128 | self.assertTrue(page.has_previous) 129 | 130 | 131 | class TestBackwardsPagination(TestCase): 132 | 133 | @classmethod 134 | def setUpTestData(cls): 135 | now = timezone.now() 136 | cls.items = [] 137 | for i in range(20): 138 | post = Post.objects.create(name='Name %s' % i, created=now - datetime.timedelta(hours=i)) 139 | cls.items.append(post) 140 | cls.paginator = CursorPaginator(Post.objects.all(), ('-created',)) 141 | 142 | def test_first_page_zero(self): 143 | page = self.paginator.page(last=0) 144 | self.assertSequenceEqual(page, []) 145 | self.assertTrue(page.has_previous) 146 | self.assertFalse(page.has_next) 147 | 148 | async def test_async_first_page_zero(self): 149 | page = await self.paginator.apage(last=0) 150 | self.assertSequenceEqual(page, []) 151 | self.assertTrue(page.has_previous) 152 | self.assertFalse(page.has_next) 153 | 154 | def test_first_page(self): 155 | page = self.paginator.page(last=2) 156 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 157 | self.assertTrue(page.has_previous) 158 | self.assertFalse(page.has_next) 159 | 160 | async def test_async_first_page(self): 161 | page = await self.paginator.apage(last=2) 162 | self.assertSequenceEqual(page, [self.items[18], self.items[19]]) 163 | self.assertTrue(page.has_previous) 164 | self.assertFalse(page.has_next) 165 | 166 | def test_second_page(self): 167 | previous_page = self.paginator.page(last=2) 168 | cursor = self.paginator.cursor(previous_page[0]) 169 | page = self.paginator.page(last=2, before=cursor) 170 | self.assertSequenceEqual(page, [self.items[16], self.items[17]]) 171 | self.assertTrue(page.has_previous) 172 | self.assertTrue(page.has_next) 173 | 174 | async def test_async_second_page(self): 175 | previous_page = await self.paginator.apage(last=2) 176 | cursor = self.paginator.cursor(previous_page[0]) 177 | page = await self.paginator.apage(last=2, before=cursor) 178 | self.assertSequenceEqual(page, [self.items[16], self.items[17]]) 179 | self.assertTrue(page.has_previous) 180 | self.assertTrue(page.has_next) 181 | 182 | def test_last_page(self): 183 | previous_page = self.paginator.page(last=18) 184 | cursor = self.paginator.cursor(previous_page[0]) 185 | page = self.paginator.page(last=2, before=cursor) 186 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 187 | self.assertFalse(page.has_previous) 188 | self.assertTrue(page.has_next) 189 | 190 | async def test_async_last_page(self): 191 | previous_page = await self.paginator.apage(last=18) 192 | cursor = self.paginator.cursor(previous_page[0]) 193 | page = await self.paginator.apage(last=2, before=cursor) 194 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 195 | self.assertFalse(page.has_previous) 196 | self.assertTrue(page.has_next) 197 | 198 | def test_incomplete_last_page(self): 199 | previous_page = self.paginator.page(last=18) 200 | cursor = self.paginator.cursor(previous_page[0]) 201 | page = self.paginator.page(last=100, before=cursor) 202 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 203 | self.assertFalse(page.has_previous) 204 | self.assertTrue(page.has_next) 205 | 206 | async def test_async_incomplete_last_page(self): 207 | previous_page = await self.paginator.apage(last=18) 208 | cursor = self.paginator.cursor(previous_page[0]) 209 | page = await self.paginator.apage(last=100, before=cursor) 210 | self.assertSequenceEqual(page, [self.items[0], self.items[1]]) 211 | self.assertFalse(page.has_previous) 212 | self.assertTrue(page.has_next) 213 | 214 | 215 | class TestTwoFieldPagination(TestCase): 216 | 217 | @classmethod 218 | def setUpTestData(cls): 219 | now = timezone.now() 220 | cls.items = [] 221 | data = [ 222 | (now, 'B 横浜市'), 223 | (now, 'C'), 224 | (now, 'D 横浜市'), 225 | (now + datetime.timedelta(hours=1), 'A'), 226 | ] 227 | for time, name in data: 228 | post = Post.objects.create(name=name, created=time) 229 | cls.items.append(post) 230 | 231 | def test_order(self): 232 | paginator = CursorPaginator(Post.objects.all(), ('created', 'name')) 233 | previous_page = paginator.page(first=2) 234 | self.assertSequenceEqual(previous_page, [self.items[0], self.items[1]]) 235 | cursor = paginator.cursor(previous_page[-1]) 236 | page = paginator.page(first=2, after=cursor) 237 | self.assertSequenceEqual(page, [self.items[2], self.items[3]]) 238 | 239 | def test_reverse_order(self): 240 | paginator = CursorPaginator(Post.objects.all(), ('-created', '-name')) 241 | previous_page = paginator.page(first=2) 242 | self.assertSequenceEqual(previous_page, [self.items[3], self.items[2]]) 243 | cursor = paginator.cursor(previous_page[-1]) 244 | page = paginator.page(first=2, after=cursor) 245 | self.assertSequenceEqual(page, [self.items[1], self.items[0]]) 246 | 247 | def test_mixed_order(self): 248 | paginator = CursorPaginator(Post.objects.all(), ('created', '-name')) 249 | previous_page = paginator.page(first=2) 250 | self.assertSequenceEqual(previous_page, [self.items[2], self.items[1]]) 251 | cursor = paginator.cursor(previous_page[-1]) 252 | page = paginator.page(first=2, after=cursor) 253 | self.assertSequenceEqual(page, [self.items[0], self.items[3]]) 254 | 255 | 256 | class TestRelationships(TestCase): 257 | @classmethod 258 | def setUpTestData(cls): 259 | cls.items = [] 260 | author_1 = Author.objects.create(name='Ana') 261 | author_2 = Author.objects.create(name='Bob') 262 | for i in range(20): 263 | post = Post.objects.create(name='Name %02d' % i, author=author_1 if i % 2 else author_2) 264 | cls.items.append(post) 265 | cls.paginator = CursorPaginator(Post.objects.all(), ('author__name', 'name')) 266 | 267 | def test_first_page(self): 268 | page = self.paginator.page(first=2) 269 | self.assertSequenceEqual(page, [self.items[1], self.items[3]]) 270 | 271 | def test_after_page(self): 272 | cursor = self.paginator.cursor(self.items[17]) 273 | page = self.paginator.page(first=2, after=cursor) 274 | self.assertSequenceEqual(page, [self.items[19], self.items[0]]) 275 | 276 | 277 | class TestNoArgsWithNull(TestCase): 278 | def test_with_items(self): 279 | authors = [ 280 | Author.objects.create(name='Alice', age=30), 281 | Author.objects.create(name='Bob', age=None), 282 | Author.objects.create(name='Carol', age=None), 283 | Author.objects.create(name='Dave', age=40) 284 | ] 285 | paginator = CursorPaginator(Author.objects.all(), ('-age', 'id',)) 286 | page = paginator.page() 287 | self.assertSequenceEqual(page, [authors[3], authors[0], authors[1], authors[2]]) 288 | self.assertFalse(page.has_next) 289 | self.assertFalse(page.has_previous) 290 | 291 | 292 | class TestForwardNullPagination(TestCase): 293 | # When there are NULL values, there needs to be another key to make the sort 294 | # unique as README Caveats say 295 | @classmethod 296 | def setUpTestData(cls): 297 | now = timezone.now() 298 | cls.items = [] 299 | for i in range(2): # index 0-1 300 | author = Author.objects.create(name='Name %s' % i, age=i+20, created=now - datetime.timedelta(hours=i)) 301 | cls.items.append(author) 302 | for i in range(5): # index 2-6 303 | author = Author.objects.create(name='NameNull %s' % (i + 2), age=None, created=now - datetime.timedelta(hours=i)) 304 | cls.items.append(author) 305 | cls.paginator = CursorPaginator(Author.objects.all(), ('-age', '-created',)) 306 | # [1, 0, 2, 3, 4, 5, 6] 307 | 308 | def test_first_page(self): 309 | page = self.paginator.page(first=3) 310 | self.assertSequenceEqual(page, [self.items[1], self.items[0], self.items[2]]) 311 | self.assertTrue(page.has_next) 312 | self.assertFalse(page.has_previous) 313 | 314 | def test_second_page(self): 315 | previous_page = self.paginator.page(first=3) 316 | cursor = self.paginator.cursor(previous_page[-1]) 317 | page = self.paginator.page(first=2, after=cursor) 318 | self.assertSequenceEqual(page, [self.items[3], self.items[4]]) 319 | self.assertTrue(page.has_next) 320 | self.assertTrue(page.has_previous) 321 | 322 | def test_last_page(self): 323 | previous_page = self.paginator.page(first=5) 324 | cursor = self.paginator.cursor(previous_page[-1]) 325 | page = self.paginator.page(first=10, after=cursor) 326 | self.assertSequenceEqual(page, [self.items[5], self.items[6]]) 327 | self.assertFalse(page.has_next) 328 | self.assertTrue(page.has_previous) 329 | 330 | 331 | class TestBackwardsNullPagination(TestCase): 332 | @classmethod 333 | def setUpTestData(cls): 334 | now = timezone.now() 335 | cls.items = [] 336 | for i in range(2): # index 0-1 337 | author = Author.objects.create(name='Name %s' % i, age=i+20, created=now - datetime.timedelta(hours=i)) 338 | cls.items.append(author) 339 | for i in range(5): # index 2-6 340 | author = Author.objects.create(name='NameNull %s' % (i + 2), age=None, created=now - datetime.timedelta(hours=i)) 341 | cls.items.append(author) 342 | cls.paginator = CursorPaginator(Author.objects.all(), ('-age', '-created',)) 343 | # => [1, 0, 2, 3, 4, 5, 6] 344 | 345 | def test_first_page(self): 346 | page = self.paginator.page(last=2) 347 | self.assertSequenceEqual(page, [self.items[5], self.items[6]]) 348 | self.assertTrue(page.has_previous) 349 | self.assertFalse(page.has_next) 350 | 351 | def test_second_page(self): 352 | previous_page = self.paginator.page(last=2) 353 | cursor = self.paginator.cursor(previous_page[0]) 354 | page = self.paginator.page(last=4, before=cursor) 355 | self.assertSequenceEqual(page, [self.items[0], self.items[2], self.items[3], self.items[4]]) 356 | self.assertTrue(page.has_previous) 357 | self.assertTrue(page.has_next) 358 | 359 | def test_last_page(self): 360 | previous_page = self.paginator.page(last=6) 361 | cursor = self.paginator.cursor(previous_page[0]) 362 | page = self.paginator.page(last=10, before=cursor) 363 | self.assertSequenceEqual(page, [self.items[1]]) 364 | self.assertFalse(page.has_previous) 365 | self.assertTrue(page.has_next) 366 | 367 | 368 | class TestRelationshipsWithNull(TestCase): 369 | @classmethod 370 | def setUpTestData(cls): 371 | cls.items = [] 372 | author_1 = Author.objects.create(name='Ana', age=25) # odd number 373 | author_2 = Author.objects.create(name='Bob') # even number 374 | for i in range(20): 375 | post = Post.objects.create(name='Name %02d' % i, author=author_1 if i % 2 else author_2) 376 | cls.items.append(post) 377 | cls.paginator = CursorPaginator(Post.objects.all(), ('author__age', 'name')) 378 | 379 | def test_first_page(self): 380 | page = self.paginator.page(first=2) 381 | self.assertSequenceEqual(page, [self.items[1], self.items[3]]) # Ana comes first 382 | 383 | def test_after_page(self): 384 | cursor = self.paginator.cursor(self.items[17]) 385 | page = self.paginator.page(first=2, after=cursor) 386 | self.assertSequenceEqual(page, [self.items[19], self.items[0]]) 387 | --------------------------------------------------------------------------------