Skip to content

Commit 62cf387

Browse files
authored
Support multiple fields; Benchmarks (#8)
1 parent 52576bc commit 62cf387

15 files changed

+1179
-63
lines changed

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[report]
22
omit =
3+
runbench.py
34
runtests.py
45
setup.py

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/mysite
2+
/manage.py
3+
14
# Byte-compiled / optimized / DLL files
25
__pycache__/
36
*.py[cod]
@@ -58,4 +61,4 @@ docs/_build/
5861
# Vim
5962
*~
6063
*.swp
61-
*.swo
64+
*.swo

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
1.2.0
2+
==================
3+
4+
* Support paginating by multiple fields
5+
16
1.1.0
27
==================
38

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,16 @@ sdist: test clean
1313
release: sdist
1414
twine upload dist/*
1515

16+
bench_build:
17+
cd bench && docker-compose build
18+
19+
bench_clean:
20+
cd bench \
21+
&& docker-compose stop \
22+
&& docker-compose rm --force -v
23+
24+
bench_run:
25+
cd bench \
26+
&& docker-compose run --rm --entrypoint '/bin/sh -c' paginator '/bin/sh'
27+
1628
.PHONY: clean docs test sdist release

README.md

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ based on the top/last keyset
2222
This approach has two main advantages over the *OFFSET/LIMIT* approach:
2323

2424
* is correct: unlike the *offset/limit* based approach it correctly handles
25-
new entries and deleted entries. Last row of Page 4 does not show up as first
26-
row of Page 5 just because row 23 on Page 2 was deleted in the meantime.
27-
Nor do rows mysteriously vanish between pages. These anomalies are common
28-
with the *offset/limit* based approach, but the *keyset* based solution does
29-
a much better job at avoiding them.
25+
new entries and deleted entries. Last row of Page 4 does not show up as first
26+
row of Page 5 just because row 23 on Page 2 was deleted in the meantime.
27+
Nor do rows mysteriously vanish between pages. These anomalies are common
28+
with the *offset/limit* based approach, but the *keyset* based solution does
29+
a much better job at avoiding them.
3030
* is fast: all operations can be solved with a fast row positioning followed
31-
by a range scan in the desired direction.
31+
by a range scan in the desired direction.
3232

3333
For a full explanation go to
3434
[the seek method](http://use-the-index-luke.com/sql/partial-results/fetch-next-page)
@@ -46,11 +46,6 @@ infinite-scroll-pagination requires the following software to be installed:
4646
pip install django-infinite-scroll-pagination
4747
```
4848

49-
## Django Rest Framework (DRF)
50-
51-
DRF has the built-in `CursorPagination`
52-
that is similar to this lib. Use that instead.
53-
5449
## Usage
5550

5651
This example paginates by a `created_at` date field:
@@ -102,12 +97,24 @@ def pagination_ajax(request):
10297
return HttpResponse(json.dumps(data), content_type="application/json")
10398
```
10499

105-
Paginating by pk, id or some `unique=True` field:
100+
Paginating by `pk`, `id`, or some `unique=True` field:
106101

107102
```python
108103
page = paginator.paginate(queryset, lookup_field='pk', value=pk, per_page=20)
109104
```
110105

106+
Paginating by multiple fields:
107+
108+
```python
109+
page = paginator.paginate(
110+
queryset,
111+
lookup_field=('-is_pinned', '-created_at', '-pk'),
112+
value=(is_pinned, created_at, pk),
113+
per_page=20)
114+
```
115+
116+
> Make sure the last field is `unique=True`, or `pk`
117+
111118
## Items order
112119

113120
DESC order:
@@ -178,14 +185,23 @@ class Article(models.Model):
178185

179186
class Meta:
180187
indexes = [
181-
models.Index(fields=['created_at', 'pk'],
182-
models.Index(fields=['-created_at', '-pk'])]
188+
models.Index(fields=['created_at', 'id']),
189+
models.Index(fields=['-created_at', '-id'])]
183190
```
184191

185192
> Note: an index is require for both directions,
186193
since the query has a `LIMIT`.
187194
See [indexes-ordering](https://www.postgresql.org/docs/9.3/indexes-ordering.html)
188195

196+
However, this library does not implements the fast "row values"
197+
variant of [the seek method](https://use-the-index-luke.com/sql/partial-results/fetch-next-page).
198+
What this means is the index is only
199+
used on the first field. If the first field is a boolean,
200+
then it won't be used. So, it's pointless to index anything other than the first field.
201+
See [PR #8](https://github.com/nitely/django-infinite-scroll-pagination/pull/8)
202+
if you are interested in benchmarks numbers, and please let me know
203+
if there is a way to implement the "row values" variant without using raw SQL.
204+
189205
Pass a limit to the following methods,
190206
or use them in places where there won't be
191207
many records, otherwise they get expensive fast:

bench/Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.7.2-alpine
2+
3+
ENV PYTHONDONTWRITEBYTECODE 1
4+
ENV PYTHONUNBUFFERED 1
5+
6+
RUN apk update \
7+
&& apk add \
8+
postgresql-client \
9+
postgresql-dev \
10+
gcc \
11+
musl-dev \
12+
make \
13+
libffi-dev
14+
15+
RUN mkdir -p /usr/src/app
16+
WORKDIR /usr/src/app
17+
18+
RUN pip install --upgrade pip \
19+
&& pip install Django==2.2.8 \
20+
&& pip install psycopg2-binary==2.8.6
21+
22+
CMD until pg_isready --username=postgres --host=database; do sleep 1; done;
23+
ENTRYPOINT /bin/sh

bench/docker-compose.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
version: '3'
2+
3+
services:
4+
database:
5+
image: postgres:10.5
6+
restart: always
7+
paginator:
8+
build: .
9+
hostname: paginator
10+
volumes:
11+
- ..:/usr/src/app
12+
links:
13+
- database

infinite_scroll_pagination/paginator.py

Lines changed: 92 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
#-*- coding: utf-8 -*-
22

3-
43
try:
54
from collections.abc import Sequence
65
except ImportError:
76
from collections import Sequence
87

98
from django.core.paginator import EmptyPage
10-
from django.db.models import QuerySet
9+
from django.db.models import QuerySet, Q
1110

1211
__all__ = [
1312
'SeekPaginator',
@@ -17,8 +16,7 @@
1716
'PREV_PAGE']
1817

1918

20-
NEXT_PAGE = 1
21-
PREV_PAGE = 2
19+
NEXT_PAGE, PREV_PAGE, DESC, ASC = range(1, 5)
2220

2321

2422
class _NoPk:
@@ -33,52 +31,73 @@ def __repr__(self):
3331
# the first page
3432
_NO_PK = _NoPk()
3533

34+
# XXX simplify things by removing the pk parameter,
35+
# and requiring it as last field/value; we should
36+
# also validate there is a single unique=True field,
37+
# and it's the last one; this is a breaking chance, though
3638

37-
class SeekPaginator(object):
3839

40+
class SeekPaginator:
3941
def __init__(self, query_set, per_page, lookup_field):
4042
assert isinstance(query_set, QuerySet), 'QuerySet expected'
4143
assert isinstance(per_page, int), 'Int expected'
42-
assert isinstance(lookup_field, str), 'String expected'
44+
#assert isinstance(lookup_field, str), 'String expected'
4345
self.query_set = query_set
4446
self.per_page = per_page
45-
self.is_desc = lookup_field.startswith('-')
46-
self.is_asc = not self.is_desc
47-
self.lookup_field = lookup_field.lstrip('-')
47+
if isinstance(lookup_field, str):
48+
lookup_field = (lookup_field,)
49+
self.lookup_fields = lookup_field
50+
51+
@property
52+
def fields(self):
53+
return tuple(f.lstrip('-') for f in self.lookup_fields)
54+
55+
@property
56+
def fields_direction(self):
57+
d = {True: DESC, False: ASC}
58+
return tuple(
59+
(f.lstrip('-'), d[f.startswith('-')])
60+
for f in self.lookup_fields)
4861

4962
def prepare_order(self, has_pk, move_to):
50-
pk_sort = 'pk'
51-
lookup_sort = self.lookup_field
52-
if ((self.is_desc and move_to == NEXT_PAGE) or
53-
(self.is_asc and move_to == PREV_PAGE)):
54-
pk_sort = '-%s' % pk_sort
55-
lookup_sort = '-%s' % lookup_sort
63+
fields = list(self.fields_direction)
5664
if has_pk:
57-
return [lookup_sort, pk_sort]
58-
return [lookup_sort]
59-
60-
def prepare_lookup(self, value, pk, move_to):
61-
lookup_include = '%s__gt' % self.lookup_field
62-
lookup_exclude_pk = 'pk__lte'
63-
if ((self.is_desc and move_to == NEXT_PAGE) or
64-
(self.is_asc and move_to == PREV_PAGE)):
65-
lookup_include = '%s__lt' % self.lookup_field
66-
lookup_exclude_pk = 'pk__gte'
67-
lookup_exclude = None
68-
if pk is not _NO_PK:
69-
lookup_include = "%se" % lookup_include
70-
lookup_exclude = {self.lookup_field: value, lookup_exclude_pk: pk}
71-
lookup_filter = {lookup_include: value}
72-
return lookup_filter, lookup_exclude
65+
fields.append(
66+
('pk', fields[-1][1]))
67+
result = []
68+
for f, d in fields:
69+
if ((d == DESC and move_to == NEXT_PAGE) or
70+
(d == ASC and move_to == PREV_PAGE)):
71+
f = '-%s' % f
72+
result.append(f)
73+
return result
74+
75+
# q = X<=? & ~(X=? & ~(Y<?))
76+
def _apply_filter(self, i, fields, values, move_to):
77+
assert i < len(fields)
78+
f, d = fields[i]
79+
v = values[i]
80+
lf = '%s__gt' % f
81+
if ((d == DESC and move_to == NEXT_PAGE) or
82+
(d == ASC and move_to == PREV_PAGE)):
83+
lf = '%s__lt' % f
84+
if len(fields) == 1:
85+
return Q(**{lf: v})
86+
if i+1 == len(fields):
87+
return Q(**{lf: v})
88+
q = self._apply_filter(i+1, fields, values, move_to)
89+
return Q(**{lf + 'e': v}) & ~(Q(**{f: v}) & ~q)
7390

7491
def apply_filter(self, value, pk, move_to):
75-
query_set = self.query_set
76-
lookup_filter, lookup_exclude = self.prepare_lookup(
77-
value=value, pk=pk, move_to=move_to)
78-
query_set = query_set.filter(**lookup_filter)
79-
if lookup_exclude:
80-
query_set = query_set.exclude(**lookup_exclude)
81-
return query_set
92+
assert len(value) == len(self.lookup_fields)
93+
fields = list(self.fields_direction)
94+
values = list(value)
95+
if pk is not _NO_PK:
96+
values.append(pk)
97+
fields.append(
98+
('pk', fields[-1][1]))
99+
q = self._apply_filter(0, fields, values, move_to)
100+
return self.query_set.filter(q)
82101

83102
def seek(self, value, pk, move_to):
84103
"""
@@ -92,9 +111,36 @@ def seek(self, value, pk, move_to):
92111
AND NOT (date = ? AND id >= ?)
93112
ORDER BY date DESC, id DESC
94113
114+
Multi field lookup. Note how it produces nesting,
115+
and how I removed it using boolean logic simplification::
116+
117+
X <= ?
118+
AND NOT (X = ? AND (date <= ? AND NOT (date = ? AND id >= ?)))
119+
<--->
120+
X <= ?
121+
AND (NOT X = ? OR NOT date <= ? OR (date = ? AND id >= ?))
122+
<--->
123+
X <= ?
124+
AND (NOT X = ? OR NOT date <= ? OR date = ?)
125+
AND (NOT X = ? OR NOT date <= ? OR id >= ?)
126+
127+
A * ~(B * (C * ~(D * F)))
128+
-> (D + ~B + ~C) * (F + ~B + ~C) * A
129+
A * ~(B * (C * ~(D * (F * ~(G * H)))))
130+
-> (D + ~B + ~C) * (F + ~B + ~C) * (~B + ~C + ~G + ~H) * A
131+
A * ~(B * (C * ~(D * (F * ~(G * (X * ~(Y * Z)))))))
132+
-> (D + ~B + ~C) * (F + ~B + ~C) * (Y + ~B + ~C + ~G + ~X) * (Z + ~B + ~C + ~G + ~X) * A
133+
134+
Addendum::
135+
136+
X <= ?
137+
AND NOT (X = ? AND NOT (date <= ? AND NOT (date = ? AND id >= ?)))
138+
95139
"""
96140
query_set = self.query_set
97141
if value is not None:
142+
if not isinstance(value, (tuple, list)):
143+
value = (value,)
98144
query_set = self.apply_filter(
99145
value=value, pk=pk, move_to=move_to)
100146
query_set = query_set.order_by(
@@ -125,7 +171,6 @@ def page(self, value, pk=_NO_PK, move_to=NEXT_PAGE):
125171

126172

127173
class SeekPage(Sequence):
128-
129174
def __init__(self, query_set, key, move_to, paginator):
130175
self._query_set = query_set
131176
self._key = key
@@ -163,8 +208,11 @@ def _some_seek(self, direction):
163208
pk = _NO_PK
164209
if self._key['pk'] is not _NO_PK:
165210
pk = last.pk
211+
values = tuple(
212+
getattr(last, f)
213+
for f in self.paginator.fields)
166214
return self.paginator.seek(
167-
value=getattr(last, self.paginator.lookup_field),
215+
value=values,
168216
pk=pk,
169217
move_to=direction)
170218

@@ -214,10 +262,10 @@ def prev_pages_left(self, limit=None):
214262
def _some_page(self, index):
215263
if not self.object_list:
216264
return {}
217-
key = {
218-
'value': getattr(
219-
self.object_list[index],
220-
self.paginator.lookup_field)}
265+
values = tuple(
266+
getattr(self.object_list[index], f)
267+
for f in self.paginator.fields)
268+
key = {'value': values}
221269
if self._key['pk'] is not _NO_PK:
222270
key['pk'] = self.object_list[index].pk
223271
return key

infinite_scroll_pagination/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def to_page_key(value=None, pk=None):
6363
"""Serialize a value and pk to `timestamp-pk`` format"""
6464
if value is None:
6565
return ''
66+
if isinstance(value, (tuple, list)):
67+
(value,) = value
6668
value = _make_aware_maybe(value)
6769
try:
6870
timestamp = value.timestamp()

0 commit comments

Comments
 (0)