阻止django admin在列表表单上运行SELECT COUNT(*)

时间:2012-05-03 14:11:03

标签: django django-models django-admin

每次我使用Admin列出模型的条目时,Admin都会计算表中的行数。更糟糕的是,即使您过滤查询,它似乎也是如此。

例如,如果我只想显示id为123,456,789的模型,我可以这样做:

/admin/myapp/mymodel/?id__in=123,456,789

但查询运行(以及其他)是:

SELECT COUNT(*) FROM `myapp_mymodel` WHERE `myapp_mymodel`.`id` IN (123, 456, 789) # okay
SELECT COUNT(*) FROM `myapp_mymodel` # why???

哪个杀了mysql + innodb。似乎问题已被部分承认in this ticket,但我的问题似乎更具体,因为它计算了所有行,即使它不应该。

有没有办法禁用全局行数?

注意:我使用的是django 1.2.7。

7 个答案:

答案 0 :(得分:20)

Django 1.8允许您通过设置show_full_result_count = False来禁用此功能。

https://docs.djangoproject.com/en/1.8/ref/contrib/admin/#django.contrib.admin.ModelAdmin.show_full_result_count

答案 1 :(得分:19)

好的,我想我找到了解决方案。正如彼得所建议的那样,最好的方法是处理count属性,可以通过使用自定义查询集(如this post中所示)覆盖它来完成,该查询使用近似的等价物来统计计数: / p>

from django.db import connections, models
from django.db.models.query import QuerySet

class ApproxCountQuerySet(QuerySet):
    """Counting all rows is very expensive on large Innodb tables. This
    is a replacement for QuerySet that returns an approximation if count()
    is called with no additional constraints. In all other cases it should
    behave exactly as QuerySet.

    Only works with MySQL. Behaves normally for all other engines.
    """

    def count(self):
        # Code from django/db/models/query.py

        if self._result_cache is not None and not self._iter:
            return len(self._result_cache)

        is_mysql = 'mysql' in connections[self.db].client.executable_name.lower()

        query = self.query
        if (is_mysql and not query.where and
                query.high_mark is None and
                query.low_mark == 0 and
                not query.select and
                not query.group_by and
                not query.having and
                not query.distinct):
            # If query has no constraints, we would be simply doing
            # "SELECT COUNT(*) FROM foo". Monkey patch so the we
            # get an approximation instead.
            cursor = connections[self.db].cursor()
            cursor.execute("SHOW TABLE STATUS LIKE %s",
                    (self.model._meta.db_table,))
            return cursor.fetchall()[0][4]
        else:
            return self.query.get_count(using=self.db)

然后在管理员:

class MyAdmin(admin.ModelAdmin):

    def queryset(self, request):
        qs = super(MyAdmin, self).queryset(request)
        return qs._clone(klass=ApproxCountQuerySet)

近似函数可能会在页码100000上搞乱,但对我的情况来说已经足够了。

答案 2 :(得分:7)

我发现Nova的答案非常有用,但我使用了postgres。我稍微修改它以适用于postgres,稍微改变一下来处理表名称空间,稍微不同的“检测postgres”逻辑。

这是pg版本。

class ApproxCountPgQuerySet(models.query.QuerySet):
  """approximate unconstrained count(*) with reltuples from pg_class"""

  def count(self):
      if self._result_cache is not None and not self._iter:
          return len(self._result_cache)

      if hasattr(connections[self.db].client.connection, 'pg_version'):
          query = self.query
          if (not query.where and query.high_mark is None and query.low_mark == 0 and
              not query.select and not query.group_by and not query.having and not query.distinct):
              # If query has no constraints, we would be simply doing
              # "SELECT COUNT(*) FROM foo". Monkey patch so the we get an approximation instead.
              parts = [p.strip('"') for p in self.model._meta.db_table.split('.')]
              cursor = connections[self.db].cursor()
              if len(parts) == 1:
                  cursor.execute("select reltuples::bigint FROM pg_class WHERE relname = %s", parts)
              else:
                  cursor.execute("select reltuples::bigint FROM pg_class c JOIN pg_namespace n on (c.relnamespace = n.oid) WHERE n.nspname = %s AND c.relname = %s", parts)
          return cursor.fetchall()[0][0]
      return self.query.get_count(using=self.db)

答案 3 :(得分:4)

Nova的解决方案(ApproxCountQuerySet)工作得很好,但是在较新版本的Django中,queryset方法被get_queryset取代,所以它现在应该是:

class MyAdmin(admin.ModelAdmin):

    def get_queryset(self, request):
        qs = super(MyAdmin, self).get_queryset(request)
        return qs._clone(klass=ApproxCountQuerySet)

答案 4 :(得分:3)

如果这是一个严重的问题,您可能需要采取Drastic Actions™。

查看1.3.1安装的代码,我看到管理代码正在使用get_paginator()返回的分页符。默认的分页器类似乎位于django/core/paginator.py中。该类有一个名为_count的私有值,它在Paginator._get_count()中设置(我的副本中的第120行)。这反过来用于设置名为count的Paginator类的属性。我认为_get_count()是你的目标。现在舞台已经确定。

您有几个选择:

  1. 直接修改来源。我推荐这个,但由于你似乎陷入了1.2.7,你可能会发现它是最权宜之计。 请记住记录此更改!未来的维护者(包括可能是您自己)将会感谢您的提升。

  2. Monkeypatch这个班级。这比直接修改更好,因为a)如果你不喜欢这个改变,你只需要注释monkeypatch,并且b)它更可能适用于未来版本的Django。我有一个monkeypatch可以追溯到4年以上,因为他们仍然没有修复模板变量_resolve_lookup()代码中的错误,该错误在顶级评估中不识别callables,只是在较低级别。虽然补丁(包装类的方法)是针对0.97-pre编写的,但它仍然适用于1.3.1。

  3. 我没有花时间弄清楚你必须为你的问题做出哪些改变,但它可能就是将_approx_count成员添加到适当的类class META然后测试是否存在该attr。如果是,并且None那么您执行sql.count()并进行设置。如果您在列表的最后一页(或附近),则可能还需要重置它。如果您需要更多帮助,请与我联系;我的电子邮件在我的个人资料中。

答案 5 :(得分:1)

管理员类可以使用change the default paginator。这是一个在短时间内缓存结果的人:https://gist.github.com/e4c5/6852723

答案 6 :(得分:0)

我设法创建了一个自定义分页器,该分页器显示当前页面的编号,下一个按钮和显示完整计数链接。如果需要,它允许使用原始分页器。

enter image description here

使用的技巧是从db中获取per_page + 1个元素,以查看我们是否还有更多元素,然后提供假计数。

假设我们需要第三页,并且该页面有25个元素=>我们想要object_list[50:75]。调用Paginator.count时,将对object_list[50:76]的查询集进行评估(请注意,我们采用 75 + 1 个元素),如果我们得到25 + 1个元素,则返回计数为76 db或50 +如果我们没有收到26个元素,则收到的元素数。


TL; DR: 我为ModelAdmin创建了一个mixin:

from django.core.paginator import Paginator
from django.utils.functional import cached_property


class FastCountPaginator(Paginator):
    """A faster paginator implementation than the Paginator. Paginator is slow
    mainly because QuerySet.count() is expensive on large queries.

    The idea is to use the requested page to generate a 'fake' count. In
    order to see if the page is the final one  it queries n+1 elements
    from db then reports the count as page_number * per_page + received_elements.
    """

    use_fast_pagination = True

    def __init__(self, page_number, *args, **kwargs):
        self.page_number = page_number
        super(FastCountPaginator, self).__init__(*args, **kwargs)

    @cached_property
    def count(self):
        # Populate the object list when count is called. As this is a cached property,
        # it will be called only once per instance
        return self.populate_object_list()

    def page(self, page_number):
        """Return a Page object for the given 1-based page number."""
        page_number = self.validate_number(page_number)
        return self._get_page(self.object_list, page_number, self)

    def populate_object_list(self):
        # converts queryset object_list to a list and return the number of elements until there
        # the trick is to get per_page elements + 1 in order to see if the next page exists.
        bottom = self.page_number * self.per_page
        # get one more object than needed to see if we should show next page
        top = bottom + self.per_page + 1
        object_list = list(self.object_list[bottom:top])
        # not the last page
        if len(object_list) == self.per_page + 1:
            object_list = object_list[:-1]
        else:
            top = bottom + len(object_list)
        self.object_list = object_list
        return top


class ModelAdminFastPaginationMixin:
    show_full_result_count = False  # prevents root_queryset.count() call

    def changelist_view(self, request, extra_context=None):
        # strip count_all query parameter from the request before it is processed
        # this allows all links to be generated like this parameter was not present and without raising errors
        request.GET = request.GET.copy()
        request.GET.paginator_count_all = request.GET.pop('count_all', False)

        return super().changelist_view(request, extra_context)

    def get_paginator(self, request, queryset, per_page, orphans=0, allow_empty_first_page=True):
        # use the normal paginator if we want to count all the ads
        if hasattr(request.GET, 'paginator_count_all') and request.GET.paginator_count_all:
            return Paginator(queryset, per_page, orphans, allow_empty_first_page)
        page = self._validate_page_number(request.GET.get('p', '0'))
        return FastCountPaginator(page, queryset, per_page, orphans, allow_empty_first_page)

    def _validate_page_number(self, number):
        # taken from Paginator.validate_number and adjusted
        try:
            if isinstance(number, float) and not number.is_integer():
                raise ValueError
            number = int(number)
        except (TypeError, ValueError):
            return 0
        if number < 1:
            number = 0
        return number

pagination.html模板:

{% if cl and cl.paginator and cl.paginator.use_fast_pagination %}
    {# Fast paginator with only next button and show the total number of results#}
    {% load admin_list %}
    {% load i18n %}
    {% load admin_templatetags %}
    <p class="paginator">
        {% if pagination_required %}
            {% for i in page_range %}
                {% if forloop.last %}
                    {% fast_paginator_number cl i 'Next' %}
                {% else %}
                    {% fast_paginator_number cl i %}
                {% endif %}
            {% endfor %}
        {% endif %}
        {% show_count_all_link cl "showall" %}
    </p>
{% else %}
    {#  use the default pagination template if we are not using the FastPaginator  #}
    {% include "admin/pagination.html" %}
{% endif %}

和使用的模板标签:

from django import template
from django.contrib.admin.views.main import PAGE_VAR
from django.utils.html import format_html
from django.utils.safestring import mark_safe

register = template.Library()

DOT = '.'


@register.simple_tag
def fast_paginator_number(cl, i, text_display=None):
    """Generate an individual page index link in a paginated list.

    Allows to change the link text by setting text_display
    """
    if i == DOT:
        return '… '
    elif i == cl.page_num:
        return format_html('<span class="this-page">{}</span> ', i + 1)
    else:
        return format_html(
            '<a href="{}"{}>{}</a> ',
            cl.get_query_string({PAGE_VAR: i}),
            mark_safe(' class="end"' if i == cl.paginator.num_pages - 1 else ''),
            text_display if text_display else i + 1,
        )


@register.simple_tag
def show_count_all_link(cl, css_class='', text_display='Show the total number of results'):
    """Generate a button that toggles between FastPaginator and the normal
    Paginator."""
    return format_html(
        '<a href="{}"{}>{}</a> ',
        cl.get_query_string({PAGE_VAR: cl.page_num, 'count_all': True}),
        mark_safe(f' class="{css_class}"' if css_class else ''),
        text_display,
    )

您可以通过以下方式使用它:

class MyVeryLargeModelAdmin(ModelAdminFastPaginationMixin, admin.ModelAdmin):
# ...

或更简单的版本,不显示 Next 按钮,并且不显示结果总数

from django.core.paginator import Paginator
from django.utils.functional import cached_property


class FastCountPaginator(Paginator):
    """A faster paginator implementation than the Paginator. Paginator is slow
    mainly because QuerySet.count() is expensive on large queries.

    The idea is to use the requested page to generate a 'fake' count. In
    order to see if the page is the final one  it queries n+1 elements
    from db then reports the count as page_number * per_page + received_elements.
    """

    use_fast_pagination = True

    def __init__(self, page_number, *args, **kwargs):
        self.page_number = page_number
        super(FastCountPaginator, self).__init__(*args, **kwargs)

    @cached_property
    def count(self):
        # Populate the object list when count is called. As this is a cached property,
        # it will be called only once per instance
        return self.populate_object_list()

    def page(self, page_number):
        """Return a Page object for the given 1-based page number."""
        page_number = self.validate_number(page_number)
        return self._get_page(self.object_list, page_number, self)

    def populate_object_list(self):
        # converts queryset object_list to a list and return the number of elements until there
        # the trick is to get per_page elements + 1 in order to see if the next page exists.
        bottom = self.page_number * self.per_page
        # get one more object than needed to see if we should show next page
        top = bottom + self.per_page + 1
        object_list = list(self.object_list[bottom:top])
        # not the last page
        if len(object_list) == self.per_page + 1:
            object_list = object_list[:-1]
        else:
            top = bottom + len(object_list)
        self.object_list = object_list
        return top


class ModelAdminFastPaginationMixin:
    show_full_result_count = False  # prevents root_queryset.count() call

    def get_paginator(self, request, queryset, per_page, orphans=0, allow_empty_first_page=True):
        page = self._validate_page_number(request.GET.get('p', '0'))
        return FastCountPaginator(page, queryset, per_page, orphans, allow_empty_first_page)

    def _validate_page_number(self, number):
        # taken from Paginator.validate_number and adjusted
        try:
            if isinstance(number, float) and not number.is_integer():
                raise ValueError
            number = int(number)
        except (TypeError, ValueError):
            return 0
        if number < 1:
            number = 0
        return number