Opened 3 months ago

Closed 3 months ago

#35733 closed Cleanup/optimization (invalid)

Page.__len__ could skip a query if self.paginator.count == 0

Reported by: Jacob Walls Owned by: Jacob Walls
Component: Core (Other) Version: dev
Severity: Normal Keywords:
Cc: Triage Stage: Accepted
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

With code like this,

paginator = Paginator(unevaluated_queryset, 25)
page = paginator.get_page(1)  # causes count query via validate_number()
if not page:  # causes select query
    return {}

I get an additional query versus:

paginator = Paginator(unevaluated_queryset, 25)
if not paginator.count:  # causes count query
    return {}

In the case of no data, the second SELECT query is unnecessary if we already know the paginator is empty. Since paginator.count is cached, would it be worth optimizing out this additional query? Otherwise, you have to dig into the internals to discover that the two examples above do not perform equivalently.

  • django/core/paginator.py

    diff --git a/django/core/paginator.py b/django/core/paginator.py
    index 7b3189cc8b..334166636d 100644
    a b class Page(collections.abc.Sequence):  
    188188        return "<Page %s of %s>" % (self.number, self.paginator.num_pages)
    189189
    190190    def __len__(self):
     191        if self.paginator.count == 0:
     192            return 0
    191193        return len(self.object_list)
    192194
    193195    def __getitem__(self, index):

Change History (4)

comment:1 by Simon Charette, 3 months ago

If we are going to do this I think should use paginator.__dict__.get('count') instead to avoid causing the inverse problem if count is not already cached? Or maybe it's not necessary because a page cannot be created without count being cached in the first place?

I think that a similar optimization could be used for any count that is smaller than the paginator's page size.

Last edited 3 months ago by Simon Charette (previous) (diff)

comment:2 by Jacob Walls, 3 months ago

Or maybe it's not necessary because a page cannot be created without count being cached in the first place?

That's more or less what I found, except that it is possible to manually instantiate a Page, just unlikely enough that it may not be worth coding around?

The recommend interface is to create the Page via Paginator.page() (or Paginator.get_page() and Paginator.__iter__() which call it), any of which in turn cache the count via validate_number():

You usually won't construct ``Page`` objects by hand -- you'll get them by
iterating :class:`Paginator`, or by using :meth:`Paginator.page`.

comment:3 by Simon Charette, 3 months ago

Triage Stage: UnreviewedAccepted

In this case I think it's safe to move forward with the optimization, the closest ticket I could find is #23771.

Note that a similar optimization could have done the other way around though where when a page is requested if len(page.object_list) < paginator.per_page then count can be deduced from self.paginator.count = (self.number - 1) * self.paginator.per_page + len(self.object_list).

  • django/core/paginator.py

    diff --git a/django/core/paginator.py b/django/core/paginator.py
    index 7b3189cc8b..6b0aafc6d0 100644
    a b def page(self, number):  
    8989        number = self.validate_number(number)
    9090        bottom = (number - 1) * self.per_page
    9191        top = bottom + self.per_page
    92         if top + self.orphans >= self.count:
     92        if self.orphans and top + self.orphans >= self.count:
    9393            top = self.count
    9494        return self._get_page(self.object_list[bottom:top], number, self)
    9595
    def __repr__(self):  
    188188        return "<Page %s of %s>" % (self.number, self.paginator.num_pages)
    189189
    190190    def __len__(self):
    191         return len(self.object_list)
     191        if (paginator_count := self.paginator.__dict__.get("count")) == 0:
     192            return 0
     193        object_list_len = len(self.object_list)
     194        if paginator_count is None and object_list_len < self.paginator.per_page:
     195            self.paginator.count = (
     196                self.number - 1
     197            ) * self.paginator.per_page + object_list_len
     198        return object_list_len
    192199
    193200    def __getitem__(self, index):
    194201        if not isinstance(index, (int, slice)):

Unfortunately because Paginator.get_page validates against self.num_pages which triggers the calculation of self.count this is not feasible. On the bright side though, this means that get_page will systematically trigger self.count calculation which strengthen the assumption that it's safe to use directly in Page.__len__.

comment:4 by Jacob Walls, 3 months ago

Resolution: invalid
Status: assignedclosed

In writing a test case, I discovered that I failed to confirm the no-data case does any additional select query. In the no-data case, a slice of the queryset from [0:0] is taken, which the ORM knows not to perform a query for. So there is nothing to optimize here.

In Paginator.page(), bottom and top are both 0 if the count is 0:

        return self._get_page(self.object_list[bottom:top], number, self)
Note: See TracTickets for help on using tickets.
Back to Top