From 45e66ec24eeb33dd8dcd59a886d22724371c624e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 2 Nov 2025 14:00:11 +0300 Subject: [PATCH 1/3] Enforce ordering only when queryset is not ordered --- rest_framework/pagination.py | 9 +++++---- tests/test_pagination.py | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index b6329b8c3a..bd55ea2d11 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -624,10 +624,11 @@ def paginate_queryset(self, queryset, request, view=None): (offset, reverse, current_position) = self.cursor # Cursor pagination always enforces an ordering. - if reverse: - queryset = queryset.order_by(*_reverse_ordering(self.ordering)) - else: - queryset = queryset.order_by(*self.ordering) + if not queryset.ordered: + if reverse: + queryset = queryset.order_by(*_reverse_ordering(self.ordering)) + else: + queryset = queryset.order_by(*self.ordering) # If we have a cursor with a fixed position then filter by that. if current_position is not None: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index d8f66e95bc..63b897def7 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -969,8 +969,9 @@ def __init__(self, idx): self.created = idx class MockQuerySet: - def __init__(self, items): + def __init__(self, items, ordered=False): self.items = items + self.ordered = ordered def filter(self, created__gt=None, created__lt=None): if created__gt is not None: @@ -987,7 +988,7 @@ def filter(self, created__gt=None, created__lt=None): def order_by(self, *ordering): if ordering[0].startswith('-'): - return MockQuerySet(list(reversed(self.items))) + return MockQuerySet(list(reversed(self.items)), ordered=True) return self def __getitem__(self, sliced): From 77612482afa46a8dd3213ee547dfa99cb6ea936c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 2 Nov 2025 14:05:19 +0300 Subject: [PATCH 2/3] Remove ordering filter class handling These filters are already applied by GenericAPIView.filter_queryset() --- rest_framework/pagination.py | 20 ++------------------ tests/test_pagination.py | 36 ------------------------------------ 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index bd55ea2d11..9e68cdfd1e 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -615,7 +615,7 @@ def paginate_queryset(self, queryset, request, view=None): return None self.base_url = request.build_absolute_uri() - self.ordering = self.get_ordering(request, queryset, view) + self.ordering = self.get_ordering() self.cursor = self.decode_cursor(request) if self.cursor is None: @@ -802,28 +802,12 @@ def get_previous_link(self): cursor = Cursor(offset=offset, reverse=True, position=position) return self.encode_cursor(cursor) - def get_ordering(self, request, queryset, view): + def get_ordering(self): """ Return a tuple of strings, that may be used in an `order_by` method. """ - # The default case is to check for an `ordering` attribute - # on this pagination instance. ordering = self.ordering - ordering_filters = [ - filter_cls for filter_cls in getattr(view, 'filter_backends', []) - if hasattr(filter_cls, 'get_ordering') - ] - - if ordering_filters: - # If a filter exists on the view that implements `get_ordering` - # then we defer to that filter to determine the ordering. - filter_cls = ordering_filters[0] - filter_instance = filter_cls() - ordering_from_filter = filter_instance.get_ordering(request, queryset, view) - if ordering_from_filter: - ordering = ordering_from_filter - assert ordering is not None, ( 'Using cursor pagination, but no ordering attribute was declared ' 'on the pagination class.' diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 63b897def7..af8e82b6b3 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -616,42 +616,6 @@ def test_invalid_cursor(self): with pytest.raises(exceptions.NotFound): self.pagination.paginate_queryset(self.queryset, request) - def test_use_with_ordering_filter(self): - class MockView: - filter_backends = (filters.OrderingFilter,) - ordering_fields = ['username', 'created'] - ordering = 'created' - - request = Request(factory.get('/', {'ordering': 'username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('username',) - - request = Request(factory.get('/', {'ordering': '-username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('-username',) - - request = Request(factory.get('/', {'ordering': 'invalid'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('created',) - - def test_use_with_ordering_filter_without_ordering_default_value(self): - class MockView: - filter_backends = (filters.OrderingFilter,) - ordering_fields = ['username', 'created'] - - request = Request(factory.get('/')) - ordering = self.pagination.get_ordering(request, [], MockView()) - # it gets the value of `ordering` provided by CursorPagination - assert ordering == ('created',) - - request = Request(factory.get('/', {'ordering': 'username'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('username',) - - request = Request(factory.get('/', {'ordering': 'invalid'})) - ordering = self.pagination.get_ordering(request, [], MockView()) - assert ordering == ('created',) - def test_cursor_pagination(self): (previous, current, next, previous_url, next_url) = self.get_pages('/') From 64647c0ca5de1a271f29b691305c61a5d0f1fa38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Tue, 4 Nov 2025 14:41:59 +0300 Subject: [PATCH 3/3] Return queryset's ordering from .get_ordering() --- rest_framework/pagination.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 9e68cdfd1e..2bf51b1d6e 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -11,6 +11,7 @@ from django.core.paginator import InvalidPage from django.core.paginator import Paginator as DjangoPaginator +from django.db.models import OrderBy from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ @@ -615,7 +616,7 @@ def paginate_queryset(self, queryset, request, view=None): return None self.base_url = request.build_absolute_uri() - self.ordering = self.get_ordering() + self.ordering = self.get_ordering(queryset) self.cursor = self.decode_cursor(request) if self.cursor is None: @@ -802,10 +803,36 @@ def get_previous_link(self): cursor = Cursor(offset=offset, reverse=True, position=position) return self.encode_cursor(cursor) - def get_ordering(self): + def get_ordering_from_queryset(self, queryset): + if not queryset.ordered: + return False + + qs_ordering = queryset.query.order_by + + # Fallback to model's Meta ordering if no order_by is given + if not qs_ordering: + qs_ordering = queryset.query.get_meta().ordering + + ordering = [] + for expr in qs_ordering: + if isinstance(expr, str): + ordering.append(expr) + + elif isinstance(expr, OrderBy): + field_name = expr.expression.name + descending = expr.descending + ordering.append(f"{'-' if descending else ''}{field_name}") + + return ordering + + def get_ordering(self, queryset): """ Return a tuple of strings, that may be used in an `order_by` method. """ + # Return the ordering value from the queryset if it has one. + if queryset.ordered: + return self.get_ordering_from_queryset(queryset) + ordering = self.ordering assert ordering is not None, (