Through models и Generic relations | Курс Django ORM урок 4.4
Цель урока
Разобрать through модели для M2M с дополнительными данными и GenericForeignKey, механизм для связи одной модели с объектами разных типов. Понять когда использовать каждый подход и как строить эффективные запросы.
Необходимые знания
Through models
Стандартная связь M2M хранит только пару ключей. Если нужны дополнительные данные о связи, нужна through model.
В нашем проекте теги на продуктах простые. Усложним: пусть каждый тег добавляется пользователем в конкретное время.
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(max_length=50, unique=True)
class ProductTag(models.Model):
"""Through model для M2M между Product и Tag"""
product = models.ForeignKey("Product", on_delete=models.CASCADE, related_name="product_tags")
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name="product_tags")
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [("product", "tag")]
class Product(models.Model):
# ...
tags = models.ManyToManyField(
Tag,
through="ProductTag", # указываем модель для M2M
related_name="products",
)
Миграция при добавлении through к существующему M2M
Django не умеет менять существующее M2M поле на M2M с through через AlterField - это запрещено на уровне миграций. Автоматически сгенерированная миграция упадет с ошибкой:
ValueError: Cannot alter field ... - you cannot alter to or from M2M fields,
or add or remove through= on M2M fields
Нужно вручную переписать миграцию после создания. Порядок операций важен: сначала создать ProductTag, потом перенести данные пока старая таблица ещё существует, потом удалить старое.
def copy_tags(apps, schema_editor):
# Копируем данные из shop_product_tags в shop_producttag
# Обе таблицы существуют одновременно на этом шаге
schema_editor.connection.cursor().execute("""
INSERT INTO shop_producttag (product_id, tag_id, added_at)
SELECT product_id, tag_id, NOW()
FROM shop_product_tags
""")
operations = [
# 1. Создать through модель (таблица shop_producttag появляется)
migrations.CreateModel(name='ProductTag', fields=[...]),
# 2. Перенести данные пока shop_product_tags ещё существует
migrations.RunPython(copy_tags, migrations.RunPython.noop),
# 3. Удалить старое M2M поле (удаляет shop_product_tags)
migrations.RemoveField(model_name='product', name='tags'),
# 4. Добавить новое M2M поле с through
migrations.AddField(
model_name='product',
name='tags',
field=models.ManyToManyField(through='shop.ProductTag', ...),
),
]
Если старые данные не нужны, убираете шаг 2 (RunPython). Порядок остаётся: CreateModel → RemoveField → AddField.
Создание связи через through
С through нельзя использовать стандартные add(), remove(), set(). Управление через явное создание объектов:
# Добавить тег
ProductTag.objects.create(
product=product,
tag=tag_sale,
added_by=request.user,
)
# Удалить тег
ProductTag.objects.filter(product=product, tag=tag_sale).delete()
# Проверить наличие тега
has_tag = ProductTag.objects.filter(product=product, tag=tag_sale).exists()
Запросы через through
Обращаться к данным through модели можно двумя путями:
# Через M2M поле (только теги, без данных through)
product.tags.all()
# Через through модель (теги + данные связи)
ProductTag.objects.filter(product=product).select_related("tag", "added_by")
-- product.tags.all()
SELECT shop_tag.*
FROM shop_tag
INNER JOIN shop_producttag ON shop_tag.id = shop_producttag.tag_id
WHERE shop_producttag.product_id = 1;
-- ProductTag.objects.filter(product=product)
SELECT shop_producttag.*, shop_tag.*, auth_user.*
FROM shop_producttag
INNER JOIN shop_tag ON shop_producttag.tag_id = shop_tag.id
LEFT OUTER JOIN auth_user ON shop_producttag.added_by_id = auth_user.id
WHERE shop_producttag.product_id = 1;
Фильтрация по данным through
# Продукты с тегом добавленным определенным пользователем
Product.objects.filter(
product_tags__added_by=user,
product_tags__tag__slug="sale",
)
# Теги добавленные за последние 7 дней
from django.utils import timezone
from datetime import timedelta
Tag.objects.filter(
product_tags__added_at__gte=timezone.now() - timedelta(days=7)
).distinct()
prefetch_related с through
# Загрузить продукты с тегами и данными о добавлении
Product.objects.prefetch_related(
Prefetch(
"product_tags",
queryset=ProductTag.objects.select_related("tag", "added_by"),
to_attr="tag_data",
)
)
for product in products:
for pt in product.tag_data:
print(f" {pt.tag.name} добавлен {pt.added_by.username} в {pt.added_at}")
Generic relations - связь с разными моделями
Иногда нужно связать одну модель с объектами разных типов. Пример: комментарии или теги, которые можно прикрепить и к продукту, и к заказу, и к любой другой модели.
Это реализуется через GenericForeignKey из django.contrib.contenttypes.
Как работает contenttypes
django.contrib.contenttypes хранит информацию о всех моделях в таблице django_content_type:
id | app_label | model
---|-----------|--------
1 | shop | product
2 | shop | order
3 | auth | user
...
ContentType позволяет ссылаться на любую модель через пару content_type_id, object_id.
Модель с GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
class Comment(models.Model):
# Ссылка на тип модели
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
# id объекта той модели
object_id = models.PositiveIntegerField()
# Виртуальное поле которое объединяет два предыдущих
content_object = GenericForeignKey("content_type", "object_id")
user = models.ForeignKey(User, on_delete=models.CASCADE)
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
# Составной индекс обязателен, без него каждый GenericFK запрос
# делает Seq Scan по всей таблице комментариев
# WHERE content_type_id = X AND object_id = Y
models.Index(fields=["content_type", "object_id"]),
]
CREATE INDEX shop_comment_ct_obj_idx ON shop_comment (content_type_id, object_id);
Без этого индекса каждое обращение к product.comments.all() выполняет Seq Scan по всей таблице shop_comment. На таблице с миллионами комментариев это критично. Индекс на (content_type_id, object_id) обязателен для любой модели с GenericForeignKey.
Обратная связь - GenericRelation
Чтобы обращаться к комментариям от самой модели, добавьте GenericRelation:
from django.contrib.contenttypes.fields import GenericRelation
class Product(models.Model):
# ...
comments = GenericRelation(Comment)
class Order(models.Model):
# ...
comments = GenericRelation(Comment)
Создание и запросы
# Создать комментарий к продукту
product = Product.objects.get(id=1)
Comment.objects.create(
content_object=product, # Django сам определяет content_type и object_id
user=user,
text="Отличный товар!",
)
# Или явно
ct = ContentType.objects.get_for_model(Product)
Comment.objects.create(
content_type=ct,
object_id=product.id,
user=user,
text="Отличный товар!",
)
# Получить все комментарии к продукту
product.comments.all()
# Получить объект на который ссылается комментарий
comment = Comment.objects.get(id=1)
obj = comment.content_object # вернет Product или Order, в зависимости от content_type
SQL для product.comments.all():
SELECT shop_comment.*
FROM shop_comment
WHERE content_type_id = 1 -- id ContentType для Product
AND object_id = 42; -- id конкретного продукта
prefetch_related для GenericForeignKey
GenericForeignKey не поддерживает стандартный select_related. Для загрузки связанных объектов используйте prefetch_related:
# prefetch_related поддерживает GenericForeignKey, Django группирует по content_type
comments = Comment.objects.prefetch_related("content_object")
-- Запрос 1: комментарии
SELECT * FROM shop_comment;
-- Запрос 2: продукты (для комментариев к Product)
SELECT * FROM shop_product WHERE id IN (1, 3, 7);
-- Запрос 3: заказы (для комментариев к Order)
SELECT * FROM shop_order WHERE id IN (2, 5);
Django группирует по content_type и делает отдельный запрос IN для каждого типа объектов.
ContentType кеш
ContentType.objects.get_for_model() кеширует результат, повторные вызовы не делают SQL:
# Первый вызов SELECT к django_content_type
ct = ContentType.objects.get_for_model(Product)
# Повторный вызов из кеша
ct = ContentType.objects.get_for_model(Product)
Когда использовать through, когда GenericForeignKey
Through model:
- Нужны дополнительные данные о связи M2M (дата, пользователь, метаданные)
- Типы связей заранее известны
- Нужна полноценная фильтрация и аннотирование через данные связи
GenericForeignKey:
- Нужна универсальная модель, привязанная к любому типу объектов (комментарии, лайки, теги, логи)
- Количество типов объектов будет расти
- Не нужны сложные JOIN через generic связь
Ограничения GenericForeignKey:
- Нельзя использовать
select_related - Нельзя делать JOIN через
content_objectв QuerySet - Нет referential integrity на уровне БД, можно создать ссылку на несуществующий объект
- Сложнее администрировать данные
Практическое задание
-
Создайте through модель
ProductTagс полямиadded_byиadded_at. Добавьте несколько тегов к продуктам через явное создание объектовProductTag. Загрузите продукты с тегами черезprefetch_relatedсto_attr. -
Создайте модель
CommentсGenericForeignKey. ДобавьтеGenericRelationкProductиOrder. Создайте несколько комментариев к продуктам и заказам. -
Загрузите все комментарии и для каждого выведите тип объекта и его строковое представление через
comment.content_object. -
Найдите все комментарии к продуктам дороже 500. Нельзя сделать прямой JOIN, предложите решение в два шага.
Возможные ошибки
Использование add()/remove() с through моделью
# AttributeError
product.tags.add(tag) # нельзя если задан through
# Правильно
ProductTag.objects.create(product=product, tag=tag, added_by=user)
Отсутствие индекса на content_type и object_id
# Без индекса каждый запрос комментариев к объекту - seq scan
class Comment(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
class Meta:
# Обязательно добавить составной индекс
indexes = [
models.Index(fields=["content_type", "object_id"])
]
select_related для GenericForeignKey
# Не работает - ошибка или игнорируется
Comment.objects.select_related("content_object")
# Правильно
Comment.objects.prefetch_related("content_object")
Связь со следующим уроком
Блок 4 завершен. В уроке 5.1 начинаем блок производительности: разберем индексы и какие они бывают. Как Django создает их через Meta.indexes и как правильно выбрать что индексировать.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru