Шаг 06. ORM правильно: N+1 закрыт

📁 Серия: Капстоун B ⏱️ ~45 мин 🎯 Сложность: Средняя
#select_related #prefetch_related #annotate #n+1 #Q #F

⚡ Кратко: что делаем на этом шаге

Цель: Отработать QuerySet API в Django shell и написать view с оптимизированными запросами. Закрыть N+1 через select_related/prefetch_related.

  • Q-объекты: Q(status="a") | Q(status="r")
  • F-объекты: атомарное обновление на уровне БД без загрузки в Python
  • annotate/aggregate: подсчёт, средние значения без дополнительных запросов
  • select_related: JOIN для FK/O2O — устраняет N+1 при доступе к book.publisher
  • prefetch_related: отдельный SELECT + Python-join для M2M/обратных FK — устраняет N+1 при book.authors.all()

🎯 Цель этапа

На этом шаге мы закрываем callout-verify из урока 22 — самое распространённое узкое место Django-приложений: N+1 проблема. Разбираем весь QuerySet API на примерах нашего каталога, пишем view-функцию с правильными запросами.

💡 Что такое N+1: Если у вас 100 книг и вы делаете for book in Book.objects.all(): print(book.publisher.name) — Django выполняет 1 запрос за книги + 100 запросов за каждое издательство = 101 запрос. С select_related("publisher") — это 1 запрос с JOIN. Разница на 10 000 книгах — между 10 001 запросом и 1 запросом.

После этого шага у нас будет

  • Опыт работы с filter/exclude/Q/F в Django shell
  • Понимание разницы select_related vs prefetch_related
  • View-функция со списком книг без N+1
  • Аннотации (количество экземпляров, доступных) в запросах

📄 Затрагиваемые файлы

ФайлДействиеОписание
catalog/views.pyЗаполнитьView-функции со списком книг и деталями
catalog/urls.pyСоздатьURL-маршруты приложения
config/urls.pyИзменитьПодключить catalog.urls
catalog/templates/catalog/book_list.htmlСоздатьПростой шаблон списка книг

🔨 Шаги

1. Django shell — изучаем QuerySet API

💻 Терминал
python manage.py shell
📄 Django shell — filter, exclude, Q, F
from catalog.models import Author, Book, BookCopy, Genre
from django.db.models import Q, F, Count, Avg

# ── filter и exclude ──────────────────────────────────────────────────────────

# Книги, изданные после 2000 года
books_2000 = Book.objects.filter(publish_year__gte=2000)
print(books_2000.count())

# Книги НЕ относящиеся к жанру "Классика"
non_classic = Book.objects.exclude(genres__name="Классика")

# Цепочка фильтров
available_copies = BookCopy.objects.filter(
    status=BookCopy.Status.AVAILABLE
).exclude(book__publish_year__lt=1900)

# ── Q-объекты: OR, NOT ────────────────────────────────────────────────────────

# Экземпляры, которые доступны ИЛИ зарезервированы
ready_copies = BookCopy.objects.filter(
    Q(status=BookCopy.Status.AVAILABLE) | Q(status=BookCopy.Status.RESERVED)
)

# Книги Толстого ИЛИ изданные после 2000
tolstoy_or_recent = Book.objects.filter(
    Q(authors__last_name="Толстой") | Q(publish_year__gte=2000)
).distinct()  # distinct() нужен при join-ах, чтобы избежать дублей

# ── F-объекты: поля в выражениях ──────────────────────────────────────────────

# F позволяет использовать значение поля в фильтре (сравнение полей друг с другом)
# Например: BookCopy у которых due_back совпадает с полем другой модели
# (искусственный пример, но паттерн важен)

# Атомарное обновление без загрузки в Python (важно!)
# Book.objects.filter(...).update(publish_year=F("publish_year") + 1)
# Это ОДИН SQL-запрос UPDATE, без race condition

# ── annotate и aggregate ─────────────────────────────────────────────────────

# Количество экземпляров для каждой книги
books_with_copies = Book.objects.annotate(
    total_copies=Count("copies"),
    available_copies=Count(
        "copies",
        filter=Q(copies__status=BookCopy.Status.AVAILABLE)
    )
)
for book in books_with_copies:
    print(f"{book.title}: {book.available_copies}/{book.total_copies} доступно")

# Агрегация по всему queryset
stats = BookCopy.objects.aggregate(
    total=Count("id"),
    available=Count("id", filter=Q(status=BookCopy.Status.AVAILABLE)),
)
print(stats)  # {"total": 15, "available": 8}

# Среднее количество книг на автора
avg_books = Author.objects.annotate(
    books_count=Count("books")
).aggregate(avg=Avg("books_count"))
print(avg_books)

2. N+1: проблема и решение

📄 Django shell — N+1 демонстрация
# ── N+1 ПРОБЛЕМА (НЕ делайте так) ───────────────────────────────────────────

# Этот код выполняет 1 + N + M запросов:
# 1 — SELECT * FROM catalog_book
# N — SELECT * FROM catalog_publisher WHERE id=X (для каждой книги)
# M — SELECT ... FROM catalog_author JOIN ... WHERE book_id=Y (для каждой книги)
books = Book.objects.all()
for book in books:
    # Каждое обращение — отдельный SQL!
    print(book.publisher.name)          # N лишних запросов
    print([str(a) for a in book.authors.all()])  # M лишних запросов

# ── select_related — решение для FK/O2O (JOIN) ───────────────────────────────

# select_related добавляет JOIN к исходному SELECT.
# Один SQL вместо 1+N.
# Используйте для ForeignKey и OneToOneField.
books = Book.objects.select_related("publisher").all()
for book in books:
    print(book.publisher.name)  # Нет доп. запросов! Данные уже загружены.

# ── prefetch_related — решение для M2M и обратных FK ─────────────────────────

# prefetch_related делает ОТДЕЛЬНЫЙ SELECT для связанной таблицы
# и объединяет данные в Python.
# Используйте для ManyToMany и обратных ForeignKey (copies, и т.п.)
books = Book.objects.prefetch_related("authors", "genres", "copies").all()
for book in books:
    print([str(a) for a in book.authors.all()])  # Python-кэш, нет SQL!
    print([str(g) for g in book.genres.all()])   # Python-кэш, нет SQL!

# ── Комбинация: и FK, и M2M ──────────────────────────────────────────────────

# Правило: select_related для FK/O2O, prefetch_related для M2M и обратных FK
books = Book.objects.select_related(
    "publisher"                     # FK → JOIN
).prefetch_related(
    "authors",                      # M2M → отдельный SELECT
    "genres",                       # M2M → отдельный SELECT
    "copies",                       # обратный FK → отдельный SELECT
).annotate(
    total_copies=Count("copies"),
    available_copies=Count(
        "copies",
        filter=Q(copies__status=BookCopy.Status.AVAILABLE)
    )
).order_by("title")

# Итого: 4 запроса вместо 1 + N*3
# При 1000 книг разница: 3001 запрос → 4 запроса
💡 Правило select_related vs prefetch_related:
  • select_related — ForeignKey, OneToOneField (JOIN, один SQL)
  • prefetch_related — ManyToManyField, обратные FK (book.copies.all()) (отдельный SELECT + Python-merge)
  • Вложенные FK: select_related("publisher__country") — двойной JOIN
  • Prefetch с фильтром: Prefetch("copies", queryset=BookCopy.objects.filter(status="a"))

3. Пишем view с правильным queryset

📄 catalog/views.py
# catalog/views.py
from django.shortcuts import render, get_object_or_404
from django.db.models import Count, Q

from .models import Book, BookCopy


def book_list(request):
    """
    Список книг с правильным queryset — без N+1.
    select_related для publisher (FK).
    prefetch_related для authors и genres (M2M).
    annotate для подсчёта экземпляров (без дополнительных запросов в шаблоне).
    """
    books = (
        Book.objects
        .select_related("publisher")
        .prefetch_related("authors", "genres")
        .annotate(
            total_copies=Count("copies"),
            available_copies=Count(
                "copies",
                filter=Q(copies__status=BookCopy.Status.AVAILABLE)
            ),
        )
        .order_by("title")
    )
    return render(request, "catalog/book_list.html", {"books": books})


def book_detail(request, pk: int):
    """
    Детальная страница книги с экземплярами.
    prefetch_related("copies") — загружаем все экземпляры одним запросом.
    """
    book = get_object_or_404(
        Book.objects.select_related("publisher").prefetch_related(
            "authors", "genres", "copies"
        ),
        pk=pk,
    )
    return render(request, "catalog/book_detail.html", {"book": book})

4. Подключаем URL-маршруты

📄 catalog/urls.py
# catalog/urls.py
from django.urls import path
from . import views

app_name = "catalog"

urlpatterns = [
    path("", views.book_list, name="book-list"),
    path("book//", views.book_detail, name="book-detail"),
]
📄 config/urls.py
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("catalog/", include("catalog.urls")),   # ← добавляем
]

5. Создаём простой шаблон

💻 Терминал
mkdir -p catalog\templates\catalog
📄 catalog/templates/catalog/book_list.html
<!-- catalog/templates/catalog/book_list.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <title>Каталог книг</title>
</head>
<body>
  <h1>Каталог книг ({{ books.count }} записей)</h1>
  <ul>
    {% for book in books %}
    <li>
      <strong>{{ book.title }}</strong>
      — {{ book.publisher.name|default:"Издательство не указано" }}
      | Авторы: {{ book.authors.all|join:", " }}
      | Доступно: {{ book.available_copies }}/{{ book.total_copies }}
    </li>
    {% empty %}
    <li>Книг в каталоге нет.</li>
    {% endfor %}
  </ul>
</body>
</html>
💡 Шаблон не делает запросов: Обращения book.publisher.name, book.authors.all, book.available_copies в шаблоне НЕ делают SQL-запросов, потому что данные уже загружены через select_related, prefetch_related и annotate в view. Это и есть правильная работа с Django ORM.

🧠 Объяснение логики

Как Django лениво выполняет запросы

QuerySet в Django — ленивый: Book.objects.filter(...) не выполняет SQL. Запрос происходит только при итерации (for book in books), слайсинге (books[0:10]), вызове list(), len(), bool() или repr(). Это позволяет строить сложные запросы цепочкой методов без промежуточных SQL.

Когда prefetch_related эффективнее select_related

select_related использует SQL JOIN — один большой запрос. При M2M или обратных FK это приводит к дублированию строк (cartesian product). prefetch_related делает второй SELECT и объединяет в Python — нет дублей, нет разрастания результата. При 100 книгах и 5 авторах у каждой: JOIN вернёт 500 строк (100×5), prefetch — 100 + 500 = два компактных результата.

⚠️ Частая ошибка: prefetch_related с дополнительным filter(): book.copies.filter(status="a") в шаблоне после prefetch_related("copies") СБРАСЫВАЕТ кэш и делает новый SQL-запрос! Решение: Prefetch("copies", queryset=BookCopy.objects.filter(status="a")) в prefetch_related() на уровне view.

✅ Проверка

1. Запускаем сервер и проверяем страницу

💻 Терминал
python manage.py runserver
💻 Браузер
http://127.0.0.1:8000/catalog/
Успех: Страница отображает список книг с авторами, издательствами и количеством доступных экземпляров. Сервер не падает с ошибками.

2. Проверяем количество SQL-запросов (опционально)

📄 Django shell
from django.test.utils import override_settings
from django.db import reset_queries, connection

# Включаем логирование запросов
import django.db
django.db.reset_queries()

from catalog.models import Book, BookCopy
from django.db.models import Count, Q

books = list(
    Book.objects.select_related("publisher")
    .prefetch_related("authors", "genres", "copies")
    .annotate(total_copies=Count("copies"))
)
print(f"Запросов: {len(django.db.connection.queries)}")
# Ожидаем: 4 (books + publishers JOIN, authors, genres, copies)

Диагностика

  • TemplateDoesNotExist: catalog/book_list.html — убедитесь, что папка catalog/templates/catalog/ создана и в settings.py APP_DIRS: True
  • NoReverseMatch — проверьте app_name = "catalog" в catalog/urls.py и include в config/urls.py
  • OperationalError: no such table — не применена миграция: python manage.py migrate

➡️ Что дальше

На последнем шаге мы завершаем проект: создаём фикстуры с тестовыми данными, пишем management-команду для импорта книг, проходим финальный чеклист и намечаем пути развития проекта.

  • Готово: QuerySet API, N+1 устранён, view с правильными запросами
  • Далее (шаг 07): dumpdata/loaddata, management-команды, финальный чеклист