Django filtra queryset __in para * cada * item na lista

101

Digamos que tenho os seguintes modelos

class Photo(models.Model):
    tags = models.ManyToManyField(Tag)

class Tag(models.Model):
    name = models.CharField(max_length=50)

Em uma exibição, tenho uma lista com filtros ativos chamados categorias . Quero filtrar objetos de fotos que têm todas as marcas presentes nas categorias .

Eu tentei:

Photo.objects.filter(tags__name__in=categories)

Mas isso corresponde a qualquer item nas categorias, não a todos os itens.

Portanto, se as categorias forem ['feriado', 'verão'], quero fotos com a marca de feriado e verão.

Isso pode ser alcançado?

Sander van Leeuwen
fonte
6
Talvez: qs = Photo.objects.all (); para categoria em categorias: qs = qs.filter (tags__name = categoria)
jpic
2
jpic está certo, Photo.objects.filter(tags__name='holiday').filter(tags__name='summer')é o caminho a percorrer. (Este é o mesmo exemplo de Jpic). Cada um filterdeve adicionar mais JOINs à consulta, então você pode adotar uma abordagem de anotação se eles forem muitos.
Davor Lucic
1
Aqui está a referência nos documentos: docs.djangoproject.com/en/dev/topics/db/queries/…
sgallen
Você esperaria que houvesse uma função integrada para isso por Django
Vincent

Respostas:

124

Resumo:

Uma opção é, como sugerido por jpic e sgallen nos comentários, adicionar .filter()para cada categoria. Cada adicional filteradiciona mais junções, o que não deve ser um problema para um pequeno conjunto de categorias.

Existe a abordagem de agregação . Essa consulta seria mais curta e talvez mais rápida para um grande conjunto de categorias.

Você também tem a opção de usar consultas personalizadas .


Alguns exemplos

Configuração de teste:

class Photo(models.Model):
    tags = models.ManyToManyField('Tag')

class Tag(models.Model):
    name = models.CharField(max_length=50)

    def __unicode__(self):
        return self.name

In [2]: t1 = Tag.objects.create(name='holiday')
In [3]: t2 = Tag.objects.create(name='summer')
In [4]: p = Photo.objects.create()
In [5]: p.tags.add(t1)
In [6]: p.tags.add(t2)
In [7]: p.tags.all()
Out[7]: [<Tag: holiday>, <Tag: summer>]

Usando a abordagem de filtros em cadeia :

In [8]: Photo.objects.filter(tags=t1).filter(tags=t2)
Out[8]: [<Photo: Photo object>]

Consulta resultante:

In [17]: print Photo.objects.filter(tags=t1).filter(tags=t2).query
SELECT "test_photo"."id"
FROM "test_photo"
INNER JOIN "test_photo_tags" ON ("test_photo"."id" = "test_photo_tags"."photo_id")
INNER JOIN "test_photo_tags" T4 ON ("test_photo"."id" = T4."photo_id")
WHERE ("test_photo_tags"."tag_id" = 3  AND T4."tag_id" = 4 )

Observe que cada um filteradiciona mais JOINSà consulta.

Usando abordagem de anotação :

In [29]: from django.db.models import Count
In [30]: Photo.objects.filter(tags__in=[t1, t2]).annotate(num_tags=Count('tags')).filter(num_tags=2)
Out[30]: [<Photo: Photo object>]

Consulta resultante:

In [32]: print Photo.objects.filter(tags__in=[t1, t2]).annotate(num_tags=Count('tags')).filter(num_tags=2).query
SELECT "test_photo"."id", COUNT("test_photo_tags"."tag_id") AS "num_tags"
FROM "test_photo"
LEFT OUTER JOIN "test_photo_tags" ON ("test_photo"."id" = "test_photo_tags"."photo_id")
WHERE ("test_photo_tags"."tag_id" IN (3, 4))
GROUP BY "test_photo"."id", "test_photo"."id"
HAVING COUNT("test_photo_tags"."tag_id") = 2

ANDQobjetos ed não funcionariam:

In [9]: from django.db.models import Q
In [10]: Photo.objects.filter(Q(tags__name='holiday') & Q(tags__name='summer'))
Out[10]: []
In [11]: from operator import and_
In [12]: Photo.objects.filter(reduce(and_, [Q(tags__name='holiday'), Q(tags__name='summer')]))
Out[12]: []

Consulta resultante:

In [25]: print Photo.objects.filter(Q(tags__name='holiday') & Q(tags__name='summer')).query
SELECT "test_photo"."id"
FROM "test_photo"
INNER JOIN "test_photo_tags" ON ("test_photo"."id" = "test_photo_tags"."photo_id")
INNER JOIN "test_tag" ON ("test_photo_tags"."tag_id" = "test_tag"."id")
WHERE ("test_tag"."name" = holiday  AND "test_tag"."name" = summer )
Davor Lucic
fonte
6
Existe uma solução com uma pesquisa personalizada? docs.djangoproject.com/en/1.10/howto/custom-lookups Seria legal mudar "__in" para "__all" e fazer com que ele criasse a consulta sql correta.
t1m0
1
Esta solução de anotação parece errada. E se houver três tags possíveis (vamos chamar a adicional para t3, e uma foto tiver as tags t2e t3. Então, essa foto ainda corresponderá à consulta fornecida.
beruic
@beruic Acho que a ideia é que você substitua num_tags = 2 por num_tags = len (tags); Espero que o 2 embutido no código tenha sido apenas um exemplo.
tbm
3
@tbm Mesmo assim, não funcionaria. Photo.objects.filter(tags__in=tags)corresponde a fotos que têm qualquer uma das marcas, não apenas aquelas que têm todas. Alguns daqueles que possuem apenas uma das tags desejadas, podem ter exatamente a quantidade de tags que você está procurando, e alguns daqueles que possuem todas as tags desejadas, também podem ter tags adicionais.
beruic
1
@beruic a anotação conta apenas as tags retornadas pela consulta, então se (núm tags retornadas pela consulta) == (núm tags pesquisadas) então a linha é incluída; tags "extras" não são pesquisadas, portanto, não serão contadas. Eu verifiquei isso em meu próprio aplicativo.
tbm
8

Outra abordagem que funciona, embora apenas PostgreSQL, é usar django.contrib.postgres.fields.ArrayField :

Exemplo copiado de documentos :

>>> Post.objects.create(name='First post', tags=['thoughts', 'django'])
>>> Post.objects.create(name='Second post', tags=['thoughts'])
>>> Post.objects.create(name='Third post', tags=['tutorial', 'django'])

>>> Post.objects.filter(tags__contains=['thoughts'])
<QuerySet [<Post: First post>, <Post: Second post>]>

>>> Post.objects.filter(tags__contains=['django'])
<QuerySet [<Post: First post>, <Post: Third post>]>

>>> Post.objects.filter(tags__contains=['django', 'thoughts'])
<QuerySet [<Post: First post>]>

ArrayFieldtem alguns recursos mais poderosos, como sobreposição e transformações de índice .

Sander van Leeuwen
fonte
3

Isso também pode ser feito por geração de consulta dinâmica usando Django ORM e alguma mágica do Python :)

from operator import and_
from django.db.models import Q

categories = ['holiday', 'summer']
res = Photo.filter(reduce(and_, [Q(tags__name=c) for c in categories]))

A ideia é gerar objetos Q apropriados para cada categoria e então combiná-los usando o operador AND em um QuerySet. Por exemplo, para o seu exemplo, seria igual a

res = Photo.filter(Q(tags__name='holiday') & Q(tags__name='summer'))
demalexx
fonte
3
Isso não funcionaria. Seus exemplos de consulta não retornariam nada para os modelos em questão.
Davor Lucic
Obrigado pela correção. Achei que encadear filterseria o mesmo que usar andobjetos Q em um filtro ... Erro meu.
demalexx
Não se preocupe, meu primeiro pensamento onde também objetos Q.
Davor Lucic
1
Isso seria mais lento se você trabalhar com tabelas grandes e dados grandes para comparação. (como 1 milhão cada)
gies0r
Essa abordagem deve funcionar se você alternar de filterpara excludee usar um operador de negação. Assim: res = Photo.exclude(~reduce(and_, [Q(tags__name=c) for c in categories]))
Ben
1

Eu uso uma pequena função que itera filtros em uma lista para um determinado operador e um nome de coluna:

def exclusive_in (cls,column,operator,value_list):         
    myfilter = column + '__' + operator
    query = cls.objects
    for value in value_list:
        query=query.filter(**{myfilter:value})
    return query  

e esta função pode ser chamada assim:

exclusive_in(Photo,'tags__name','iexact',['holiday','summer'])

também funciona com qualquer classe e mais tags na lista; operadores podem ser qualquer um como 'iexact', 'in', 'contains', 'ne', ...

David
fonte
0
queryset = Photo.objects.filter(tags__name="vacaciones") | Photo.objects.filter(tags__name="verano")
Edgar eduardo de los santos
fonte
-1

Se quisermos fazer isso de forma dinâmica, siga o exemplo:

tag_ids = [t1.id, t2.id]
qs = Photo.objects.all()

for tag_id in tag_ids:
    qs = qs.filter(tag__id=tag_id)    

print qs
tarasinf
fonte
Pode não funcionar tão logo a segunda iteração, o queryset estará vazia
lapin