diff --git a/binder/views.py b/binder/views.py index 3ec6ac20..b9142927 100644 --- a/binder/views.py +++ b/binder/views.py @@ -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 @@ -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 @@ -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] @@ -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: @@ -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): diff --git a/docs/api.md b/docs/api.md index 49ee9509..1b2f2d93 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`. diff --git a/tests/test_after.py b/tests/test_after.py new file mode 100644 index 00000000..7a4c65ec --- /dev/null +++ b/tests/test_after.py @@ -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')