Artículos

Outer joins complejos con FilteredRelations de Django

El ORM de Django es excelente y sigue mejorando. Hoy veremos un problema trivial de resolver con SQL puro que, hasta hace poco, no podía resolverse con el ORM sin recurrir a enfoques más complejos y, a menudo, menos eficientes.

Preparar un entorno de prueba

Para demostrar este problema, crearé un nuevo proyecto Django con una aplicación polls. Esta aplicación tendrá solo 2 modelos, como se muestra a continuación:

from django.db import models
​
​
class Voter(models.Model):
    name = models.CharField(max_length=100)
​
​
class Vote(models.Model):
    value = models.CharField(max_length=100)
    voter = models.ForeignKey(Voter, on_delete=models.CASCADE)
    year = models.PositiveIntegerField()

Tenemos un modelo Voter y los votos reales en Vote. Agreguemos algunos datos:

juan = Voter.objects.create(name='Juan')
gustavo = Voter.objects.create(name='Gustavo')
kelian = Voter.objects.create(name='Kelian')
micaela = Voter.objects.create(name='Micaela')
​
Vote.objects.create(voter=Voter.objects.get(name='Juan'), value='C++', year=2019)
Vote.objects.create(voter=Voter.objects.get(name='Juan'), value='Javascript', year=2020)
Vote.objects.create(voter=Voter.objects.get(name='Gustavo'), value='Javascript', year=2020)
Vote.objects.create(voter=Voter.objects.get(name='Kelian'), value='C#', year=2019)

Vemos que Juan votó por C++ en 2019 y luego cambió de opinión a Javascript en 2020, ¡nada de malo en eso! Gustavo votó por Javascript en 2020, Kelian votó por C# en 2019 y Micaela todavía no ha votado.

El requisito

La dirección no está conforme. Quiere ver los resultados de 2020, pero también incluir a los votantes registrados que no votaron. Dice que no puede hacerlo con los informes actuales. Así que nos disponemos a escribir una consulta para esto, usando nuestro querido ORM, por supuesto.

El primer requisito es mostrar a todos los votantes, hayan votado o no. Esto indica que no podemos simplemente hacer un inner join de las dos tablas: necesitamos un outer join para conservar los registros de Voter que no tienen coincidencia en Vote. Podemos hacer que Django ejecute un left join iniciando el queryset desde el lado inverso de la relación: Voter.

In [33]: from polls.models import *
In [34]: from django.db.models import F
In [35]: for v in Voter.objects.annotate(value=F('vote__value'), year=F('vote__year')).values('name', 'value', 'year'):
    ...:     print(v)
    ...:
{'name': 'Juan', 'vote__value': 'C++', 'vote__year': 2019}
{'name': 'Juan', 'vote__value': 'Javascript', 'vote__year': 2020}
{'name': 'Gustavo', 'vote__value': 'Javascript', 'vote__year': 2020}
{'name': 'Kelian', 'vote__value': 'C#', 'vote__year': 2019}
{'name': 'Micaela', 'vote__value': None, 'vote__year': None}

¡Bien! No se ve tan mal, excepto que tenemos votos de todos los años. No es grave, solo olvidamos restringirlo a los votos de 2020:

In [36]: for v in Voter.objects.annotate(value=F('vote__value'), year=F('vote__year')).values('name', 'value', 'year').filter(year=2020):
    ...:     print(v)
    ...:
{'name': 'Juan', 'vote__value': 'Javascript', 'vote__year': 2020}
{'name': 'Gustavo', 'vote__value': 'Javascript', 'vote__year': 2020}

Espera, ahora perdimos a Micaela y Kelian porque no tienen votos en 2020. Sabemos que deberían aparecer con None en sus columnas de Vote, así que quizá debamos filtrar por 2020 OR nulo.

In [37]: from django.db.models import Q
In [38]: for v in Voter.objects.annotate(value=F('vote__value'), year=F('vote__year')).values('name', 'value', 'year').filter(Q(year=2020) | Q(year__isnull=True)):
    ...:     print(v)
    ...:
{'name': 'Juan', 'value': 'Javascript', 'year': 2020}
{'name': 'Gustavo', 'value': 'Javascript', 'year': 2020}
{'name': 'Micaela', 'value': None, 'year': None}

¡No! Recuperamos a Micaela, pero Kelian sigue sin aparecer porque sí tiene un voto, aunque es de 2019.

Nuestro problema es que el orden en el que se aplican los filtros no sirve para el requisito. Veamos la consulta:

In [39]: print(Voter.objects.annotate(value=F('vote__value'), year=F('vote__year')).values('name', 'value', 'year').filter(Q(year=2020) | Q(year__isnull=True)).query)
SELECT "polls_voter"."name", "polls_vote"."value" AS "value", "polls_vote"."year" AS "year" FROM "polls_voter" LEFT OUTER JOIN "polls_vote" ON ("polls_voter"."id" = "polls_vote"."voter_id") WHERE ("polls_vote"."year" = 2020 OR "polls_vote"."year" IS NULL)

No es una consulta compleja, solo un left join con una cláusula WHERE. Pensemos en el orden de los filtros:

1) Tenemos los votantes y los unimos con los votos cuando coincide la clave foránea de Vote. Esto deja fuera la fila de Micaela, y Juan y Kelian tienen una coincidencia con un voto de 2019.

2) Nuestra cláusula join es en realidad un left join, así que se reincorporan todos los votantes que no tuvieron coincidencia, usando NULL para las columnas del voto. La fila de Micaela vuelve a aparecer.

3) Filtramos por year = 2020 OR year IS NULL. El año del voto de Kelian no es 2020 ni NULL, así que su fila queda fuera.

Lo que necesitamos es un poco distinto. En el paso 1, debemos hacer coincidir las filas por la clave foránea AND el año 2020. Esto dejaría fuera las filas de Micaela y Kelian en el primer paso y las reincorporaría en el paso 2 con NULL en las columnas de sus votos. ¡Entonces nuestro paso 3 funcionaría perfectamente!

Esto puede hacerse aplicando los filtros en la cláusula ON. En SQL puedes ampliar la cláusula ON del left join con tantas condiciones como necesites. En Django, lamentablemente, todas las operaciones .filter se aplican en una cláusula WHERE, así que no hay forma de lograrlo... ¡hasta Django 2.0!

Django 2.0 introdujo los objetos FilteredRelation(). Con ellos puedes agregar condiciones adicionales en la cláusula ON del join, que se combinarán mediante AND con la condición de la clave foránea, incluida por defecto. Probémoslo:

In [41]: from django.db.models import FilteredRelation
In [41]: for v in Voter.objects.annotate(votes2020=FilteredRelation('vote', condition=Q(vote__year=2020))).values('name', 'votes2020__value', 'votes2020__year'):
    ...:     print(v)
    ...:
{'name': 'Juan', 'votes2020__value': 'Javascript', 'votes2020__year': 2020}
{'name': 'Gustavo', 'votes2020__value': 'Javascript', 'votes2020__year': 2020}
{'name': 'Micaela', 'votes2020__value': None, 'votes2020__year': None}
{'name': 'Kelian', 'votes2020__value': None, 'votes2020__year': None}

¡Sí! Tenemos nuestros 4 votantes y solo quienes votaron en 2020 tienen sus resultados. Comprobemos la consulta:

In [42]: print(Voter.objects.annotate(votes2020=FilteredRelation('vote', condition=Q(vote__year=2020))).values('name', 'votes2020__value', 'votes2020__year').query)
SELECT "polls_voter"."name", votes2020."value", votes2020."year" FROM "polls_voter" LEFT OUTER JOIN "polls_vote" votes2020 ON ("polls_voter"."id" = votes2020."voter_id" AND (votes2020."year" = 2020))

En efecto, nuestro nuevo filtro se aplica en la cláusula ON y no hay ningún WHERE a la vista. ¡La dirección estará muy satisfecha, todo gracias a Django 2.0!

“Outer joins complejos con FilteredRelations de Django” de Javier Ayres está bajo la licencia CC BY SA. Los ejemplos de código fuente están bajo la licencia MIT.

Foto de Dhru J.

Clasificado en Investigación y aprendizaje.

Lecturas relacionadas