Как использовать Subquery в Django ORM для аннотации моделей

Ответ

Subquery в Django ORM — это объект, который позволяет создавать вложенные SQL-запросы. Его основное преимущество — возможность выполнять сложные выборки и аннотации, которые зависят от другой таблицы, за один-единственный запрос к базе данных, избегая проблемы N+1.

Практический пример

Допустим, у нас есть модели Post и Comment, и мы хотим для каждого поста получить текст его последнего комментария.

1. Модели:

# models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
    text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

2. Запрос с Subquery:

# views.py
from django.db.models import OuterRef, Subquery
from .models import Post, Comment

# Создаем подзапрос, который для каждого поста (OuterRef('pk'))
# найдет самый новый комментарий.
latest_comment = Comment.objects.filter(
    post=OuterRef('pk')
).order_by('-created_at')

# Аннотируем каждый пост текстом последнего комментария.
# Подзапрос `latest_comment.values('text')[:1]` вернет
# только одно значение — текст одного комментария.
posts_with_last_comment = Post.objects.annotate(
    last_comment_text=Subquery(latest_comment.values('text')[:1])
)

# Теперь у каждого объекта `post` в `posts_with_last_comment`
# будет дополнительное поле `last_comment_text`.

Ключевые аспекты

  • OuterRef: Используется для ссылки на поле из внешнего запроса (в примере — pk модели Post).
  • Эффективность: Вся операция выполняется на уровне СУБД одним SQL-запросом, что гораздо производительнее, чем делать отдельные запросы для каждого объекта в цикле.
  • Ограничение результата: Подзапрос, используемый для аннотации или фильтрации, должен возвращать ровно одно значение (одну строку и один столбец). Для этого часто используется срез [:1].

Ответ 18+ 🔞

Смотри, ну вот объясняю тебе про эти Subquery в Django, как есть. Это такая штука, которая позволяет впихнуть один SQL-запрос внутрь другого, прямо как матрёшку, только полезную, а не ту, что на полке пылится. Главный профит — ты можешь за один заход, одним жирным запросом к базе, наделать таких аннотаций и выборок, что голова кругом, и при этом избежишь этой ебаной проблемы N+1, которая жрёт производительность, как голодный студент доширак.

Давай на пальцах, с примером

Представь, у тебя есть посты и комментарии к ним. И тебе вдруг приспичило для каждого поста вытащить текст самого свежего комментария, не делая отдельный запрос для каждого поста. Вот как это делается.

1. Модели (тут всё стандартно):

# models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
    text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

2. Сам запрос, где и происходит вся магия:

# views.py
from django.db.models import OuterRef, Subquery
from .models import Post, Comment

# Сначала готовим подзапрос. Он как бы говорит: "Эй, для каждого поста снаружи (OuterRef('pk')),
# найди-ка мне его комментарии и отсортируй их так, чтобы самый новый был сверху".
latest_comment = Comment.objects.filter(
    post=OuterRef('pk')
).order_by('-created_at')

# А теперь основным запросом берём все посты и к каждому приклеиваем (аннотируем) результат подзапроса.
# Обрати внимание на [:1] — это чтобы подзапрос вернул одну строчку, а не кучу. Иначе СУБД обосрётся.
posts_with_last_comment = Post.objects.annotate(
    last_comment_text=Subquery(latest_comment.values('text')[:1])
)

# Всё, ебать! Теперь у каждого объекта `post` в этом кверисете есть поле `last_comment_text`
# с текстом последнего комментария. И всё это — ОДНИМ запросом к базе. Красота!

На что смотреть, чтобы не облажаться

  • OuterRef: Это твой крючок, чтобы зацепиться за внешний запрос. Без него подзапрос не поймёт, о каком именно посте ты говоришь. Типа "эй, смотри на pk того поста, который мы сейчас обрабатываем снаружи".
  • Производительность — овердохуища: Вся эта конструкция выполняется на стороне базы данных одним здоровенным SQL-запросом. Это в разы, а то и в сотни раз быстрее, чем в цикле на питоне дёргать базу для каждого поста отдельно. Серьёзно, не надо так.
  • Одно значение — и точка: Подзапрос, который ты суёшь в Subquery, должен вернуть ровно одну колонку и одну строку. Ни больше, ни меньше. Поэтому почти всегда в конце торчит этот срез [:1]. Если забудешь — получишь ошибку, и будет тебе, хитрой жопе, пиздец, а не результат.