Як відфільтрувати об’єкти для анотації підрахунку в Django?


123

Розглянемо прості моделі Джанго 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 має нову функцію умовного агрегування , див . Прийняту відповідь нижче.

Відповіді:


105

Умовна агрегація в 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))
)

В документах є окремий розділ про фільтрування приміток . Це те саме, що і умовне агрегування, але більше схоже на мій приклад вище. У будь-якому випадку це набагато здоровіше, ніж те, що я робив раніше.


До речі, подібного прикладу за посиланням на документацію немає, aggregateпоказано лише використання. Ви вже протестували такі запити? (У мене немає і я хочу вірити! :)
rudyryk

2
У мене є. Вони працюють. Я насправді потрапив у дивний патч, де старий (надскладний) підзапит перестав працювати після оновлення до Django 2.0, і мені вдалося замінити його на суперпростий відфільтрований підрахунок. Є кращий in-doc приклад для анотацій, тож я зараз розкрию це.
Олі

1
Тут є кілька відповідей, це спосіб Django 2.0, а нижче ви знайдете спосіб Django 1.11 (Subqueries) та Django 1.8.
Райан Кастнер

2
Остерігайтеся, якщо ви спробуєте це в Django <2, наприклад 1.9, він буде працювати без винятку, але фільтр просто не застосовується. Тож може з’явитися робота з Джанго <2, але це не так.
djvg

Якщо вам потрібно додати кілька фільтрів, ви можете додати їх у аргумент Q () з розділеним, наприклад, filter = Q (учасники__is_paid = True, somethingelse = значення)
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()
    )))

Це прийнятне рішення, коли відповідних елементів багато? Скажімо, що я хочу порахувати події, що відбулися останній тиждень.
SverkerSbrg

Чому ні? Я маю на увазі, чому ваш випадок відрізняється? У випадку вище, на будь-яку кількість платних учасників може бути будь-яка кількість платних учасників.
рудирик

Я думаю, що питання @SverkerSbrg задається питанням, чи це неефективно для великих наборів, а не те, чи не спрацювало б це? Найголовніше , щоб знати, що це не робить його в пітон, це створює умови випадку SQL - см github.com/django/django/blob/master/django/db/models / ... - так що це буде досить продуктивним, простий приклад був би кращим, ніж приєднання, але більш складні версії можуть включати підзапити тощо.
Hayden Crocker

1
При використанні цього з Count(замість Sum), я думаю, ми повинні встановити default=None(якщо не використовуємо filterаргумент django 2 ).
djvg

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 :) Я оновлю питання.
рудирик

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до них s:

    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. Згрупуйте всі Participants за своїм 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}]>
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.