¿Cómo filtrar objetos para anotaciones de recuento en Django?

123

Considere modelos simples de Django Eventy 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)

Es fácil anotar consultas de eventos con el número total de participantes:

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

¿Cómo anotar con el recuento de participantes filtrados por is_paid=True?

Necesito consultar todos los eventos independientemente del número de participantes, por ejemplo, no necesito filtrar por resultado anotado. Si hay 0participantes, está bien, solo necesito0 valor anotado.

El ejemplo de la documentación no funciona aquí, porque excluye objetos de la consulta en lugar de anotarlos con 0.

Actualizar. Django 1.8 tiene una nueva función de expresiones condicionales , por lo que ahora podemos hacer lo siguiente:

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

Actualización 2. Django 2.0 tiene una nueva función de agregación condicional , consulte la respuesta aceptada a continuación.

Rudyryk
fuente

Respuestas:

105

La agregación condicional en Django 2.0 le permite reducir aún más la cantidad de faff que esto ha tenido en el pasado. Esto también usará la filterlógica de Postgres , que es algo más rápida que un caso de suma (he visto números como el 20-30%).

De todos modos, en su caso, estamos viendo algo tan simple como:

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

Hay una sección separada en los documentos sobre el filtrado de anotaciones . Es lo mismo que la agregación condicional pero más como mi ejemplo anterior. De cualquier manera, esto es mucho más saludable que las retorcidas subconsultas que estaba haciendo antes.

Oli
fuente
Por cierto, no hay tal ejemplo en el enlace de documentación, solo aggregatese muestra el uso. ¿Ya ha probado este tipo de consultas? (¡No lo he hecho y quiero creer! :)
rudyryk
2
Yo tengo. Trabajan. De hecho, encontré un parche extraño en el que una subconsulta antigua (súper complicada) dejó de funcionar después de actualizar a Django 2.0 y logré reemplazarla con un conteo filtrado súper simple. Hay un mejor ejemplo en el documento para las anotaciones, así que lo incorporaré ahora.
Oli
1
Aquí hay algunas respuestas, esta es la forma Django 2.0, y debajo encontrará la forma Django 1.11 (Subconsultas) y la forma Django 1.8.
Ryan Castner
2
Cuidado, si se intenta esto en Django <2, por ejemplo, 1,9, se va a ejecutar, sin excepción, pero el filtro simplemente no se aplica. Por lo que puede parecer que funciona con Django <2, pero no es así.
djvg
Si necesita agregar varios filtros, puede agregarlos en el argumento Q () separados por, como filtro de ejemplo = Q (participantes__is_pagado = Verdadero, algo del valor = valor)
Tobit
93

Acabo de descubrir que Django 1.8 tiene una nueva función de expresiones condicionales , así que ahora podemos hacer lo siguiente:

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
fuente
¿Es esta una solución elegible cuando los elementos coincidentes son muchos? Digamos que quiero contar los eventos de clic que ocurrieron la última semana.
SverkerSbrg
Por qué no? Quiero decir, ¿por qué tu caso es diferente? En el caso anterior, puede haber cualquier número de participantes pagados en el evento.
rudyryk
Creo que la pregunta que se hace @SverkerSbrg es si esto es ineficiente para conjuntos grandes, en lugar de si funcionaría o no ... ¿correcto? Lo más importante que debe saber es que no lo está haciendo en python, sino que está creando una cláusula de caso de SQL; consulte github.com/django/django/blob/master/django/db/models/… - por lo que tendrá un rendimiento razonable, Un ejemplo simple sería mejor que una combinación, pero las versiones más complejas podrían incluir subconsultas, etc.
Hayden Crocker
1
Al usar esto con Count(en lugar de Sum), supongo que deberíamos establecer default=None(si no usamos el filterargumento django 2 ).
djvg
41

ACTUALIZAR

El enfoque de subconsulta que menciono ahora es compatible con Django 1.11 a través de subconsultas-expresiones .

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

Prefiero esto a la agregación (suma + caso) , porque debería ser más rápido y fácil de optimizar (con la indexación adecuada) .

Para la versión anterior, se puede lograr lo mismo 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
fuente
¡Gracias Todor! Parece que encontré el camino sin usar .extra, ya que prefiero evitar SQL en Django :) Actualizaré la pregunta.
rudyryk
1
De nada, por cierto, soy consciente de este enfoque, pero hasta ahora era una solución que no funcionaba, por eso no lo mencioné. Sin embargo, acabo de descubrir que se ha solucionado Django 1.8.2, así que supongo que estás con esa versión y es por eso que funciona para ti. Puede leer más sobre eso aquí y aquí
Todor
2
Entiendo que esto produce un Ninguno cuando debería ser 0. ¿Alguien más recibe esto?
StefanJCollier
@StefanJCollier Sí, yo Nonetambién. Mi solución fue usar Coalesce( from django.db.models.functions import Coalesce). Se utiliza de esta manera: Coalesce(Subquery(...), 0). Sin embargo, puede haber un mejor enfoque.
Adam Taylor
6

Sugeriría utilizar el .valuesmétodo de suParticipant consultas en lugar.

En resumen, lo que quieres hacer viene dado por:

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

Un ejemplo completo es el siguiente:

  1. Crear 2 Events:

    event1 = Event.objects.create(title='event1')
    event2 = Event.objects.create(title='event2')
  2. Agregue Participants a ellos:

    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 Participantlos correos electrónicos por su 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)...']>

    Aquí se necesita distinto:

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

    Lo que .valuesy .distinctestán haciendo aquí es que están creando dos cubos de Participants agrupados por su elemento event. Tenga en cuenta que esos cubos contienen Participant.

  4. Luego puede anotar esos depósitos, ya que contienen el conjunto de originales Participant. Aquí queremos contar el número de Participant, esto se hace simplemente contando los ids de los elementos en esos cubos (ya que son Participant):

    Participant.objects\
        .values('event')\
        .distinct()\
        .annotate(models.Count('id'))
    > <QuerySet [{'event': 1, 'id__count': 10}, {'event': 2, 'id__count': 50}]>
  5. Finalmente, solo desea Participantcon un is_paidser True, puede agregar un filtro delante de la expresión anterior, y esto produce la expresión que se muestra arriba:

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

El único inconveniente es que debe recuperar el Eventdespués, ya que solo tiene el iddel método anterior.

Raffi
fuente
2

Qué resultado estoy buscando:

  • Personas (asignatario) que tienen tareas agregadas a un informe. - Recuento total único de personas
  • Personas que tienen tareas agregadas a un informe, pero solo para tareas cuya facturabilidad es superior a 0.

En general, tendría que usar dos consultas diferentes:

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

Pero quiero ambos en una consulta. Por lo tanto:

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
fuente