Трюки Django: полиморфизм с общими внешними ключами

Одной из многих мощных функций, которые Django привносит в ORM, является способность вводить полиморфизм в наши модели — концепция, которая становится критически важной по мере роста приложений и разработчиков, стремящихся сократить повторяющийся и шаблонный код.

В этой статье я расскажу, что такое универсальный внешний ключ, как его настроить и где он наиболее полезен.

Внешние ключи

В Django разработчик может создать связь между двумя конкретными таблицами с помощью внешних ключей. Эти отношения (один к одному, один ко многим, многие ко многим) устанавливаются между двумя конкретными моделями. Например, от UserProfile до User (один к одному), от Library до Books (один ко многим), от Students до Courses (многие ко многим).

В каждом из приведенных выше примеров связь строго между двумя конкретными моделями, но как насчет случая, когда мы хотим связать одну модель с несколькими другими моделями?

В качестве конкретного примера давайте представим, что мы создаем веб-приложение с функцией, позволяющей пользователям оставлять отзывы о различных товарах, найденных в оптовом магазине: Books, Fruits, Clothes, OfficeSupplies и т. д.

Одним из возможных решений может быть добавление другого поля внешнего ключа в модель Reviews для каждой модели, которую можно просмотреть. Это обеспечило бы простое решение, но создало бы дополнительные сложности в виде громоздких операторов if-else каждый раз, когда необходимо пройти через отношение.

Общие внешние ключи

Введите Общие внешние ключи. Как следует из названия, они позволяют нам создать внешний ключ, в котором каждая запись может ссылаться на любую модель, а не только на одну.

В то время как внешний ключ обычно представляет собой просто одно целое число, указывающее на конкретную запись в связанной модели, общий внешний ключ также включает дополнительное поле для отслеживания того, на какую модель ссылаются.

Давайте посмотрим, как это может выглядеть на практике:

from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType

class Review(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    comment = models.TextField()
    rating = models.IntegerField()
    
    # Fields below are for generic foreign key so that Reviews can be associated with different models.
    content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField(null=True, blank=True)
    content_object = GenericForeignKey('content_type', 'object_id')

Первые 3 поля тривиальны — они описывают рассматриваемый отзыв: кто написал отзыв, как он его оценил и какие комментарии они написали.

Следующие 3 поля составляют общий внешний ключ:

  • content_type описывает, на какую модель ссылается эта запись.
  • Целое число object_id указывает на конкретную запись в модели, на которую ссылаются.
  • content_object - это то, как пройти отношение к модели, на которую ссылаются.

Универсальные внешние ключи требуют дополнительной работы, когда мы также должны обновлять модели, на которые ссылаются:

class Book(models.Model):
    title = models.CharField(max_length=128)
    author = models.CharField(max_length=128)
    price = models.DecimalField()

    # Link back to reviews:
    reviews = GenericRelation('Review')

    def __str__(self):
      return f'{self.title}, by {self.author} (${self.price})'

class Fruit(models.Model):
    name = models.CharField(max_length=64)
    price = models.DecimalField()

    # Link back to reviews:
    reviews = GenericRelation('Review')

    def __str__(self):
      return f'{self.name} (${self.price})'

В приведенном выше примере модели Book и Fruit могут иметь обзоры, назначенные им с помощью общего внешнего ключа. Давайте посмотрим, как это выглядит на практике

Использование общих внешних ключей

Теперь, когда мы создали модель Review и установили связь с моделями Fruit и Book, давайте создадим несколько записей и добавим к ним обзоры.

Как и обычный внешний ключ, мы можем создать обзор непосредственно из моделей, на которые ссылаются (Fruit и Book), например:

>>> from .models import Review, Fruit, Book
>>> banana = Fruit.objects.create(name='Banana', price=0.99)
>>> banana.reviews.create(author=User.objects.first(), comment='too sticky!', rating=1)
<Review: Review object (1)>
>>> banana.reviews.create(author=User.objects.last(), comment='Great source of Potasium', rating=5)
<Review: Review object (2)>
>>>
>>> moby_dick = Book.objects.create(title='Moby Dick (paperback)', author='Herman Melville', price='9.99')
>>> moby_dick.reviews.create(author=User.objects.first(), comment='Classic!', rating=5)
<Review: Review object (3)>

Мы видим, что теперь у нас есть всего три обзора, охватывающих 2 записи для 2 разных моделей. Мы можем очень легко пересечь отношения и в другом направлении:

>>> Review.objects.all()
<QuerySet [<Review: Review object (1)>, <Review: Review object (2)>, <Review: Review object (3)>]>
>>> Review.objects.first()
<Review: Review object (1)>
>>> Review.objects.first().content_object
<Fruit: Banana ($0.99)>
>>> Review.objects.last().content_object
<Book: Moby Dick (paperback) by Herman Melville ($9.99)>

Указатель content_object позволяет нам перемещаться по отношению в другом направлении, не беспокоясь о том, какой тип модели может быть на другом конце.

Полиморфизм

Пример 1: встроенные методы

Вот где все становится действительно круто. Мы можем использовать поле content_object и полиморфизм для создания метода __str__, который вызывает метод __str__ модели, на которую ссылаются. Например, если бы мы хотели, чтобы метод __str__ модели Review печатал рейтинг вместе с названием рецензируемого элемента, мы могли бы сделать это следующим образом:

class Review(models.Model):
    [ ... ]

    def __str__(self):
        return f"{self.author} review ({self.rating}-star) of {self.content_object}"

Теперь при печати объекта Review мы получаем:

>>> Review.objects.first()
<Review: John Smith review (1-star) of Banana ($0.99)>

Это также может быть чрезвычайно полезно и для метода get_absolute_url, например, при отображении обзора вы можете оставить ссылку на страницу для рецензируемого элемента, например:

<h1>Review<h1> 
<a href="{{ review.content_object.get_absolute_url }}">
  View item
</a><br />
<b>Author</b>: {{ review.author }}<br />
<b>Rating</b>: {{ review.rating }}-star<br />
<b>Comment</b>: <p>{{ review.comment }}</p>

Обратите внимание, что ссылка абстрактно вызывает метод get_absolute_url ссылочного объекта. Мы можем сделать это, ничего не зная об объекте, на который ссылаются — это может быть Book, Fruit или даже какая-то модель, которую мы еще не создали, этот код всегда будет создавать рабочий URL-адрес правильной модели, при условии, что мы определили нашу get_absolute_url методов на всех наших моделях.

Пример 2: поля

Мы также можем применить эту концепцию на полевом уровне. Вы заметите, что обе наши модели Fruit и Book имеют поле price. Как и раньше, мы можем использовать полиморфизм для общего отображения любых общих полей между моделями, на которые ссылаются, например:

{% if review.content_object.price %}
<b>Price</b>: ${{ review.content_object.price }}
{% endif %}

Оператор if защищает нас от будущих ссылок на любые модели, в которых может не быть определено поле price.

При необходимости общие поля между моделями, на которые ссылаются, могут быть принудительно реализованы с помощью абстрактных базовых классов в Django, но это тема для другого дня.

Пример 3: Агрегаты

Допустим, мы хотели узнать количество отзывов для каждой записи Fruit:

>>> from django.db.models import Count
>>> all_fruits = Fruit.objects.annotate(num_reviews=Count('reviews'))
>>> all_fruits.first().num_reviews
2

Средний обзор для каждой Fruit записи:

>>> from django.db.models import Avg
>>> all_fruits = Fruit.objects.annotate(avg_rating=Avg('reviews__rating'))
>>> all_fruits.first().avg_rating
3.0

К сожалению, Django не может вычислять агрегаты в другом направлении, поэтому полиморфизм здесь невозможен. Например, мы могли бы рассчитать среднюю цену только для Fruit записей с 5-звездочным рейтингом:

>>> from django.db.models import Avg
>>> Fruit.objects.filter(reviews__rating=5).aggregate(Avg('price'))
{'price__avg': Decimal('0.990000000000000')}

Заключение

Общие внешние ключи могут быстро и элегантно вписаться в ряд вариантов использования — в первую очередь, когда модель может иметь связь с несколькими другими моделями. Будь то отслеживание тегов товаров, товаров в корзине, комментариев к товарам и т. д.

Несмотря на то, что общие внешние ключи имеют ограничения и снижают производительность некоторых запросов, они обеспечивают ключевую функциональность и полиморфизм в растущей базе кода.

Статьи по Теме