Artigos

Outer joins complexos com FilteredRelations do Django

O ORM do Django é excelente e continua melhorando. Hoje veremos um problema trivial de resolver com SQL puro que, até pouco tempo atrás, não podia ser resolvido com o ORM sem recorrer a abordagens mais complexas e, muitas vezes, menos eficientes.

Preparar um ambiente de teste

Para demonstrar esse problema, vou criar um novo projeto Django com uma aplicação polls. Essa aplicação terá apenas 2 modelos, como mostrado a seguir:

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

Temos um modelo Voter e os votos reais em Vote. Vamos adicionar alguns dados:

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 votou em C++ em 2019 e depois mudou de ideia para Javascript em 2020, nada de errado nisso! Gustavo votou em Javascript em 2020, Kelian votou em C# em 2019 e Micaela ainda não votou.

O requisito

A direção não está satisfeita. Quer ver os resultados de 2020, mas também incluir os eleitores registrados que não votaram. Diz que não consegue fazer isso com os relatórios atuais. Então, começamos a escrever uma consulta para isso, usando nosso querido ORM, é claro.

O primeiro requisito é mostrar todos os eleitores, tenham votado ou não. Isso indica que não podemos simplesmente fazer um inner join das duas tabelas: precisamos de um outer join para manter os registros em Voter que não têm correspondência em Vote. Podemos fazer o Django executar um left join iniciando o queryset pelo lado inverso da relação: 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}

Ótimo! Não parece tão ruim, exceto que temos votos de todos os anos. Nada grave, apenas esquecemos de restringir aos 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}

Espere, agora perdemos Micaela e Kelian porque não têm votos em 2020. Sabemos que deveriam aparecer com None nas suas colunas de Vote, então talvez precisemos 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}

Não! Recuperamos Micaela, mas Kelian continua ausente porque ele tem um voto, porém é de 2019.

Nosso problema é que a ordem em que os filtros são aplicados não atende ao requisito. Vamos ver a 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)

Não é uma consulta complexa, apenas um left join com uma cláusula WHERE. Vamos pensar na ordem dos filtros:

1) Temos os eleitores e os unimos aos votos quando a chave estrangeira em Vote corresponde. Isso deixa a linha de Micaela de fora, e Juan e Kelian têm correspondência com um voto de 2019.

2) Nossa cláusula join é, na verdade, um left join, então todos os eleitores sem correspondência são trazidos de volta, usando NULL nas colunas dos votos. A linha de Micaela volta a aparecer.

3) Filtramos por year = 2020 OR year IS NULL. O ano do voto de Kelian não é 2020 nem NULL, então sua linha é excluída.

O que precisamos é um pouco diferente. No passo 1, precisamos fazer as linhas corresponderem pela chave estrangeira AND pelo ano 2020. Isso deixaria as linhas de Micaela e Kelian de fora no primeiro passo e as incluiria novamente no passo 2 com NULL nas colunas dos votos. Então nosso passo 3 funcionaria perfeitamente!

Isso pode ser feito aplicando nossos filtros na cláusula ON. Em SQL, você pode simplesmente ampliar a cláusula ON do left join com quantas condições precisar. No Django, infelizmente, todas as operações .filter são aplicadas em uma cláusula WHERE, então não há como conseguir isso... até o Django 2.0!

Django 2.0 introduziu os objetos FilteredRelation(). Com eles, você pode adicionar condições extras na cláusula ON do join, que serão combinadas por AND com a condição da chave estrangeira, incluída por padrão. Vamos testar:

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}

Sim! Temos nossos 4 eleitores, e apenas quem votou em 2020 tem seus resultados. Vamos conferir a 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))

De fato, nosso novo filtro é aplicado na cláusula ON, e não há nenhum WHERE à vista. A direção ficará muito satisfeita, tudo graças ao Django 2.0!

“Outer joins complexos com FilteredRelations do Django” por Javier Ayres está sob a licença CC BY SA. Os exemplos de código-fonte estão sob a licença MIT.

Foto de Dhru J.

Classificado em Pesquisa e aprendizado.

Leituras relacionadas