Jak filtrować obiekty pod kątem adnotacji zliczania w Django?

123

Rozważ proste modele Django Eventi 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)

W zapytaniu dotyczącym wydarzeń można łatwo opisać całkowitą liczbę uczestników:

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

Jak dodawać adnotacje z liczbą przefiltrowanych uczestników is_paid=True?

Muszę odpytywać wszystkie zdarzenia niezależnie od liczby uczestników, np. Nie muszę filtrować według wyników z adnotacjami. Jeśli są 0uczestnicy, w porządku, potrzebuję tylko 0wartości z adnotacjami.

Przykład z dokumentacji nie działa tutaj, ponieważ wyklucza obiektów z kwerendy zamiast opisywania ich 0.

Aktualizacja. Django 1.8 ma nową funkcję wyrażeń warunkowych , więc teraz możemy zrobić tak:

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()
    )))

Aktualizacja 2. Django 2.0 ma nową funkcję agregacji warunkowej , zobacz zaakceptowaną odpowiedź poniżej.

rudyryk
źródło

Odpowiedzi:

105

Agregacja warunkowa w Django 2.0 pozwala na dalsze zmniejszenie ilości błędów, które miały miejsce w przeszłości. Spowoduje to również użycie filterlogiki Postgresa , która jest nieco szybsza niż przypadek sumy (widziałem liczby takie jak 20-30% przewijane).

W każdym razie w Twoim przypadku patrzymy na coś tak prostego, jak:

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

W dokumentach znajduje się osobna sekcja dotycząca filtrowania adnotacji . To to samo, co agregacja warunkowa, ale bardziej przypomina mój przykład powyżej. Tak czy inaczej, jest to o wiele zdrowsze niż sękate podzapytania, które robiłem wcześniej.

Oli
źródło
BTW, nie ma takiego przykładu w linku do dokumentacji, aggregatepokazane jest tylko użycie. Czy przetestowałeś już takie zapytania? (Nie mam i chcę wierzyć! :)
rudyryk
2
Mam. Oni pracują. Właściwie trafiłem na dziwną łatkę, w której stare (super skomplikowane) podzapytanie przestało działać po aktualizacji do Django 2.0 i udało mi się zastąpić je super prostą liczbą filtrowaną. Jest lepszy przykład adnotacji w dokumencie, więc wyciągnę go teraz.
Oli
1
Jest tutaj kilka odpowiedzi, to jest droga Django 2.0, a poniżej znajdziesz sposób Django 1.11 (podzapytania) i sposób Django 1.8.
Ryan Castner
2
Uwaga, jeśli spróbujesz tego w Django <2, np. 1.9, zadziała to bez wyjątku, ale filtr po prostu nie zostanie zastosowany. Więc może się wydawać, że działa z Django <2, ale tak nie jest.
djvg
Jeśli chcesz dodać wiele filtrów, możesz dodać je w argumencie Q () oddzielając je, jako przykład, filter = Q (Participants__is_paid = True, somethingelse = value)
Tobit
93

Właśnie odkryłem, że Django 1.8 ma nową funkcję wyrażeń warunkowych , więc teraz możemy zrobić tak:

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
źródło
Czy to kwalifikujące się rozwiązanie, gdy pasujących elementów jest wiele? Powiedzmy, że chcę policzyć kliknięcia, które miały miejsce w ostatnim tygodniu.
SverkerSbrg
Dlaczego nie? To znaczy, dlaczego twoja sprawa jest inna? W powyższym przypadku na imprezie może uczestniczyć dowolna liczba opłaconych uczestników.
rudyryk 08.04.17
Myślę, że pytanie, które zadaje @SverkerSbrg, brzmi, czy jest to nieefektywne dla dużych zestawów, a nie czy to zadziała ... prawda? Najważniejsze, aby wiedzieć, że nie robi tego w Pythonie, ale tworzy klauzulę wielkości liter w SQL - zobacz github.com/django/django/blob/master/django/db/models/ ... - więc będzie dość wydajne, prosty przykład byłby lepszy niż łączenie, ale bardziej złożone wersje mogą zawierać podzapytania itp.
Hayden Crocker
1
Używając tego z Count(zamiast Sum), myślę, że powinniśmy ustawić default=None(jeśli nie używamy filterargumentu django 2 ).
djvg
41

AKTUALIZACJA

Podejście pod-zapytań, o którym wspominałem, jest teraz obsługiwane w Django 1.11 za pośrednictwem wyrażeń podzapytań .

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()
    )
)

Wolę to od agregacji (suma + przypadek) , ponieważ powinno być szybsze i łatwiejsze do optymalizacji (przy odpowiednim indeksowaniu) .

W przypadku starszej wersji to samo można osiągnąć za pomocą .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`"
})
Todor
źródło
Dzięki Todor! Wygląda na to, że znalazłem sposób bez użycia .extra, ponieważ wolę unikać SQL w Django :) Zaktualizuję pytanie.
rudyryk
1
Nie ma za co, bo wiem o tym podejściu, ale do tej pory było to rozwiązanie niedziałające, dlatego o tym nie wspomniałem. Jednak właśnie odkryłem, że zostało to naprawione w Django 1.8.2, więc myślę, że jesteś z tą wersją i dlatego działa dla ciebie. Możesz przeczytać więcej na ten temat tutaj i tutaj
Todor
2
Rozumiem, że to daje None, kiedy powinno być 0. Czy ktoś jeszcze to otrzymuje?
StefanJCollier,
@StefanJCollier Tak, ja Noneteż. Moim rozwiązaniem było użycie Coalesce( from django.db.models.functions import Coalesce). Go używać tak: Coalesce(Subquery(...), 0). Jednak może być lepsze podejście.
Adam Taylor
6

Sugerowałbym zamiast tego użycie .valuesmetody twojego Participantzestawu zapytań.

Krótko mówiąc, to, co chcesz zrobić, to:

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

Kompletny przykład jest następujący:

  1. Utwórz 2 Events:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
  2. Dodaj Participantdo nich 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. Grupuj wszystkie Participantwedług ich eventpól:

    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)...']>

    Tutaj potrzebny jest odrębny:

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

    Co .valuesi .distinctrobią tutaj, to to, że tworzą dwa segmenty Participants pogrupowane według ich elementu event. Zauważ, że te zasobniki zawierają Participant.

  4. Następnie możesz dodać adnotacje do tych zasobników, ponieważ zawierają one zestaw oryginałów Participant. Tutaj chcemy policzyć liczbę Participant, robimy to po prostu przez policzenie ids elementów w tych zasobnikach (ponieważ są Participant):

    Participant.objects\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 10}, {'event': 2, 'id__count': 50}]>
  5. Ostatecznie chcesz tylko Participantz is_paidistotą True, możesz po prostu dodać filtr przed poprzednim wyrażeniem, a to daje wyrażenie pokazane powyżej:

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

Jedyną wadą jest to, że musisz odzyskać Eventpóźniej, ponieważ masz tylko idz powyższej metody.

Raffi
źródło
2

Jakiego wyniku szukam:

  • Osoby (cesjonariusz), które mają zadania dodane do raportu. - Całkowita unikalna liczba osób
  • Osoby, które mają zadania dodane do raportu, ale tylko dla zadań, których rozliczalność jest większa niż 0.

Generalnie musiałbym użyć dwóch różnych zapytań:

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

Ale chcę, aby oba w jednym zapytaniu. W związku z tym:

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

Wynik:

<QuerySet [{'report__title': 'TestReport', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}, {'report__title': 'Utilization_Report_April_2019', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}]>
Arindam Roychowdhury
źródło