Django ORM

Through models и Generic relations | Курс Django ORM урок 4.4

Through models и Generic relations | Курс Django ORM урок 4.4
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 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). Порядок остаётся: CreateModelRemoveFieldAddField.

Создание связи через 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 на уровне БД, можно создать ссылку на несуществующий объект
  • Сложнее администрировать данные

Практическое задание

  1. Создайте through модель ProductTag с полями added_by и added_at. Добавьте несколько тегов к продуктам через явное создание объектов ProductTag. Загрузите продукты с тегами через prefetch_related с to_attr.

  2. Создайте модель Comment с GenericForeignKey. Добавьте GenericRelation к Product и Order. Создайте несколько комментариев к продуктам и заказам.

  3. Загрузите все комментарии и для каждого выведите тип объекта и его строковое представление через comment.content_object.

  4. Найдите все комментарии к продуктам дороже 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 и как правильно выбрать что индексировать.


<< Урок 4.3

Урок 5.1 >>


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы