Skip to content

Commit

Permalink
Add special after filter
Browse files Browse the repository at this point in the history
  • Loading branch information
daanvdk committed Nov 16, 2023
1 parent aeab374 commit 84385a7
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 1 deletion.
72 changes: 71 additions & 1 deletion binder/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from django.db.models.lookups import Transform
from django.utils import timezone
from django.db import transaction
from django.db.models.expressions import BaseExpression, Value, CombinedExpression, OrderBy, ExpressionWrapper
from django.db.models.expressions import BaseExpression, Value, CombinedExpression, OrderBy, ExpressionWrapper, Func
from django.db.models.fields.reverse_related import ForeignObjectRel


Expand All @@ -35,6 +35,17 @@
from .json import JsonResponse, jsonloads


class Tuple(Func):
template = '(%(expressions)s)'


class GreaterThan(Func):
template = '(%(expressions)s)'
arg_joiner = ' > '
arity = 2
output_field = models.BooleanField()


def get_joins_from_queryset(queryset):
"""
Given a queryset returns a set of lines that are used to determine which
Expand Down Expand Up @@ -1497,6 +1508,7 @@ def _generate_meta(self, include_meta, queryset, request, pk=None):

return meta


def _apply_q_with_possible_annotations(self, queryset, q, annotations):
for filter in q_get_flat_filters(q):
head = filter.split('__', 1)[0]
Expand All @@ -1510,6 +1522,55 @@ def _apply_q_with_possible_annotations(self, queryset, q, annotations):
return queryset.filter(q)


def _after_expr(self, request, after_id):
"""
This method given a request and an id returns a boolean expression that
indicates if a record would show up after the provided id for the
ordering specified by this request.
"""
# First we get the object we need to use as our base for our filter
try:
obj = self.get_queryset(request).get(pk=int(after_id))
except (ValueError, self.model.DoesNotExist):
raise BinderRequestError(f'invalid value for after_id: {after_id!r}')

# Now we will build up a comparison expr based on the order by
ordering = self.order_by(self.model.objects.all(), request).query.order_by
left_exprs = []
right_exprs = []

for field in ordering:
# First we have to split of a leading '-' as indicating reverse
reverse = field.startswith('-')
if reverse:
field = field[1:]

# Then we build 2 exprs for the left hand side (objs in the query)
# and the right hand side (the object with the provided after id)
left_expr = F(field)

right_expr = obj
for attr in field.split('__'):
right_expr = getattr(right_expr, attr)
if isinstance(right_expr, models.Model):
right_expr = right_expr.pk
right_expr = Value(right_expr)

# To handle reverse we flip the expressions
if reverse:
left_exprs.append(right_expr)
right_exprs.append(left_expr)
else:
left_exprs.append(left_expr)
right_exprs.append(right_expr)

# Now we turn this into one big comparison
if len(ordering) == 1:
return GreaterThan(left_exprs[0], right_exprs[0])
else:
return GreaterThan(Tuple(*left_exprs), Tuple(*right_exprs))


def _get_filtered_queryset_base(self, request, pk=None, include_annotations=None):
queryset = self.get_queryset(request)
if pk:
Expand Down Expand Up @@ -1547,6 +1608,15 @@ def _get_filtered_queryset_base(self, request, pk=None, include_annotations=None
q = self._search_base(request.GET['search'], request)
queryset = self._apply_q_with_possible_annotations(queryset, q, annotations)

#### after
try:
after = request.GET['after']
except KeyError:
pass
else:
expr = self._after_expr(request, after)
queryset = queryset.filter(expr)

return queryset, annotations

def get_filtered_queryset(self, request, *args, **kwargs):
Expand Down
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Notice that `api/animal?.zoo_history:not:any=Artis` requires that both `zoo` and

It is NOT allowed to use both `:any` and `all` in one filter since this does not make any sense. Also notice that you must first `:all` or `:any`, and only then you can use other filters like `:not:icontains` or `:startswith` etc.

#### Filtering after a certain record

Sometimes you want to filter a request to only return records that come after a certain record. For example you have fetched 25 records already and you want to fetch the next 25. For example if you called `/api/animal/` and the last record had id `1337` you can call `/api/animal/?after=1337` to get the next page of records. This will also respect other filters & ordering.

### Ordering the collection
Ordering is a simple matter of enumerating the fields in the `order_by` query parameter, eg. `api/animal?order_by=name`. If you want to make the ordering stable when there are multiple animals sharing the same name, you can separate with commas like `api/animal?order_by=name,id`. The results will be sorted on name, and where the name is the same, they'll be sorted by `id`.

Expand Down
64 changes: 64 additions & 0 deletions tests/test_after.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from django.contrib.auth.models import User
from django.test import TestCase

from binder.json import jsonloads

from .testapp.models import Animal, Zoo


class TestAfter(TestCase):

def setUp(self):
self.mapping = {}

zoo1 = Zoo.objects.create(name='Zoo 2')
self.mapping[Animal.objects.create(name='Animal F', zoo=zoo1).id] = 'f'
self.mapping[Animal.objects.create(name='Animal E', zoo=zoo1).id] = 'e'
self.mapping[Animal.objects.create(name='Animal D', zoo=zoo1).id] = 'd'

zoo2 = Zoo.objects.create(name='Zoo 1')
self.mapping[Animal.objects.create(name='Animal C', zoo=zoo2).id] = 'c'
self.mapping[Animal.objects.create(name='Animal B', zoo=zoo2).id] = 'b'
self.mapping[Animal.objects.create(name='Animal A', zoo=zoo2).id] = 'a'

user = User(username='testuser', is_active=True, is_superuser=True)
user.set_password('test')
user.save()
self.assertTrue(self.client.login(username='testuser', password='test'))

def get(self, *ordering, after=None):
params = {}
if ordering:
params['order_by'] = ','.join(ordering)
if after is not None:
params['after'] = next(
pk
for pk, char in self.mapping.items()
if char == after
)

res = self.client.get('/animal/', params)
self.assertEqual(res.status_code, 200)
res = jsonloads(res.content)

return ''.join(self.mapping[obj['id']] for obj in res['data'])

def test_default(self):
self.assertEqual(self.get(), 'fedcba')
self.assertEqual(self.get(after='d'), 'cba')

def test_ordered(self):
self.assertEqual(self.get('name', ), 'abcdef')
self.assertEqual(self.get('name', after='c'), 'def')

def test_ordered_relation(self):
self.assertEqual(self.get('zoo,name', ), 'defabc')
self.assertEqual(self.get('zoo,name', after='f'), 'abc')

def test_ordered_reverse(self):
self.assertEqual(self.get('-name', ), 'fedcba')
self.assertEqual(self.get('-name', after='d'), 'cba')

def test_ordered_relation_field(self):
self.assertEqual(self.get('zoo.name', ), 'cbafed')
self.assertEqual(self.get('zoo.name', after='a'), 'fed')

0 comments on commit 84385a7

Please sign in to comment.