Comment filtrer les objets pour l'annotation de comptage dans Django?


123

Considérez les modèles Django simples Eventet 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)

Il est facile d'annoter la requête d'événements avec le nombre total de participants:

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

Comment annoter avec le nombre de participants filtrés par is_paid=True?

J'ai besoin d'interroger tous les événements quel que soit le nombre de participants, par exemple je n'ai pas besoin de filtrer par résultat annoté. S'il y a des 0participants, ça va, j'ai juste besoin 0d'une valeur annotée.

L' exemple de la documentation ne fonctionne pas ici, car il exclut les objets de la requête au lieu de les annoter avec 0.

Mettre à jour. Django 1.8 a une nouvelle fonctionnalité d'expressions conditionnelles , donc maintenant nous pouvons faire comme ceci:

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

Mise à jour 2. Django 2.0 a une nouvelle fonctionnalité d' agrégation conditionnelle , voir la réponse acceptée ci-dessous.

Réponses:


105

L'agrégation conditionnelle dans Django 2.0 vous permet de réduire davantage la quantité de faff que cela a été dans le passé. Cela utilisera également la filterlogique de Postgres , qui est un peu plus rapide qu'un cas de somme (j'ai vu des nombres comme 20-30% bandés).

Quoi qu'il en soit, dans votre cas, nous examinons quelque chose d'aussi simple que:

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

Il existe une section distincte dans la documentation sur le filtrage des annotations . C'est la même chose que l'agrégation conditionnelle mais plus comme mon exemple ci-dessus. Dans tous les cas, c'est beaucoup plus sain que les sous-requêtes noueuses que je faisais auparavant.


BTW, il n'y a pas d'exemple de ce type par le lien de documentation, seule l' aggregateutilisation est indiquée. Avez-vous déjà testé de telles requêtes? (Je n'ai pas et je veux croire! :)
rudyryk

2
J'ai. Ils travaillent. En fait, j'ai frappé un correctif étrange où une ancienne sous-requête (super compliquée) a cessé de fonctionner après la mise à niveau vers Django 2.0 et j'ai réussi à la remplacer par un compte filtré super simple. Il existe un meilleur exemple dans le document pour les annotations, donc je vais le faire maintenant.
Oli

1
Il y a quelques réponses ici, c'est la manière Django 2.0, et ci-dessous vous trouverez la manière Django 1.11 (Sous-requêtes), et la manière Django 1.8.
Ryan Castner

2
Attention, si vous essayez ceci dans Django <2, par exemple 1.9, il sera exécuté sans exception, mais le filtre n'est pas appliquée simplement. Il peut donc sembler fonctionner avec Django <2, mais ce n'est pas le cas.
djvg

Si vous avez besoin d'ajouter plusieurs filtres, vous pouvez les ajouter dans l'argument Q () avec séparés par, comme exemple filter = Q (participants__is_paid = True, somethingelse = value)
Tobit

93

Je viens de découvrir que Django 1.8 a une nouvelle fonctionnalité d'expressions conditionnelles , donc maintenant nous pouvons faire comme ceci:

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

Est-ce une solution éligible lorsque les éléments correspondants sont nombreux? Disons que je veux compter les événements de clic qui se sont produits la semaine dernière.
SverkerSbrg

Pourquoi pas? Je veux dire, pourquoi votre cas est-il différent? Dans le cas ci-dessus, il peut y avoir un nombre quelconque de participants payés sur l'événement.
rudyryk

Je pense que la question que se pose @SverkerSbrg est de savoir si cela est inefficace pour les grands ensembles, plutôt que si cela fonctionnerait ou non ... correct? La chose la plus importante à savoir est qu'il ne le fait pas en python, il crée une clause de cas SQL - voir github.com/django/django/blob/master/django/db/models / ... - donc il sera raisonnablement performant, un exemple simple serait mieux qu'une jointure, mais des versions plus complexes pourraient inclure des sous-requêtes, etc.
Hayden Crocker

1
Lorsque vous utilisez ceci avec Count(au lieu de Sum), je suppose que nous devrions définir default=None(si vous n'utilisez pas l' filterargument django 2 ).
djvg

41

METTRE À JOUR

L'approche de sous-requête que je mentionne est désormais prise en charge dans Django 1.11 via des expressions de sous-requête .

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

Je préfère cela à l'agrégation (somme + cas) , car il devrait être plus rapide et plus facile à optimiser (avec une indexation appropriée) .

Pour les versions plus anciennes, la même chose peut être obtenue en utilisant .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`"
})

Merci Todor! On dirait que j'ai trouvé le chemin sans utiliser .extra, car je préfère éviter SQL dans Django :) Je vais mettre à jour la question.
rudyryk

1
Vous êtes les bienvenus, car je suis au courant de cette approche, mais c'était une solution qui ne fonctionnait pas jusqu'à présent, c'est pourquoi je n'en ai pas parlé. Cependant, je viens de constater qu'il a été corrigé Django 1.8.2, donc je suppose que vous êtes avec cette version et c'est pourquoi cela fonctionne pour vous. Vous pouvez en savoir plus à ce sujet ici et ici
Todor

2
J'obtiens que cela produit un None alors qu'il devrait être 0. Quelqu'un d'autre obtient-il cela?
StefanJCollier

@StefanJCollier Oui, je l'ai Noneaussi. Ma solution était d'utiliser Coalesce( from django.db.models.functions import Coalesce). Vous pouvez l' utiliser comme ceci: Coalesce(Subquery(...), 0). Il y a peut-être une meilleure approche, cependant.
Adam Taylor

6

Je suggérerais d'utiliser la .valuesméthode de votre jeu de Participantrequêtes à la place.

Pour faire court, ce que vous voulez faire est donné par:

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

Un exemple complet est le suivant:

  1. Créer 2 Events:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
  2. Ajoutez-y des Participants:

    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. Regroupez tous les Participants par leur eventdomaine:

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

    Ici, distinct est nécessaire:

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

    Ce qu'ils font .valueset .distinctfont ici, c'est qu'ils créent deux seaux de Participants regroupés par leur élément event. Notez que ces buckets contiennent Participant.

  4. Vous pouvez ensuite annoter ces compartiments car ils contiennent l'ensemble de l'original Participant. Ici, nous voulons compter le nombre de Participant, cela se fait simplement en comptant les ids des éléments dans ces buckets (puisque ceux-ci sont Participant):

    Participant.objects\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 10}, {'event': 2, 'id__count': 50}]>
  5. Enfin, vous ne voulez Participantqu'avec un is_paidêtre True, vous pouvez simplement ajouter un filtre devant l'expression précédente, et cela donne l'expression ci-dessus:

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

Le seul inconvénient est que vous devez récupérer la Eventsuite car vous ne disposez que idde la méthode ci-dessus.


2

Quel résultat je recherche:

  • Personnes (cessionnaire) qui ont des tâches ajoutées à un rapport. - Nombre total unique de personnes
  • Les personnes qui ont des tâches ajoutées à un rapport mais, pour une tâche dont la facturation est supérieure à 0 uniquement.

En général, je devrais utiliser deux requêtes différentes:

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

Mais je veux les deux dans une seule requête. Par conséquent:

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

Résultat:

<QuerySet [{'report__title': 'TestReport', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}, {'report__title': 'Utilization_Report_April_2019', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}]>
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.