Сортировка, срезы, пагинация | Курс Django ORM урок 2.4
Цель урока
Разобрать order_by(), reverse(), distinct() и срезы QuerySet. Понять, во что они транслируются в SQL. Разобрать Django Paginator и объяснить, почему пагинация OFFSET деградирует на больших таблицах и что с этим делать.
Необходимые знания
order_by()
# По возрастанию
Product.objects.order_by("price")
# По убыванию (минус = DESC)
Product.objects.order_by("-price")
# По нескольким полям
Product.objects.order_by("category", "-price")
# Сброс Meta.ordering
Product.objects.order_by()
-- order_by("price")
SELECT * FROM shop_product ORDER BY price ASC;
-- order_by("-price")
SELECT * FROM shop_product ORDER BY price DESC;
-- order_by("category", "-price")
SELECT * FROM shop_product ORDER BY category_id ASC, price DESC;
-- order_by() без сортировки
SELECT * FROM shop_product;
order_by() с пустым списком аргументов сбрасывает Meta.ordering. Это важно когда нужен QuerySet без гарантированного порядка, например, для агрегаций, где сортировка добавляет лишний GROUP BY.
Сортировка через связанные модели
# Сортировка по полю связанной модели
Product.objects.order_by("category__name", "name")
SELECT shop_product.*
FROM shop_product
INNER JOIN shop_category ON shop_product.category_id = shop_category.id
ORDER BY shop_category.name ASC, shop_product.name ASC;
Django автоматически добавляет JOIN. Это удобно, но нужно понимать, JOIN для сортировки происходит при каждом запросе, даже если данные категории не нужны.
Сортировка по аннотациям
from django.db.models import Avg
# Сортировка по вычисляемому значению
Product.objects.annotate(avg_rating=Avg("reviews__rating")).order_by("-avg_rating")
SELECT shop_product.*, AVG(shop_review.rating) AS avg_rating
FROM shop_product
LEFT OUTER JOIN shop_review ON shop_product.id = shop_review.product_id
GROUP BY shop_product.id
ORDER BY avg_rating DESC;
Аннотации в order_by() подробно разберем в уроке 3.3.
nulls_first и nulls_last
В PostgreSQL значения NULL по умолчанию идут последними при ASC и первыми при DESC. Это можно контролировать:
from django.db.models import F
# NULL значения в конце при сортировке по убыванию
Product.objects.order_by(F("discount_price").desc(nulls_last=True))
# NULL значения в начале при сортировке по возрастанию
Product.objects.order_by(F("discount_price").asc(nulls_first=True))
ORDER BY discount_price DESC NULLS LAST
ORDER BY discount_price ASC NULLS FIRST
reverse()
# Разворачивает текущий порядок сортировки
Product.objects.order_by("price").reverse()
# эквивалентно order_by("-price")
reverse() работает только если QuerySet уже имеет ordering, из Meta или явный order_by(). Без определенного порядка reverse() ничего не делает.
Практически полезен для last(), Django внутри использует reverse().first():
Product.objects.filter(is_active=True).last()
# = Product.objects.filter(is_active=True).reverse().first()
distinct()
Убирает дублирующиеся строки в результате:
# Дублирующиеся категории из-за JOIN с products
Category.objects.filter(products__is_active=True).distinct()
SELECT DISTINCT shop_category.*
FROM shop_category
INNER JOIN shop_product ON shop_category.id = shop_product.category_id
WHERE shop_product.is_active = true
ORDER BY shop_category.name;
DISTINCT в SQL сравнивает все колонки в SELECT. В PostgreSQL есть DISTINCT ON (field), возвращает первую строку для каждого уникального значения поля. Django поддерживает это через distinct("field"):
# Первый продукт каждой категории (по Meta.ordering)
Product.objects.order_by("category_id", "name").distinct("category_id")
SELECT DISTINCT ON (category_id) shop_product.*
FROM shop_product
ORDER BY category_id, name;
distinct("field") работает только в PostgreSQL и требует чтобы первый order_by() совпадал с полем в distinct().
distinct() и order_by() через связи
Проблема: если в order_by() есть поле из таблицы JOIN, DISTINCT может не убрать дубли корректно, потому что технически строки различаются по полю из JOIN.
# Потенциально не работает как ожидается
Category.objects.filter(products__is_active=True).distinct().order_by("products__price")
В таком случае лучше переформулировать запрос или использовать values() + агрегацию.
Срезы QuerySet
QuerySet поддерживает срезы в стиле Python, которые транслируются в LIMIT и OFFSET:
# Первые 10 продуктов
Product.objects.all()[:10]
# Продукты с 10 по 20 (offset=10, limit=10)
Product.objects.all()[10:20]
# Один объект по индексу (эквивалент offset=5, limit=1)
Product.objects.all()[5]
-- [:10]
SELECT * FROM shop_product ORDER BY name LIMIT 10;
-- [10:20]
SELECT * FROM shop_product ORDER BY name LIMIT 10 OFFSET 10;
-- [5]
SELECT * FROM shop_product ORDER BY name LIMIT 1 OFFSET 5;
Ограничения срезов
После среза QuerySet "замораживается", нельзя добавить фильтр:
qs = Product.objects.all()[:10]
qs.filter(is_active=True) # TypeError: Cannot filter a query once a slice has been taken
Всегда фильтруйте до среза:
Product.objects.filter(is_active=True)[:10]
Отрицательные индексы не поддерживаются:
Product.objects.all()[-1] # AssertionError
# Вместо этого используйте last()
Product.objects.last()
Пагинация с помощью Paginator
Django предоставляет Paginator, стандартный инструмент разбивки QuerySet на страницы:
from django.core.paginator import Paginator
products = Product.objects.filter(is_active=True).order_by("-created_at")
paginator = Paginator(products, per_page=20) # 20 объектов на странице
# Получить конкретную страницу
page = paginator.page(1)
print(paginator.num_pages) # общее количество страниц
print(paginator.count) # общее количество объектов
print(page.object_list) # объекты текущей страницы
print(page.has_next()) # есть ли следующая страница
print(page.has_previous()) # есть ли предыдущая страница
print(page.next_page_number()) # номер следующей страницы
SQL при paginator.count:
SELECT COUNT(*) FROM shop_product WHERE is_active = true;
SQL при page.object_list (страница 1, per_page=20):
SELECT * FROM shop_product WHERE is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 0;
SQL для страницы 5:
SELECT * FROM shop_product WHERE is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 80;
Проблема OFFSET на больших таблицах
OFFSET не пропускает строки, PostgreSQL читает все строки с начала и отбрасывает первые N, только потом возвращает следующие M. Страница 1000 при per_page=20 означает OFFSET 19 980: база прочитает 20 000 строк и вернет только 20.
Время выполнения растет линейно с номером страницы:
Страница 1: OFFSET 0 - быстро
Страница 100: OFFSET 1980 - заметно медленнее
Страница 1000: OFFSET 19980 - медленно
Страница 10000: OFFSET 199980 - очень медленно
Для интернет-магазина с сотнями товаров это не проблема. Но если страниц тысячи, нужен другой подход.
Cursor-based пагинация
Вместо OFFSET используйте значение поля последней записи предыдущей страницы:
# Обычная пагинация через OFFSET (деградирует)
def get_page_offset(page_number, per_page=20):
offset = (page_number - 1) * per_page
return Product.objects.filter(is_active=True).order_by("id")[offset:offset + per_page]
# Cursor-based не деградирует
def get_page_cursor(last_id=None, per_page=20):
qs = Product.objects.filter(is_active=True).order_by("id")
if last_id:
qs = qs.filter(id__gt=last_id)
return qs[:per_page]
-- Cursor-based: всегда быстро, использует индекс на id
SELECT * FROM shop_product
WHERE is_active = true AND id > 12340
ORDER BY id
LIMIT 20;
Ограничения cursor-based пагинации: нельзя перейти на произвольную страницу (только "следующая"), курсор должен быть по уникальному полю (id, created_at с уникальностью). Поэтому cursor-based подходит для бесконечной прокрутки в API, но не для классической пагинации с номерами страниц.
Производительность сортировки
Сортировка без индекса
-- Если нет индекса на price, PostgreSQL делает filesort
SELECT * FROM shop_product ORDER BY price DESC LIMIT 20;
-- EXPLAIN: Sort (cost=1500..1550 rows=100)
Сортировка с индексом
-- С индексом на price, Index Scan в обратном порядке
-- CREATE INDEX ON shop_product (price DESC);
SELECT * FROM shop_product ORDER BY price DESC LIMIT 20;
-- EXPLAIN: Index Scan Backward (cost=0.4..50 rows=20)
Если вы часто сортируете по полю и берёте только первые N записей (LIMIT), индекс на это поле ускоряет запрос. Подробно об индексах и EXPLAIN, в уроках 5.1 и 5.2.
Практическое задание
-
Получите 5 самых дорогих активных продуктов. Посмотрите SQL.
-
Получите продукты на "второй странице" (с 11 по 20) отсортированные по имени. Какой OFFSET в SQL?
-
Используйте
Paginatorдля разбивки всех активных продуктов по 5 штук. Выведите информацию о странице 2: объекты, есть ли следующая, номер следующей страницы. -
Реализуйте пагинацию на основе курсора: получите первые 5 продуктов по
id, затем вызовите функцию снова передавlast_idпоследнего продукта. Сравните SQL с вариантом с OFFSET. -
Найдите все уникальные категории, у которых есть хотя бы один активный продукт. Добавьте
order_by("name")иdistinct(). Посмотрите SQL.
Возможные ошибки
Сортировка без индекса в большой таблице
# Медленно если нет индекса на created_at
Order.objects.order_by("-created_at")
# Добавьте индекс в Meta
class Meta:
indexes = [models.Index(fields=["-created_at"])]
OFFSET пагинация для API с большими данными
# Деградирует на больших страницах
Product.objects.all()[10000:10020]
# Для API cursor-based
Product.objects.filter(id__gt=last_id).order_by("id")[:20]
Фильтрация после среза
# TypeError
products = Product.objects.all()[:10]
products.filter(is_active=True) # ошибка
# Правильно фильтр до среза
products = Product.objects.filter(is_active=True)[:10]
Не сбрасывать Meta.ordering перед агрегацией
# Meta.ordering = ["name"] добавляет GROUP BY name, лишний столбец в GROUP BY
Product.objects.values("category_id").annotate(count=Count("id"))
# Правильно сбросить ordering
Product.objects.values("category_id").annotate(count=Count("id")).order_by()
Этот паттерн разберем подробно в уроке 3.3.
Связь со следующим уроком
В уроке 2.5 завершаем блок базовых операций: обновление и удаление данных. Разберем чем save() отличается от update() на уровне QuerySet, как работает delete() на QuerySet и в чем разница между каскадным удалением в Django и в PostgreSQL. Также введем bulk_create() и bulk_update(), инструменты для работы с большими объемами данных.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru