Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support async queryset evaluation #49

Merged
merged 11 commits into from
Jul 1, 2024
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 41 additions & 10 deletions cursor_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def __getitem__(self, key):
def __repr__(self):
return '<Page: [%s%s]>' % (', '.join(repr(i) for i in self.items[:21]), ' (remaining truncated)' if len(self.items) > 21 else '')

async def acount(self):
return await self.items.acount()
bradleyoesch marked this conversation as resolved.
Show resolved Hide resolved


class CursorPaginator(object):
delimiter = '|'
Expand Down Expand Up @@ -68,17 +71,17 @@ 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
def _apply_paginator_arguments(self, qs, first=None, last=None, after=None, before=None):
"""
Apply first/after, last/before filtering to the queryset
"""
page_size = first or last
if page_size is None:
return CursorPage(qs, self)

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)
Expand All @@ -89,11 +92,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
Expand All @@ -103,6 +107,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 or 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 or last
items = []
async for item in qs[:page_size].aiterator():
items.append(item)
if last is not None:
items.reverse()
bradleyoesch marked this conversation as resolved.
Show resolved Hide resolved
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)

Expand Down
76 changes: 76 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):

Expand All @@ -45,6 +61,12 @@ def test_first_page(self):
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)
bradleyoesch marked this conversation as resolved.
Show resolved Hide resolved

def test_second_page(self):
previous_page = self.paginator.page(first=2)
cursor = self.paginator.cursor(previous_page[-1])
Expand All @@ -53,6 +75,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])
Expand All @@ -61,6 +91,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])
Expand All @@ -69,6 +107,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):

Expand All @@ -87,6 +133,12 @@ def test_first_page(self):
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])
Expand All @@ -95,6 +147,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])
Expand All @@ -103,6 +163,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])
Expand All @@ -111,6 +179,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):

Expand Down
Loading