Как фильтровать объекты для аннотации счетчика в Django?

124

Рассмотрим простые модели Django Eventи Participant:

class Event(models.Model):
    title = models.CharField(max_length=100)

class Participant(models.Model):
    event = models.ForeignKey(Event, db_index=True)
    is_paid = models.BooleanField(default=False, db_index=True)

Аннотировать запрос событий легко, указав общее количество участников:

events = Event.objects.all().annotate(participants=models.Count('participant'))

Как добавить аннотацию с подсчетом отфильтрованных участников is_paid=True?

Мне нужно запрашивать все события независимо от количества участников, например, мне не нужно фильтровать по аннотированному результату. Если есть 0участники, ничего страшного, мне просто нужно 0аннотированное значение.

Пример из документации здесь не работает, так как он исключает объекты из запроса вместо аннотирования их 0.

Обновить. В Django 1.8 появилась новая функция условных выражений , поэтому теперь мы можем делать это так:

events = Event.objects.all().annotate(paid_participants=models.Sum(
    models.Case(
        models.When(participant__is_paid=True, then=1),
        default=0,
        output_field=models.IntegerField()
    )))

Обновление 2. В Django 2.0 появилась новая функция условного агрегирования , см. Принятый ответ ниже.

rudyryk
источник

Ответы:

106

Условная агрегация в Django 2.0 позволяет еще больше уменьшить количество ошибок, которые были в прошлом. Это также будет использовать filterлогику Postgres , которая несколько быстрее, чем случай суммирования (я видел числа вроде 20-30%).

В любом случае, в вашем случае мы смотрим на нечто очень простое:

from django.db.models import Q, Count
events = Event.objects.annotate(
    paid_participants=Count('participants', filter=Q(participants__is_paid=True))
)

В документации есть отдельный раздел о фильтрации аннотаций . Это то же самое, что и условное агрегирование, но больше похоже на мой пример выше. В любом случае, это намного полезнее, чем корявые подзапросы, которые я делал раньше.

Oli
источник
Кстати, по ссылке на документацию такого примера нет, aggregateпоказано только использование. Вы уже тестировали такие запросы? (Не было и хочу верить! :)
rudyryk 06
2
У меня есть. Они работают. На самом деле я попал в странный патч, когда старый (сверхсложный) подзапрос перестал работать после обновления до Django 2.0, и мне удалось заменить его сверхпростым отфильтрованным счетчиком. Есть лучший пример аннотаций в документе, поэтому сейчас я его возьму.
Оли
1
Здесь есть несколько ответов, это способ Django 2.0, а ниже вы найдете способ Django 1.11 (подзапросы) и способ Django 1.8.
Райан Кастнер
2
Остерегайтесь, если вы попробуете это в Django <2, например 1.9, он будет работать без исключения, но фильтр просто не применяется. Таким образом, может показаться, что он работает с Django <2, но не работает.
djvg 01
Если вам нужно добавить несколько фильтров, вы можете добавить их в аргумент Q () с разделением, например filter = Q (members__is_paid = True, somethingelse = value)
Tobit
93

Только что обнаружил, что в Django 1.8 есть новая функция условных выражений , так что теперь мы можем делать вот так:

events = Event.objects.all().annotate(paid_participants=models.Sum(
    models.Case(
        models.When(participant__is_paid=True, then=1),
        default=0, output_field=models.IntegerField()
    )))
rudyryk
источник
Подходит ли это решение, когда подходящих элементов много? Допустим, я хочу подсчитать количество кликов, произошедших за последнюю неделю.
SverkerSbrg
Почему нет? Я имею в виду, почему ваш случай отличается? В приведенном выше случае на мероприятии может участвовать любое количество оплачиваемых участников.
рудырик 08
Я думаю, что вопрос, который задает @SverkerSbrg, заключается в том, неэффективно ли это для больших наборов, а не будет ли это работать ... правильно? Самая важная вещь, которую нужно знать, это то, что он не делает этого в python, он создает предложение case SQL - см. Github.com/django/django/blob/master/django/db/models/ ... - так что это будет достаточно производительно, простой пример будет лучше, чем соединение, но более сложные версии могут включать подзапросы и т. д.
Хайден Крокер
1
При использовании этого с Count(вместо Sum) я думаю, мы должны установить default=None(если не использовать filterаргумент django 2 ).
djvg 01
41

ОБНОВИТЬ

Подход подзапроса, о котором я упоминал, теперь поддерживается в Django 1.11 через выражения подзапроса .

Event.objects.annotate(
    num_paid_participants=Subquery(
        Participant.objects.filter(
            is_paid=True,
            event=OuterRef('pk')
        ).values('event')
        .annotate(cnt=Count('pk'))
        .values('cnt'),
        output_field=models.IntegerField()
    )
)

Я предпочитаю это агрегации (сумма + регистр) , потому что это должно быть быстрее и проще для оптимизации (с правильной индексацией) .

Для более старой версии то же самое можно сделать, используя .extra

Event.objects.extra(select={'num_paid_participants': "\
    SELECT COUNT(*) \
    FROM `myapp_participant` \
    WHERE `myapp_participant`.`is_paid` = 1 AND \
            `myapp_participant`.`event_id` = `myapp_event`.`id`"
})
Тодор
источник
Спасибо, Тодор! Похоже, я нашел способ без использования .extra, так как предпочитаю избегать SQL в Django :) Я обновлю вопрос.
rudyryk
1
Добро пожаловать, кстати, я знаю об этом подходе, но до сих пор это было неработающее решение, поэтому я не упомянул об этом. Однако я только что обнаружил, что это было исправлено Django 1.8.2, поэтому я думаю, что вы используете эту версию, и поэтому она работает на вас. Вы можете узнать больше об этом здесь и здесь
Тодор
2
Я понимаю, что это дает None, когда должно быть 0. Кто-нибудь еще получает это?
StefanJCollier
@StefanJCollier Да, я Noneтоже получил . Мое решение заключалось в использовании Coalesce( from django.db.models.functions import Coalesce). Вы можете использовать его как это: Coalesce(Subquery(...), 0). Хотя может быть и лучший подход.
Адам Тейлор,
6

Я бы предложил вместо этого использовать .valuesметод вашего Participantзапроса.

Короче говоря, то, что вы хотите сделать, дает:

Participant.objects\
    .filter(is_paid=True)\
    .values('event')\
    .distinct()\
    .annotate(models.Count('id'))

Полный пример выглядит следующим образом:

  1. Создайте 2 Eventс:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
  2. Добавьте Participantк ним:

    part1l = [Participant.objects.create(event=event1, is_paid=((_%2) == 0))\
              for _ in range(10)]
    part2l = [Participant.objects.create(event=event2, is_paid=((_%2) == 0))\
              for _ in range(50)]
  3. Сгруппируйте все Participantпо их eventполю:

    Participant.objects.values('event')
    > <QuerySet [{'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 1}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, {'event': 2}, '...(remaining elements truncated)...']>

    Здесь нужно внятное:

    Participant.objects.values('event').distinct()
    > <QuerySet [{'event': 1}, {'event': 2}]>

    Что .valuesи .distinctздесь делается, так это то, что они создают два сегмента Participants, сгруппированных по их элементам event. Обратите внимание, что эти корзины содержат файлы Participant.

  4. Затем вы можете аннотировать эти сегменты, поскольку они содержат набор оригиналов Participant. Здесь мы хотим подсчитать количество Participant, это просто делается путем подсчета ids элементов в этих сегментах (поскольку они есть Participant):

    Participant.objects\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 10}, {'event': 2, 'id__count': 50}]>
  5. Наконец, вы хотите только Participantс is_paidсуществом True, вы можете просто добавить фильтр перед предыдущим выражением, и это даст выражение, показанное выше:

    Participant.objects\
        .filter(is_paid=True)\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 5}, {'event': 2, 'id__count': 25}]>

Единственный недостаток заключается в том, что вам нужно получить Eventпотом, так как у вас есть только idметод, описанный выше.

Раффи
источник
2

Какой результат я ищу:

  • Люди (исполнители), у которых задачи добавлены в отчет. - Общее количество уникальных людей
  • Люди, у которых в отчет добавлены задачи, но только для задач, платежеспособность которых больше 0.

В общем, мне пришлось бы использовать два разных запроса:

Task.objects.filter(billable_efforts__gt=0)
Task.objects.all()

Но мне нужны оба в одном запросе. Следовательно:

Task.objects.values('report__title').annotate(withMoreThanZero=Count('assignee', distinct=True, filter=Q(billable_efforts__gt=0))).annotate(totalUniqueAssignee=Count('assignee', distinct=True))

Результат:

<QuerySet [{'report__title': 'TestReport', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}, {'report__title': 'Utilization_Report_April_2019', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}]>
Ариндам Ройчоудхури
источник