web-dev-qa-db-fra.com

Sous-requête simple avec OuterRef

J'essaie de créer une sous-requête très simple qui utilise OuterRef (pas à des fins pratiques, juste pour le faire fonctionner), mais continue de rencontrer la même erreur.

posts/models.py

from Django.db import models

class Tag(models.Model):
    name = models.CharField(max_length=120)
    def __str__(self):
        return self.name

class Post(models.Model):
    title = models.CharField(max_length=120)
    tags = models.ManyToManyField(Tag)
    def __str__(self):
        return self.title

manage.py Code shell

>>> from Django.db.models import OuterRef, Subquery
>>> from posts.models import Tag, Post
>>> tag1 = Tag.objects.create(name='tag1')
>>> post1 = Post.objects.create(title='post1')
>>> post1.tags.add(tag1)
>>> Tag.objects.filter(post=post1.pk)
<QuerySet [<Tag: tag1>]>
>>> tags_list = Tag.objects.filter(post=OuterRef('pk'))
>>> Post.objects.annotate(count=Subquery(tags_list.count()))

Les deux dernières lignes devraient me donner le nombre de balises pour chaque objet Post. Et ici, je reçois toujours la même erreur:

ValueError: This queryset contains a reference to an outer query and may only be used in a subquery.
22
mjuk

L'un des problèmes de votre exemple est que vous ne pouvez pas utiliser queryset.count() comme sous-requête, car .count() essaie d'évaluer le jeu de requêtes et de renvoyer le nombre.

On peut donc penser que la bonne approche serait d'utiliser à la place Count(). Peut-être quelque chose comme ça:

Post.objects.annotate(
    count=Count(Tag.objects.filter(post=OuterRef('pk')))
)

Cela ne fonctionnera pas pour deux raisons:

  1. L'ensemble de requêtes Tag sélectionne tous les champs Tag, tandis que Count ne peut compter que sur un seul champ. Ainsi: Tag.objects.filter(post=OuterRef('pk')).only('pk') est nécessaire (pour sélectionner compter sur tag.pk).

  2. Count lui-même n'est pas une classe Subquery, Count est une Aggregate. Donc l'expression générée par Count n'est pas reconnue comme Subquery, nous pouvons corriger cela en utilisant Subquery.

L'application des correctifs pour 1) et 2) produirait:

Post.objects.annotate(
    count=Count(Subquery(Tag.objects.filter(post=OuterRef('pk')).only('pk')))
)

Cependant si vous inspectez la requête en cours de production

SELECT 
    "tests_post"."id",
    "tests_post"."title",
    COUNT((SELECT U0."id" 
            FROM "tests_tag" U0 
            INNER JOIN "tests_post_tags" U1 ON (U0."id" = U1."tag_id") 
            WHERE U1."post_id" = ("tests_post"."id"))
    ) AS "count" 
FROM "tests_post" 
GROUP BY 
    "tests_post"."id",
    "tests_post"."title"

Vous remarquerez peut-être que nous avons une clause GROUP BY. C'est parce que Count est un agrégat, en ce moment il n'affecte pas le résultat, mais dans certains autres cas, il peut le faire. C'est pourquoi les docs suggèrent une approche un peu différente, où l'agrégation est déplacée dans le subquery via une combinaison spécifique de values + annotate + values

Post.objects.annotate(
    count=Subquery(
        Tag.objects.filter(post=OuterRef('pk'))
            # The first .values call defines our GROUP BY clause
            # Its important to have a filtration on every field defined here
            # Otherwise you will have more than one group per row!!!
            # This will lead to subqueries to return more than one row!
            # But they are not allowed to do that!
            # In our example we group only by post
            # and we filter by post via OuterRef
            .values('post')
            # Here we say: count how many rows we have per group 
            .annotate(count=Count('pk'))
            # Here we say: return only the count
            .values('count')
    )
)

Enfin, cela produira:

SELECT 
    "tests_post"."id",
    "tests_post"."title",
    (SELECT COUNT(U0."id") AS "count" 
            FROM "tests_tag" U0 
            INNER JOIN "tests_post_tags" U1 ON (U0."id" = U1."tag_id") 
            WHERE U1."post_id" = ("tests_post"."id") 
            GROUP BY U1."post_id"
    ) AS "count" 
FROM "tests_post"
49
Todor

Le paquet Django-sql-utils rend ce type d'agrégation de sous-requêtes simple. Juste pip install Django-sql-utils puis:

from sql_util.utils import SubqueryCount
posts = Post.objects.annotate(tag_count=SubqueryCount('tag'))

L'API pour SubqueryCount est identique à Count, mais elle génère une sous-sélection dans le SQL au lieu de se joindre à la table associée.

0
Brad Martsberger