Search code examples
pythondjangodjango-rest-frameworkdjango-pagination

Django Rest Framework: Paginaion in detail view breaks when default ordering field is nullable


I have a Django app for contests where a contest can have multiple entries -

Contest in models.py

class Contest(models.Model):
    is_winners_announced = models.BooleanField(default=False)
    ...

ContestEntry in models.py

class ContestEntry(models.Model):
    contest = models.ForeignKey(Contest, on_delete=models.CASCADE,
                                related_name='entries')
    submitted_at = models.DateTimeField(auto_now_add=True)
    assigned_rank = models.PositiveSmallIntegerField(null=True, blank=True)
    ...

In the ContestViewSet, I have a detail route which serves all the entries for a contest -

def pagination_type_by_field(PaginationClass, field):
    class CustomPaginationClass(PaginationClass):
        ordering = field
    return CustomPaginationClass

...

@decorators.action(
    detail=True,
    methods=['GET'],
)
def entries(self, request, pk=None):
    contest = self.get_object()
    entries = contest.entries.all()

    # Order by rank only if winners are announced
    ordering_array = ['-submitted_at']
    if contest.is_winners_announced:
        ordering_array.insert(0, 'assigned_rank')
    pagination_obj = pagination_type_by_field(
        pagination.CursorPagination, ordering_array)()
    paginated_data = contest_serializers.ContestEntrySerializer(
        instance=pagination_obj.paginate_queryset(entries, request),
        many=True,
        context={'request': request},
    ).data

    return pagination_obj.get_paginated_response(paginated_data)

Pagination works fine when winners are not declared for a contest -

GET http://localhost:8000/contests/<id>/entries/

{
    "next": "http://localhost:8000/contests/<id>/entries/?cursor=cD0yMDIwLTAyLTE3KzIwJTNBNDQlM0EwNy4yMDMyMTUlMkIwMCUzQTAw",
    "previous": null,
    "results": [  // Contains all objects and pagination works
        {...},
        ...
    ]
}

But when the winners are announced, pagination breaks:

GET http://localhost:8000/contests/<id>/entries/

{
    "next": "https://localhost:8000/contests/4/entries/?cursor=bz03JnA9Mw%3D%3D",
    "previous": null,
    "results": [  // Contains all objects only for the first page; next page is empty even when there are more entries pending to be displayed
        {...},
        ...
    ]
}

The strange thing I see here is that cursor in the second case looks different from what it normally looks like.


Solution

  • Finally found a solution to the problem.

    The pagination fails because of null values in the most significant ordering field (i.e. assigned_rank).

    When going to the next page, cursor pagination tries to compute next rows from the database based on the lowest value from the previous page -

    if self.cursor.reverse != is_reversed:
        kwargs = {order_attr + '__lt': current_position}
    else:
        kwargs = {order_attr + '__gt': current_position}
    
    queryset = queryset.filter(**kwargs)
    

    Internal Implementation

    Due to this, all the rows are filtered out.


    To prevent this, we can put up a fake rank which will not be None and will not affect the ordering in case if the actual rank is None. This fallback rank can be max(all_ranks) + 1 -

    from django.db.models.functions import Coalesce
    ...
    
    @decorators.action(
        detail=True,
        methods=['GET'],
    )
    def entries(self, request, pk=None):
        contest = self.get_object()
        entries = contest.entries.all()
    
        ordering = ('-submitted_at',)
        if contest.is_winners_announced:
            max_rank = entries.aggregate(
                Max('assigned_rank')
            )['assigned_rank__max']
            next_rank = max_rank + 1
            entries = entries.annotate(
                pseudo_rank=Coalesce('assigned_rank', next_rank))
            ordering = ('pseudo_rank',) + ordering
        ...  # same as before
    

    Read more about Coalesce.

    This would result in setting rank for all the entries to 1 more than the worst-assigned rank entry. For example, if we have ranks 1, 2 and 3 assigned, the pseudo_rank for all other entries will be 4 and they will be ordered by -submitted_at.

    And then as they say, "It worked like charm ✨".