web-dev-qa-db-fra.com

Comment filtrer les objets pour les annotations de compte dans Django?

Considérez simple Django modèles Event et 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 une 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?

Je dois 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 0 participants, ça va, j'ai juste besoin de 0 en valeur annotée.

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

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

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 fonction Agrégation conditionnelle , voir la réponse acceptée ci-dessous.

103
rudyryk

Agrégation conditionnelle in Django 2.0 vous permet de réduire davantage la quantité de faff que cela a été dans le passé. Ceci utilisera également la logique filter de Postgres , ce qui est un peu plus rapide qu’une somme (j’ai vu des chiffres comme 20 à 30% circuler).

Quoi qu'il en soit, dans votre cas, nous cherchons 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 y a une section séparée dans la documentation sur filtrage sur les annotations . C'est la même chose que l'agrégation conditionnelle mais ressemble plus à mon exemple ci-dessus. Quoi qu’il en soit, c’est beaucoup plus sain que les sous-requêtes ignobles que je faisais auparavant.

63
Oli

Je viens de découvrir que Django 1.8 a une nouvelle fonctionnalité d’expressions conditionnelles , nous pouvons donc procéder comme suit:

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

[~ # ~] met à jour [~ # ~]

L'approche de sous-requête que je mentionne est maintenant supportée dans Django 1.11 via sous-requêtes-expressions .

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 ceci à l'agrégation (somme + cas), car il devrait être plus rapide et plus facile à optimiser (avec une indexation correcte).

Pour les versions plus anciennes, il est possible d’atteindre le même résultat avec .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`"
})
38
Todor

Je suggère d'utiliser le .values méthode de votre Participant queryset à la place.

En bref, 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éez 2 Events:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
    
  2. Ajoutez-y 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 Participants par leur champ 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)...']>
    

    Ici distincte est nécessaire:

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

    Quoi .values et .distinct _ sont en train de créer deux compartiments de Participants regroupés par leur élément event. Notez que ces compartiments contiennent Participant.

  4. Vous pouvez ensuite annoter ces compartiments car ils contiennent l'ensemble de Participant d'origine. Ici, nous voulons compter le nombre de Participant, ceci simplement en comptant les ids des éléments de ces compartiments (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 que Participant avec un is_paid étant True, vous pouvez simplement ajouter un filtre devant l'expression précédente, ce qui 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 le Event ultérieurement car vous ne disposez que du id de la méthode ci-dessus.

4
Raffi

Quel résultat je recherche:

  • Personnes (cessionnaire) auxquelles des tâches sont ajoutées à un rapport. - Nombre total unique de personnes
  • Les personnes qui ont des tâches ajoutées à un rapport mais, pour la tâche qui facture, la capacité est supérieure à 0 seulement.

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 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}]>
0