Custom managers и QuerySets | Курс Django ORM урок 8
Цель урока
Научиться выносить бизнес-логику запросов в кастомные менеджеры и классы QuerySet. Разобрать разницу между Manager и QuerySet, паттерн as_manager(), реализацию soft delete и цепочку кастомных методов.
Необходимые знания
- Урок 1.3: Meta и менеджеры, базовое понимание Manager
- Урок 2.2: QuerySet API
- Урок 2.3: lookups, filter
Проблема: логика запросов размазана по коду
Часто в проектах, одни и те же условия фильтрации дублируются в разных местах:
# views.py
products = Product.objects.filter(is_active=True, stock__gt=0)
# api/views.py
products = Product.objects.filter(is_active=True, stock__gt=0).select_related("category")
# management/commands/send_digest.py
products = Product.objects.filter(is_active=True, stock__gt=0).order_by("-created_at")[:10]
Если бизнес-логика "активный продукт в наличии" изменится (например, добавится проверка price__gt=0), нужно обновить все эти места. Custom managers решают это централизовано.
Manager vs QuerySet - разница
Manager: это интерфейс через который Django предоставляет QuerySet. Product.objects, это менеджер. Менеджер дает первый QuerySet.
QuerySet: это запрос, который можно фильтровать, аннотировать, чейнить. После Product.objects.all(), работает QuerySet.
Ключевое отличие: методы Manager доступны только как первый вызов Product.objects.method(), методы QuerySet, в цепочке Product.objects.all().method().another_method().
Custom Manager
class ProductManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
def in_stock(self):
return self.get_queryset().filter(stock__gt=0)
def featured(self):
return self.get_queryset().filter(is_featured=True).order_by("-created_at")
class Product(models.Model):
name = models.CharField(max_length=200)
is_active = models.BooleanField(default=True)
stock = models.IntegerField(default=0)
is_featured = models.BooleanField(default=False)
objects = ProductManager() # заменяем стандартный менеджер
# Теперь:
Product.objects.all() # только активные (переопределен get_queryset)
Product.objects.in_stock() # активные и в наличии
Product.objects.featured() # активные избранные
-- Product.objects.all()
SELECT * FROM shop_product WHERE is_active = true ORDER BY name;
-- Product.objects.in_stock()
SELECT * FROM shop_product WHERE is_active = true AND stock > 0 ORDER BY name;
Несколько менеджеров
Часто нужен доступ ко всем объектам (например, в Django Admin):
class Product(models.Model):
objects = ProductManager() # только активные (дефолтный)
all_objects = models.Manager() # все, включая неактивные
Product.objects.all() # только активные
Product.all_objects.all() # все продукты
Django Admin использует default_manager модели. Если первый менеджер фильтрует объекты, Admin покажет только их. Явно укажите для Admin через Meta.default_manager_name или используйте all_objects как второй менеджер.
Custom QuerySet
Чтобы методы были доступны в цепочке, определите их в QuerySet:
class ProductQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)
def in_stock(self):
return self.filter(stock__gt=0)
def with_reviews(self):
return self.annotate(review_count=Count("reviews"))
def expensive(self, threshold=1000):
return self.filter(price__gte=threshold)
def for_category(self, category_slug):
return self.filter(category__slug=category_slug)
as_manager() - QuerySet как Manager
Метод as_manager() создает Manager из QuerySet. Это рекомендованный способ:
class Product(models.Model):
name = models.CharField(max_length=200)
is_active = models.BooleanField(default=True)
stock = models.IntegerField(default=0)
price = models.DecimalField(max_digits=10, decimal_places=2)
objects = ProductQuerySet.as_manager()
# Методы QuerySet доступны через менеджер
Product.objects.active()
Product.objects.in_stock()
# И в цепочке
Product.objects.active().in_stock().expensive(500)
Product.objects.for_category("phones").active().with_reviews().order_by("-review_count")
-- Product.objects.for_category("phones").active().in_stock()
SELECT shop_product.*
FROM shop_product
INNER JOIN shop_category ON shop_product.category_id = shop_category.id
WHERE shop_category.slug = 'phones'
AND shop_product.is_active = true
AND shop_product.stock > 0
ORDER BY shop_product.name;
Паттерн: Manager + QuerySet вместе
Иногда нужны и кастомный get_queryset() (для глобальной фильтрации) и методы:
class ProductQuerySet(models.QuerySet):
def in_stock(self):
return self.filter(stock__gt=0)
def with_stats(self):
return self.annotate(
review_count=Count("reviews"),
avg_rating=Avg("reviews__rating"),
)
class ProductManager(models.Manager):
def get_queryset(self):
return ProductQuerySet(self.model, using=self._db).filter(is_active=True)
def in_stock(self):
return self.get_queryset().in_stock()
def with_stats(self):
return self.get_queryset().with_stats()
class Product(models.Model):
objects = ProductManager()
all_objects = models.Manager() # без фильтрации
# Менеджер + цепочка
Product.objects.in_stock().with_stats().order_by("-avg_rating")
Soft Delete - удаление без удаления
Soft delete, паттерн при котором объекты не удаляются из БД, а помечаются как удаленные:
from django.db import models
from django.db.models import QuerySet
from django.utils import timezone
class SoftDeleteQuerySet(QuerySet):
def delete(self):
"""Мягкое удаление, пометить как удаленные."""
return self.update(
deleted_at=timezone.now(),
is_deleted=True,
)
def hard_delete(self):
"""Настоящее удаление из БД."""
return super().delete()
def alive(self):
return self.filter(is_deleted=False)
def dead(self):
return self.filter(is_deleted=True)
class SoftDeleteManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
class DeletedObjectsManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=True)
class SoftDeleteModel(models.Model):
"""Абстрактная модель с soft delete."""
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
objects = SoftDeleteManager() # только не удаленные
all_objects = models.Manager() # все, включая удаленные
deleted_objects = DeletedObjectsManager() # только удаленные
class Meta:
abstract = True
def delete(self, using=None, keep_parents=False):
"""Мягкое удаление объекта.
using это алиас БД для проектов с несколькими базами данных.
keep_parents нужен для Multi-table inheritance: если True, строка
родительской таблицы не удаляется. Принимаем для совместимости
с сигнатурой Model.delete(), но не используем при soft delete.
"""
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=["is_deleted", "deleted_at"])
def hard_delete(self, using=None, keep_parents=False):
"""Настоящее удаление. Параметры пробрасываются в super().delete()."""
super().delete(using=using, keep_parents=keep_parents)
def restore(self):
"""Восстановить удаленный объект."""
self.is_deleted = False
self.deleted_at = None
self.save(update_fields=["is_deleted", "deleted_at"])
class Product(SoftDeleteModel):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
# ... остальные поля
# Использование
product = Product.objects.get(id=1)
product.delete() # мягкое удаление (is_deleted=True)
product.hard_delete() # настоящее удаление
Product.objects.all() # только не удаленные
Product.all_objects.all() # все
# QuerySet soft delete
Product.objects.filter(price__lt=10).delete() # мягкое удаление группы
# Восстановить
product.restore() # is_deleted=False
-- product.delete()
UPDATE shop_product
SET is_deleted = true, deleted_at = NOW()
WHERE id = 1;
-- Product.objects.all()
SELECT * FROM shop_product
WHERE is_deleted = false
ORDER BY name;
Проблема: related objects при soft delete
select_related и prefetch_related не знают о soft delete, они возвращают все связанные объекты включая удаленные:
# Вернет Order у которого продукт помечен как удаленный
order = Order.objects.prefetch_related("items__product").first()
for item in order.items.all():
print(item.product) # "удаленный" продукт
Решение через limit_choices_to в ForeignKey или явная фильтрация:
# Явная фильтрация в запросе
Order.objects.prefetch_related(
Prefetch(
"items",
queryset=OrderItem.objects.select_related("product").filter(product__is_active=True)
)
)
Пример: QuerySet для модели Order
Посмотрим как выглядит QuerySet для реальной бизнес-модели. Order имеет статус, пользователя, дату создания и позиции с ценой и количеством. Все запросы к заказам выносим в методы QuerySet:
class OrderQuerySet(models.QuerySet):
def pending(self):
return self.filter(status="pending")
def delivered(self):
return self.filter(status="delivered")
def recent(self, days=30):
from django.utils import timezone
from datetime import timedelta
cutoff = timezone.now() - timedelta(days=days)
return self.filter(created_at__gte=cutoff)
def for_user(self, user):
return self.filter(user=user)
def with_total(self):
from django.db.models import Sum, F as F_expr, ExpressionWrapper, DecimalField
return self.annotate(
computed_total=Sum(
ExpressionWrapper(
F_expr("items__price") * F_expr("items__quantity"),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)
)
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders")
status = models.CharField(max_length=20)
total_price = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
objects = OrderQuerySet.as_manager()
# Использование:
Order.objects.for_user(user).recent(7).pending()
Order.objects.delivered().with_total().order_by("-computed_total")[:10]
Практическое задание
1) Создайте ProductQuerySet с методами: active(), in_stock(), by_category(slug), price_range(min, max), with_review_stats() (аннотация count и avg). Подключите через as_manager().
2) Реализуйте SoftDeleteModel как абстрактную модель. Примените к Review, при review.delete(), помечать как удаленный, не удалять из БД. Проверьте что Review.objects.all() не возвращает удаленные.
3) Добавьте менеджер all_objects = models.Manager() к Product. Проверьте что Django Admin использует objects (только активные) или настройте через Meta.default_manager_name.
4) Реализуйте OrderQuerySet с методами for_user(), recent(), with_total(). Напишите запрос: заказы пользователя за последние 7 дней с вычисленной суммой, отсортированные по сумме.
5) Напишите тест который проверяет:
Product.objects.active()не возвращает неактивные продукты- Soft delete: после
product.delete()объект недоступен черезobjects, но доступен черезall_objects restore()возвращает объект вobjects
Возможные ошибки
Manager вместо цепочки методов QuerySet
# Метод в Manager не работает в цепочке
class ProductManager(models.Manager):
def active(self):
return self.filter(is_active=True)
Product.objects.active().in_stock() # AttributeError, in_stock не определен
# active() вернул QuerySet без метода in_stock
Методы, которые нужно вызывать в цепочке, определяйте в QuerySet, не в Manager.
Переопределение get_queryset без all_objects
class ProductManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
# Нет доступа к неактивным продуктам!
# Django Admin покажет только активные
# Миграции и другие утилиты Django могут работать некорректно
Всегда добавляйте all_objects = models.Manager() как запасной менеджер.
Soft delete без переопределения QuerySet.delete()
class SoftDeleteModel(models.Model):
def delete(self):
self.is_deleted = True
self.save()
# Но QuerySet.delete() обходит Model.delete()!
Product.objects.filter(price__lt=10).delete() # настоящее удаление
При soft delete обязательно переопределять и Model.delete() и QuerySet.delete().
as_manager() теряет кастомный get_queryset
class ProductQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)
objects = ProductQuerySet.as_manager()
# as_manager() создает Manager с обычным get_queryset()
# Нет автоматической фильтрации, только явные методы
Если нужен кастомный get_queryset(), используйте Manager + QuerySet, не только as_manager().
Связь со следующим уроком
В уроке 9.1 разберем наследование моделей: Abstract, Multi-table и Proxy. Когда каждый тип применять, какой SQL генерируется и почему выбор типа наследования влияет на производительность запросов.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru