Трюки 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')}
Заключение
Общие внешние ключи могут быстро и элегантно вписаться в ряд вариантов использования — в первую очередь, когда модель может иметь связь с несколькими другими моделями. Будь то отслеживание тегов товаров, товаров в корзине, комментариев к товарам и т. д.
Несмотря на то, что общие внешние ключи имеют ограничения и снижают производительность некоторых запросов, они обеспечивают ключевую функциональность и полиморфизм в растущей базе кода.