Простой подзапрос с OuterRef

Я пытаюсь сделать очень простой подзапрос, который использует OuterRef (не для практических целей, просто для его работы), но продолжает работать с той же ошибкой.

сообщений/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

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

Последние две строки должны дать мне количество тегов для каждого объекта Post. И здесь я получаю ту же ошибку:

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

Ответ 1

Одна из проблем вашего примера заключается в том, что вы не можете использовать queryset.count() в качестве подзапроса, потому что .count() пытается оценить набор запросов и вернуть счетчик.

Поэтому можно подумать, что правильным подходом было бы использовать вместо него Count(). Может быть, что-то вроде этого:

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

Это не сработает по двум причинам:

  1. Набор запросов Tag выбирает все поля Tag, тогда как Count может рассчитывать только на одно поле. Таким образом: Tag.objects.filter(post=OuterRef('pk')).only('pk') необходим (чтобы выбрать подсчет tag.pk).

  2. Count сам по себе не является классом Subquery, Count является Aggregate. Таким образом, выражение, сгенерированное Count, не распознается как Subquery (OuterRef требует подзапроса), мы можем исправить это, используя Subquery.

Применение исправлений для 1) и 2) приведет к:

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

Однако если вы проверяете производимый запрос

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"

Вы можете заметить, что у нас есть пункт GROUP BY. Это связано с тем, что Count является Агрегатом, сейчас он не влияет на результат, но в некоторых других случаях может. Вот почему документы предлагают немного другой подход, в котором агрегация перемещается в subquery посредством определенной комбинации 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')
    )
)

Наконец, это приведет к:

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"

Ответ 2

Пакет django-sql-utils делает этот тип агрегации подзапросов простым. Просто pip install django-sql-utils и затем:

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

API для SubqueryCount такой же, как Count, но он генерирует подвыбор в SQL вместо соединения с соответствующей таблицей.