Базовых методов filter(), exclude(), order_by() достаточно для большинства задач. Но есть три ситуации, где их не хватает:
- нужно составное условие с
OR, а не толькоAND— для этогоQ - нужно сравнить два поля одной записи между собой — для этого
F - нужно посчитать агрегат (сумму, среднее, количество) — для этого функции агрегации и
annotate()
Связь с прошлым курсом. В модуле 1 прошлого курса мы писали
GROUP BY,COUNT(),AVG()напрямую в SQL. Всё, что разберём в этом уроке, — это ORM-обёртка над теми же конструкциями. Если синтаксис Django покажется запутанным — мысленно переводи его в SQL, который мы уже знаем.
Обычная цепочка filter(a=1, b=2) или filter(a=1).filter(b=2) работает как AND. Но что если нужно OR?
Мы уже использовали Q в задаче из урока 9 — теперь разберём подробно.
Q — это объект, представляющий условие SQL-запроса. Он нужен тогда, когда простого filter() недостаточно: для операций OR, NOT, сложной логики и динамической сборки запросов.
Django использует Q, потому что объекты безопаснее, переносимее между СУБД и позволяют программно конструировать SQL без написания самого SQL.
from django.db.models import Q
# Фильмы 1972 ИЛИ 1994 года
Film.objects.filter(Q(year=1972) | Q(year=1994))
# Фильмы с рейтингом выше 8 ИЛИ года после 2020
Film.objects.filter(Q(rating__gt=8.0) | Q(year__gt=2020))| Оператор | Значение | Пример |
|---|---|---|
& |
AND | Q(year__gt=2000) & Q(rating__gt=8) |
| |
OR | Q(year=1972) | Q(year=1994) |
~ |
NOT | ~Q(genres__name='Комедия') |
# AND через & — то же самое, что filter(year__gt=2000, rating__gt=8)
Film.objects.filter(Q(year__gt=2000) & Q(rating__gt=8))
# NOT — все фильмы, кроме комедий
Film.objects.filter(~Q(genres__name='Комедия'))
# Комбинация: (драма ИЛИ криминал) И рейтинг выше 8
Film.objects.filter(
(Q(genres__name='Драма') | Q(genres__name='Криминал')) & Q(rating__gt=8)
)Q-объекты можно комбинировать с обычными именованными аргументами в одном вызове — но Q всегда должен идти первым:
# Правильно: Q-объекты перед обычными аргументами
Film.objects.filter(Q(year=1972) | Q(year=1994), rating__gt=5)
# Неправильно: обычные аргументы перед Q — SyntaxError
Film.objects.filter(rating__gt=5, Q(year=1972) | Q(year=1994))В уроке 9 мы написали метод search() с Q. Расширим его — поиск теперь учитывает и режиссёра:
# films/models.py
class FilmManager(models.Manager):
def search(self, query):
"""Поиск по названию, описанию и имени режиссёра."""
return self.filter(
Q(title__icontains=query) |
Q(description__icontains=query) |
Q(director__name__icontains=query)
).distinct().distinct() добавлен на случай, если фильтрация через связанную таблицу director создаст дубликаты при JOIN.
F позволяет ссылаться на значение поля прямо в запросе — без загрузки объекта в Python. Это нужно в двух случаях: сравнение двух полей одной записи и атомарное обновление значения.
F() позволяет ссылаться на значение поля прямо внутри SQL-запроса. Чаще всего он используется для безопасного изменения счетчиков, остатков товаров, балансов и других данных, где новое значение зависит от текущего значения в базе.
Без F() такие операции подвержены состояниям гонки и требуют лишних запросов к базе данных.
Представим, что в модели Film есть поля rating (рейтинг критиков) и audience_rating (рейтинг зрителей):
from django.db.models import F
# Фильмы, где зрительский рейтинг выше критического
Film.objects.filter(audience_rating__gt=F('rating'))Без F пришлось бы загрузить все объекты в Python и сравнивать в цикле — намного медленнее и не масштабируется.
Это самое частое применение F на практике. Вспомним модель FilmStats из урока 10 со счётчиком просмотров:
# Опасно при конкурентном доступе (race condition)
stats = FilmStats.objects.get(film_id=1)
stats.views_count = stats.views_count + 1
stats.save()Проблема: между чтением значения и записью могут произойти другие обновления — реальные одновременные запросы от двух пользователей могут привести к потере одного из увеличений счётчика.
# Атомарно — вычисление происходит на стороне БД одним запросом
from django.db.models import F
FilmStats.objects.filter(film_id=1).update(views_count=F('views_count') + 1)Такой UPDATE выполняется как единая операция на уровне базы данных: UPDATE films_filmstats SET views_count = views_count + 1 WHERE film_id = 1. Конкурентные запросы не теряют инкременты.
Добавим инкремент просмотров в film_detail:
# films/views.py
from django.db.models import F
def film_detail(request, film_id):
film = get_object_or_404(
Film.objects.select_related('director').prefetch_related('genres', 'actors'),
id=film_id
)
# Атомарно увеличиваем счётчик просмотров, если статистика существует
FilmStats.objects.filter(film=film).update(views_count=F('views_count') + 1)
return render(request, 'films/film_detail.html', {'film': film})Агрегирующие функции вычисляют итоговое значение по набору записей: сумму, среднее, минимум, максимум, количество.
from django.db.models import Avg, Count, Max, Min, Sum# Средний рейтинг всех фильмов
Film.objects.aggregate(Avg('rating'))
# {'rating__avg': Decimal('8.13')}
# Несколько агрегатов сразу, с понятными именами
Film.objects.aggregate(
средний_рейтинг=Avg('rating'),
максимальный_рейтинг=Max('rating'),
минимальный_рейтинг=Min('rating'),
всего_фильмов=Count('id')
)
# {'средний_рейтинг': Decimal('8.13'), 'максимальный_рейтинг': Decimal('9.30'), ...}SQL-эквивалент:
-- Средний рейтинг
SELECT AVG(rating)
FROM films_film;-- Несколько агрегатов сразу
SELECT
AVG(rating) AS средний_рейтинг,
MAX(rating) AS максимальный_рейтинг,
MIN(rating) AS минимальный_рейтинг,
COUNT(id) AS всего_фильмов
FROM films_film;aggregate() возвращает словарь с одним набором значений по всему QuerySet — не привязан к отдельным объектам.
annotate() решает другую задачу: добавляет вычисленное значение к каждому объекту в QuerySet. Это аналог GROUP BY с агрегатом в SQL.
from django.db.models import Count
# Количество фильмов у каждого режиссёра
directors = Director.objects.annotate(film_count=Count('films'))
for director in directors:
print(f'{director.name}: {director.film_count} фильмов')SQL-эквивалент:
SELECT director.*, COUNT(film.id) as film_count
FROM films_director director
LEFT JOIN films_film film ON film.director_id = director.id
GROUP BY director.idannotate() буквально и есть GROUP BY — Django группирует по полю, к которому применяется метод (Director), и считает агрегат по связанной таблице.
Результат annotate() можно использовать в order_by() и filter():
# Режиссёры, отсортированные по количеству фильмов (больше — выше)
directors = Director.objects.annotate(film_count=Count('films')).order_by('-film_count')SQL-эквивалент:
SELECT director.*, COUNT(film.id) as film_count
FROM films_director director
LEFT JOIN films_film film ON film.director_id = director.id
GROUP BY director.id
ORDER BY film_count DESC;# Только режиссёры с более чем 2 фильмами
prolific_directors = Director.objects.annotate(
film_count=Count('films')
).filter(film_count__gt=2)SQL-эквивалент:
SELECT director.*, COUNT(film.id) as film_count
FROM films_director director
LEFT JOIN films_film film ON film.director_id = director.id
GROUP BY director.id
HAVING COUNT(film.id) > 2;Здесь появляется важный SQL-оператор HAVING, который работает с агрегатными функциями после группировки.
# Жанры с количеством фильмов и средним рейтингом фильмов в этом жанре
genres = Genre.objects.annotate(
film_count=Count('films'),
avg_rating=Avg('films__rating')
).order_by('-film_count')
for genre in genres:
print(f'{genre.name}: {genre.film_count} фильмов, средний рейтинг {genre.avg_rating}')SQL-эквивалент:
SELECT
genre.*,
COUNT(film.id) AS film_count,
AVG(film.rating) AS avg_rating
FROM films_genre genre
LEFT JOIN films_film_genres fg
ON fg.genre_id = genre.id
LEFT JOIN films_film film
ON film.id = fg.film_id
GROUP BY genre.id
ORDER BY film_count DESC;Обрати внимание: Django автоматически строит JOIN через промежуточную таблицу ManyToMany.
Value оборачивает обычное Python-значение, чтобы использовать его в выражении ORM наравне с полями модели. Применяется реже, чем Q и F, но полезен при построении вычисляемых полей:
from django.db.models import Value
from django.db.models.functions import Concat
# Объединить название и год в одну строку через ORM, а не в Python
films = Film.objects.annotate(
full_title=Concat('title', Value(' ('), 'year', Value(')'))
)SQL-эквивалент:
SELECT
*,
CONCAT(title, ' (', year, ')') AS full_title
FROM films_film;Пример результата:
Список Шиндлера (1993)
Крёстный отец (1972)
Побег из Шоушенка (1994)
Это нужно, когда вычисление должно остаться внутри SQL-запроса — например, для последующей фильтрации или сортировки по результату.
Если нужна группировка не по объектам модели, а по произвольному полю — используем values() перед annotate(). Это меняет поведение annotate(): вместо агрегата для каждого объекта он сгруппирует записи по указанным полям.
# Количество фильмов по годам
Film.objects.values('year').annotate(count=Count('id')).order_by('-year')
# [{'year': 1994, 'count': 1}, {'year': 1993, 'count': 1}, {'year': 1972, 'count': 1}]SQL-эквивалент:
SELECT
year,
COUNT(id) AS count
FROM films_film
GROUP BY year
ORDER BY year DESC;Здесь values('year') фактически становится аналогом SQL-конструкции:
GROUP BY yearБез values() перед annotate() группировка происходила бы по первичному ключу модели — то есть фактически её бы не было.
# Средний рейтинг по годам
Film.objects.values('year').annotate(avg_rating=Avg('rating')).order_by('-avg_rating')SQL-эквивалент:
SELECT
year,
AVG(rating) AS avg_rating
FROM films_film
GROUP BY year
ORDER BY avg_rating DESC;Результат:
[
{'year': 1994, 'avg_rating': 9.3},
{'year': 1972, 'avg_rating': 9.2},
{'year': 1993, 'avg_rating': 8.9},
]Важное отличие.
values()возвращает не объекты модели, а словари. Это значит — послеvalues()нельзя обратиться к методам модели илиrelated_name, только к данным, которые попали в словарь.
Сводим три похожих, но разных подхода:
| Метод | Что возвращает | Когда использовать |
|---|---|---|
aggregate() |
Один словарь со значением по всему QuerySet | Нужна общая статистика: средний рейтинг всех фильмов |
annotate() |
QuerySet объектов с добавленным полем | Нужно значение для каждого объекта: количество фильмов у каждого режиссёра |
values().annotate() |
QuerySet словарей, сгруппированных по указанному полю | Нужна группировка по произвольному критерию: количество фильмов по годам |
Соберём всё изученное в одном представлении — простая страница со сводной статистикой каталога:
# films/views.py
from django.db.models import Avg, Count, Max, Min
def catalog_stats(request):
overall_stats = Film.objects.aggregate(
total=Count('id'),
avg_rating=Avg('rating'),
max_rating=Max('rating'),
min_rating=Min('rating'),
)
top_directors = Director.objects.annotate(
film_count=Count('films')
).filter(film_count__gt=0).order_by('-film_count')[:5]
films_by_year = Film.objects.values('year').annotate(
count=Count('id')
).order_by('-year')
context = {
'overall_stats': overall_stats,
'top_directors': top_directors,
'films_by_year': films_by_year,
}
return render(request, 'films/catalog_stats.html', context)<!-- films/templates/films/catalog_stats.html -->
{% extends 'base.html' %}
{% block title %}Статистика каталога{% endblock %}
{% block content %}
<h1>Статистика каталога</h1>
<h2>Общая информация</h2>
<p>Всего фильмов: {{ overall_stats.total }}</p>
<p>Средний рейтинг: {{ overall_stats.avg_rating|floatformat:1 }}</p>
<p>Максимальный рейтинг: {{ overall_stats.max_rating }}</p>
<h2>Самые продуктивные режиссёры</h2>
<ul>
{% for director in top_directors %}
<li>{{ director.name }} — {{ director.film_count }} фильмов</li>
{% endfor %}
</ul>
<h2>Фильмы по годам</h2>
<ul>
{% for item in films_by_year %}
<li>{{ item.year }}: {{ item.count }} фильмов</li>
{% endfor %}
</ul>
{% endblock %}Не забываем добавить маршрут:
# films/urls.py
path('stats/', views.catalog_stats, name='catalog_stats'),Порядок filter() и annotate() имеет значение. Если фильтровать перед annotate(), агрегат считается только по отфильтрованным записям; если после — фильтр применяется к уже посчитанному значению:
# Среднее по ВСЕМ фильмам режиссёра, потом фильтр по результату
Director.objects.annotate(avg_rating=Avg('films__rating')).filter(avg_rating__gt=8)
# Среднее ТОЛЬКО по фильмам после 2000 года (отдельная фильтрация связанных объектов)
Director.objects.filter(films__year__gt=2000).annotate(avg_rating=Avg('films__rating'))Это частый источник багов — порядок вызовов даёт разный результат при, казалось бы, похожем синтаксисе.
Если применить Count() к двум разным M2M-связям одновременно, JOIN-комбинаторика умножает строки, и числа окажутся завышенными:
# Потенциально неверный результат — JOIN по genres и actors комбинируются
Film.objects.annotate(
genre_count=Count('genres'),
actor_count=Count('actors')
)Решение — distinct=True внутри Count():
Film.objects.annotate(
genre_count=Count('genres', distinct=True),
actor_count=Count('actors', distinct=True)
)F() существует только внутри ORM-запросов. Нельзя использовать его как обычное Python-значение:
# TypeError — F() не вычисляется в Python
new_count = F('views_count') + 1
print(new_count)
# Работает только внутри update() или filter()
FilmStats.objects.filter(film_id=1).update(views_count=F('views_count') + 1)- В чём разница между
Qи обычными именованными аргументами вfilter()? - Зачем использовать
F()при обновлении счётчика вместо обычного increment в Python? - Чем
aggregate()отличается отannotate()? - Зачем перед
annotate()иногда вызываютvalues()? Что меняется в поведении группировки? - Почему
Count()на двух разных ManyToMany-связях одновременно может дать неверный результат?
Тип: допиши код
Дано представление, которое должно показывать пять режиссёров с самым высоким средним рейтингом их фильмов — но только тех, у кого есть хотя бы 2 фильма в каталоге. Допиши пропущенные части.
from django.db.models import Avg, Count
def top_directors(request):
directors = Director.objects.annotate(
avg_rating=___________________,
film_count=___________________
).filter(
___________________
).order_by(
___________________
)[:5]
context = {'directors': directors}
return render(request, 'films/top_directors.html', context)Шаблон уже готов и ожидает у каждого director атрибуты name, avg_rating, film_count.