Como filtrar objetos para anotação de contagem no Django?

123

Considere modelos simples do Django Evente 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)

É fácil anotar a consulta de eventos com o número total de participantes:

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

Como fazer anotações com a contagem de participantes filtrada por is_paid=True?

Preciso consultar todos os eventos, independentemente do número de participantes, por exemplo, não preciso filtrar por resultado anotado. Se houver 0participantes, tudo bem, eu só preciso de um 0valor anotado.

O exemplo da documentação não funciona aqui, porque exclui objetos da consulta em vez de anotá-los 0.

Atualizar. O Django 1.8 possui um novo recurso de expressões condicionais , então agora podemos fazer o seguinte:

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

Atualização 2. O Django 2.0 possui o novo recurso de agregação condicional , veja a resposta aceita abaixo.

Rudyryk
fonte

Respostas:

105

A agregação condicional no Django 2.0 permite reduzir ainda mais a quantidade de falhas que houve no passado. Isso também usará a filterlógica do Postgres , que é um pouco mais rápida que um caso de soma (já vi números como 20% a 30%).

De qualquer forma, no seu caso, estamos vendo algo tão simples como:

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

Há uma seção separada nos documentos sobre como filtrar anotações . É o mesmo material da agregação condicional, mas mais como no meu exemplo acima. De qualquer maneira, isso é muito mais saudável do que as subconsultas rudes que eu estava fazendo antes.

Oli
fonte
BTW, não existe esse exemplo no link da documentação, apenas o aggregateuso é mostrado. Você já testou essas consultas? (Eu não tenho e quero acreditar!)
Rudyryk
2
Eu tenho. Eles trabalham. Na verdade, bati em um patch estranho, onde uma subconsulta antiga (super complicada) parou de funcionar após a atualização para o Django 2.0 e consegui substituí-la por uma contagem filtrada super simples. Há um exemplo melhor no documento para anotações, por isso vou abordar isso agora.
Oli
1
Existem algumas respostas aqui, este é o caminho do Django 2.0, e abaixo você encontrará o caminho do Django 1.11 (Subconsultas) e o caminho do Django 1.8.
Ryan Castner
2
Cuidado, se você tentar isso no Django <2, por exemplo, 1.9, ele será executado sem exceção, mas o filtro simplesmente não é aplicado. Portanto, pode parecer funcionar com o Django <2, mas não funciona.
djvg 01/02/19
Se você precisar adicionar vários filtros, poderá adicioná-los no argumento Q () com separados por, como exemplo filter = Q (participantes__is_paid = Verdadeiro, algo mais = valor)
Tobit
93

Acabei de descobrir que o Django 1.8 possui um novo recurso de expressões condicionais , agora podemos fazer o seguinte:

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
fonte
Essa é uma solução qualificada quando os itens correspondentes são muitos? Digamos que eu queira contar os eventos de clique que ocorreram na última semana.
precisa saber é o seguinte
Por que não? Quero dizer, por que seu caso é diferente? No caso acima, pode haver qualquer número de participantes pagos no evento.
Rudyryk #
Acho que a pergunta que @SverkerSbrg está perguntando é se isso é ineficiente para conjuntos grandes, em vez de funcionar ou não ... correto? O mais importante é saber que ele não está fazendo isso em python, está criando uma cláusula de caso SQL - consulte github.com/django/django/blob/master/django/db/models/… -, portanto, o desempenho será razoável, exemplo simples seria melhor do que uma junção, mas versões mais complexas podem incluir subconsultas etc.
Hayden Crocker
1
Ao usar isso com Count(em vez de Sum), acho que devemos definir default=None(se não estiver usando o filterargumento django 2 ).
djvg 01/02/19
41

ATUALIZAR

A abordagem de subconsulta que eu mencionei agora é suportada no Django 1.11 através de subconsulta-expressões .

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

Eu prefiro isso à agregação (soma + caso) , porque deve ser mais rápido e fácil ser otimizado (com indexação adequada) .

Para versões mais antigas, o mesmo pode ser alcançado usando .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
fonte
Obrigado Todor! Parece que eu encontrei o caminho sem usar .extra, como eu prefiro evitar SQL no Django :) Vou atualizar a pergunta.
rudyryk
1
De nada, estou ciente dessa abordagem, mas até agora era uma solução que não funcionava, por isso não mencionei. No entanto, acabei de descobrir que foi corrigido Django 1.8.2, então acho que você está com essa versão e é por isso que está trabalhando para você. Você pode ler mais sobre isso aqui e aqui
Todor
2
Estou percebendo que isso produz um None quando deveria ser 0. Alguém mais entendeu isso?
11238 StefanJCollier #
@StefanJCollier Sim, eu Nonetambém. Minha solução foi usar Coalesce( from django.db.models.functions import Coalesce). Você usá-lo como este: Coalesce(Subquery(...), 0). Pode haver uma abordagem melhor, no entanto.
Adam Taylor
6

Eu sugeriria usar o .valuesmétodo do seu conjunto de Participantconsultas.

Para resumir, o que você quer fazer é dado por:

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

Um exemplo completo é o seguinte:

  1. Crie 2 Events:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
  2. Adicione Participants a eles:

    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. Agrupe todos os Participants por seu eventcampo:

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

    Aqui é necessário distinto:

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

    O que .valuese .distinctestão fazendo aqui é que eles estão criando dois baldes de Participants agrupadas por seu elemento event. Observe que esses baldes contêm Participant.

  4. Em seguida, você pode fazer anotações nesses baldes, pois eles contêm o conjunto de originais Participant. Aqui queremos contar o número de Participant, isso é simplesmente feito contando os ids dos elementos nesses buckets (já que são Participant):

    Participant.objects\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 10}, {'event': 2, 'id__count': 50}]>
  5. Finalmente, você quer apenas Participantum is_paidser True, basta adicionar um filtro na frente da expressão anterior, e isso gera a expressão mostrada acima:

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

A única desvantagem é que você deve recuperar o valor Eventposterior, pois possui apenas ido método acima.

Raffi
fonte
2

Que resultado estou procurando:

  • Pessoas (cessionário) que têm tarefas adicionadas a um relatório. - Contagem total total de pessoas
  • Pessoas que possuem tarefas adicionadas a um relatório, mas, para tarefas cuja faturamento é superior a 0 apenas.

Em geral, eu precisaria usar duas consultas diferentes:

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

Mas eu quero os dois em uma consulta. Conseqüentemente:

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

Resultado:

<QuerySet [{'report__title': 'TestReport', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}, {'report__title': 'Utilization_Report_April_2019', 'withMoreThanZero': 37, 'totalUniqueAssignee': 50}]>
Arindam Roychowdhury
fonte