diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e639155..9086ee9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,8 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11"] - django-version: [4.0, 3.2] + python-version: ["3.10", "3.11"] + django-version: [5.0, 4.2] services: postgres: diff --git a/README.md b/README.md index 3b8499e..834d2a5 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ def posts_api(request, after=None): 'last_cursor': paginator.cursor(page[-1]) } return data + + +async def posts_api_async(request, after=None): + qs = Post.objects.all() + page_size = 10 + paginator = CursorPaginator(qs, ordering=('-created', '-id')) + page = await paginator.apage(first=page_size, after=after) + data = { + 'objects': [serialize_page(p) for p in page], + 'has_next_page': page.has_next, + 'last_cursor': paginator.cursor(page[-1]) + } + return data ``` Reverse pagination can be achieved by using the `last` and `before` arguments diff --git a/cursor_pagination.py b/cursor_pagination.py index fa29638..dbcc1d7 100644 --- a/cursor_pagination.py +++ b/cursor_pagination.py @@ -68,17 +68,13 @@ def _nulls_ordering(self, ordering, from_last=False): return nulls_ordering - - - def page(self, first=None, last=None, after=None, before=None): - qs = self.queryset - page_size = first or last - if page_size is None: - return CursorPage(qs, self) - + def _apply_paginator_arguments(self, qs, first=None, last=None, after=None, before=None): + """ + Apply first/after, last/before filtering to the queryset + """ from_last = last is not None if from_last and first is not None: - raise ValueError('Cannot process first and last') + raise ValueError('Cannot process first and last') if after is not None: qs = self.apply_cursor(after, qs, from_last=from_last) @@ -89,11 +85,12 @@ def page(self, first=None, last=None, after=None, before=None): if last is not None: qs = qs.order_by(*self._nulls_ordering(reverse_ordering(self.ordering), from_last=True))[:last + 1] - qs = list(qs) - items = qs[:page_size] - if last is not None: - items.reverse() - has_additional = len(qs) > len(items) + return qs + + def _get_cursor_page(self, items, has_additional, first, last, after, before): + """ + Create and return the cursor page for the given items + """ additional_kwargs = {} if first is not None: additional_kwargs['has_next'] = has_additional @@ -103,6 +100,33 @@ def page(self, first=None, last=None, after=None, before=None): additional_kwargs['has_next'] = bool(before) return CursorPage(items, self, **additional_kwargs) + def page(self, first=None, last=None, after=None, before=None): + qs = self.queryset + qs = self._apply_paginator_arguments(qs, first, last, after, before) + + qs = list(qs) + page_size = first if first is not None else last + items = qs[:page_size] + if last is not None: + items.reverse() + has_additional = len(qs) > len(items) + + return self._get_cursor_page(items, has_additional, first, last, after, before) + + async def apage(self, first=None, last=None, after=None, before=None): + qs = self.queryset + qs = self._apply_paginator_arguments(qs, first, last, after, before) + + page_size = first if first is not None else last + items = [] + async for item in qs[:page_size].aiterator(): + items.append(item) + if last is not None: + items.reverse() + has_additional = (await qs.acount()) > len(items) + + return self._get_cursor_page(items, has_additional, first, last, after, before) + def apply_cursor(self, cursor, queryset, from_last, reverse=False): position = self.decode_cursor(cursor) diff --git a/tests/tests.py b/tests/tests.py index 238dbd5..0603b44 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -18,6 +18,13 @@ def test_empty(self): self.assertFalse(page.has_next) self.assertFalse(page.has_previous) + async def test_async_empty(self): + paginator = CursorPaginator(Post.objects.all(), ('id',)) + page = await paginator.apage() + self.assertEqual(len(page), 0) + self.assertFalse(page.has_next) + self.assertFalse(page.has_previous) + def test_with_items(self): for i in range(20): Post.objects.create(name='Name %s' % i) @@ -27,6 +34,15 @@ def test_with_items(self): self.assertFalse(page.has_next) self.assertFalse(page.has_previous) + async def test_async_with_items(self): + for i in range(20): + await Post.objects.acreate(name='Name %s' % i) + paginator = CursorPaginator(Post.objects.all(), ('id',)) + page = await paginator.apage() + self.assertEqual(len(page), 20) + self.assertFalse(page.has_next) + self.assertFalse(page.has_previous) + class TestForwardPagination(TestCase): @@ -39,12 +55,30 @@ def setUpTestData(cls): cls.items.append(post) cls.paginator = CursorPaginator(Post.objects.all(), ('-created',)) + def test_first_page_zero(self): + page = self.paginator.page(first=0) + self.assertSequenceEqual(page, []) + self.assertTrue(page.has_next) + self.assertFalse(page.has_previous) + + async def test_async_first_page_zero(self): + page = await self.paginator.apage(first=0) + self.assertSequenceEqual(page, []) + self.assertTrue(page.has_next) + self.assertFalse(page.has_previous) + def test_first_page(self): page = self.paginator.page(first=2) self.assertSequenceEqual(page, [self.items[0], self.items[1]]) self.assertTrue(page.has_next) self.assertFalse(page.has_previous) + async def test_async_first_page(self): + page = await self.paginator.apage(first=2) + self.assertSequenceEqual(page, [self.items[0], self.items[1]]) + self.assertTrue(page.has_next) + self.assertFalse(page.has_previous) + def test_second_page(self): previous_page = self.paginator.page(first=2) cursor = self.paginator.cursor(previous_page[-1]) @@ -53,6 +87,14 @@ def test_second_page(self): self.assertTrue(page.has_next) self.assertTrue(page.has_previous) + async def test_async_second_page(self): + previous_page = await self.paginator.apage(first=2) + cursor = self.paginator.cursor(previous_page[-1]) + page = await self.paginator.apage(first=2, after=cursor) + self.assertSequenceEqual(page, [self.items[2], self.items[3]]) + self.assertTrue(page.has_next) + self.assertTrue(page.has_previous) + def test_last_page(self): previous_page = self.paginator.page(first=18) cursor = self.paginator.cursor(previous_page[-1]) @@ -61,6 +103,14 @@ def test_last_page(self): self.assertFalse(page.has_next) self.assertTrue(page.has_previous) + async def test_async_last_page(self): + previous_page = await self.paginator.apage(first=18) + cursor = self.paginator.cursor(previous_page[-1]) + page = await self.paginator.apage(first=2, after=cursor) + self.assertSequenceEqual(page, [self.items[18], self.items[19]]) + self.assertFalse(page.has_next) + self.assertTrue(page.has_previous) + def test_incomplete_last_page(self): previous_page = self.paginator.page(first=18) cursor = self.paginator.cursor(previous_page[-1]) @@ -69,6 +119,14 @@ def test_incomplete_last_page(self): self.assertFalse(page.has_next) self.assertTrue(page.has_previous) + async def test_async_incomplete_last_page(self): + previous_page = await self.paginator.apage(first=18) + cursor = self.paginator.cursor(previous_page[-1]) + page = await self.paginator.apage(first=100, after=cursor) + self.assertSequenceEqual(page, [self.items[18], self.items[19]]) + self.assertFalse(page.has_next) + self.assertTrue(page.has_previous) + class TestBackwardsPagination(TestCase): @@ -81,12 +139,30 @@ def setUpTestData(cls): cls.items.append(post) cls.paginator = CursorPaginator(Post.objects.all(), ('-created',)) + def test_first_page_zero(self): + page = self.paginator.page(last=0) + self.assertSequenceEqual(page, []) + self.assertTrue(page.has_previous) + self.assertFalse(page.has_next) + + async def test_async_first_page_zero(self): + page = await self.paginator.apage(last=0) + self.assertSequenceEqual(page, []) + self.assertTrue(page.has_previous) + self.assertFalse(page.has_next) + def test_first_page(self): page = self.paginator.page(last=2) self.assertSequenceEqual(page, [self.items[18], self.items[19]]) self.assertTrue(page.has_previous) self.assertFalse(page.has_next) + async def test_async_first_page(self): + page = await self.paginator.apage(last=2) + self.assertSequenceEqual(page, [self.items[18], self.items[19]]) + self.assertTrue(page.has_previous) + self.assertFalse(page.has_next) + def test_second_page(self): previous_page = self.paginator.page(last=2) cursor = self.paginator.cursor(previous_page[0]) @@ -95,6 +171,14 @@ def test_second_page(self): self.assertTrue(page.has_previous) self.assertTrue(page.has_next) + async def test_async_second_page(self): + previous_page = await self.paginator.apage(last=2) + cursor = self.paginator.cursor(previous_page[0]) + page = await self.paginator.apage(last=2, before=cursor) + self.assertSequenceEqual(page, [self.items[16], self.items[17]]) + self.assertTrue(page.has_previous) + self.assertTrue(page.has_next) + def test_last_page(self): previous_page = self.paginator.page(last=18) cursor = self.paginator.cursor(previous_page[0]) @@ -103,6 +187,14 @@ def test_last_page(self): self.assertFalse(page.has_previous) self.assertTrue(page.has_next) + async def test_async_last_page(self): + previous_page = await self.paginator.apage(last=18) + cursor = self.paginator.cursor(previous_page[0]) + page = await self.paginator.apage(last=2, before=cursor) + self.assertSequenceEqual(page, [self.items[0], self.items[1]]) + self.assertFalse(page.has_previous) + self.assertTrue(page.has_next) + def test_incomplete_last_page(self): previous_page = self.paginator.page(last=18) cursor = self.paginator.cursor(previous_page[0]) @@ -111,6 +203,14 @@ def test_incomplete_last_page(self): self.assertFalse(page.has_previous) self.assertTrue(page.has_next) + async def test_async_incomplete_last_page(self): + previous_page = await self.paginator.apage(last=18) + cursor = self.paginator.cursor(previous_page[0]) + page = await self.paginator.apage(last=100, before=cursor) + self.assertSequenceEqual(page, [self.items[0], self.items[1]]) + self.assertFalse(page.has_previous) + self.assertTrue(page.has_next) + class TestTwoFieldPagination(TestCase):