Концепция полиморфизма в ORM Django

Spread the love

Использование идеи полиморфизма в реляционных базах данных является сложной задачей. В этой статье мы представляем несколько методов использования полиморфизма для представления полиморфных объектов в реляционной базе данных с использованием ORM Django.

Этот учебник среднего уровня предназначен для читателей, которые уже знакомы с основами Django.

Что такое полиморфизм?

Полиморфизм – это способность объекта принимать различные формы. Типичные примеры полиморфных объектов могут быть различные типы пользователей или товары на сайте интернет магазина. Полиморфная модель используется, когда один объект может поменять свою функциональности. Так в примере с пользователями как правило у объектов пользователей может быть много различных типов профилей. Или на веб-сайте электронного магазина могут быть разные типы товаров.

Почему создание полиморфизма является сложным?

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

  • Как представить один полиморфный объект: Полиморфные объекты имеют разные атрибуты. Django ORM сопоставляет атрибуты со столбцами в базе данных. В таком случае, как Django ORM должен сопоставлять атрибуты со столбцами в таблице? Разные объекты должны находиться в одной и той же таблице? А если у нас много таблиц?
  • Как ссылаться на экземпляры полиморфной модели: Чтобы использовать базу данных и функции Django ORM, нам нужно ссылаться на объекты, используя внешние ключи. То, как вы решили представлять отдельный полиморфный объект, имеет решающее значение для возможности использовать его.

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

Примечание. Чтобы следовать этому руководству, рекомендуется использовать бэкэнд PostgreSQL, Django 2.x и Python 3.

Можно также использовать другие базы данных. В местах, где используются функции, уникальные для PostgreSQL, будет предложена альтернатива.

Простая реализация

Представим что нас есть книжный магазин в хорошей части города рядом с кафе, и мы хотите начать продавать книги в Интернете.

Пока мы продаем только один вид товаров: книги. В своем интернет-магазине мы хотим показать подробную информацию о книгах, например, название и цену. Так же мы хотим, чтобы наши пользователи просматривали веб-сайт и выбирали много книг, поэтому нам нужна корзина. В конечном итоге что бы выполнить заказ нам необходимо отправить книги покупателю, поэтому нам еще нужно знать вес каждой книги, чтобы рассчитать стоимость доставки.

Давайте создадим простую модель для нового книжного магазина:

from django.contrib.auth import get_user_model
from django.db import models


class Book(models.Model):
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )

    def __str__(self) -> str:
        return self.name


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(Book)

Опробуем как будет работать наша модель в интерпритаторе Python.
Попробуем создать новую книгу указав имя книги, цену и вес:

>>> from naive.models import Book
>>> book = Book.objects.create(name='Python Tricks', price=1000, weight=200)
>>> book
<Product: Python Tricks>

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

>>> from django.contrib.auth import get_user_model
>>> haki = get_user_model().create_user('haki')

>>> from naive.models import Cart
>>> cart = Cart.objects.create(user=haki)

Затем пользователь может начать добавлять элементы к нему:

>>> cart.products.add(book)
>>> cart.products.all()
<QuerySet [<Book: Python Tricks>]>

Достоинства

  • Легко понять и поддерживать: Обычно этого достаточно для одного типа продукта.

Недостатки

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

Разреженная (Sparse) модель

Представим что наш магазин начал пользоваться успехом у пользователей. И они начали спрашивать, продаётся ли также электронные книги. Электронные книги – отличный продукт для любого интернет-магазина, и мы захотели сразу начать продавать их.

Физическая книга отличается от электронной книги:

  • Электронная книга не имеет веса. Это виртуальный продукт.
  • Электронная книга не требует пересылки. Пользователи скачивают его с сайта.

Чтобы ваша существующая модель поддерживала дополнительную информацию для продажи электронных книг, мы добавили несколько полей в существующую модель Book:

from django.contrib.auth import get_user_model
from django.db import models


class Book(models.Model):
    TYPE_PHYSICAL = 'physical'
    TYPE_VIRTUAL = 'virtual'
    TYPE_CHOICES = (
        (TYPE_PHYSICAL, 'Physical'),
        (TYPE_VIRTUAL, 'Virtual'),
    )
    type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
    )

    # Common attributes
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    # Specific attributes
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )
    download_link = models.URLField(
        null=True, blank=True,
    )

    def __str__(self) -> str:
        return f'[{self.get_type_display()}] {self.name}'


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(
        Book,
    )

Сначала мы добавили поле type, чтобы указать тип книги. Затем добавили поле download_link для хранения ссылки на скачивание электронной книги.

Теперь чтобы добавить физическую книгу в книжный магазин, нам надо сделать следующее:

>>> from sparse.models import Book
>>> physical_book = Book.objects.create(
...     type=Book.TYPE_PHYSICAL,
...     name='Python Tricks',
...     price=1000,
...     weight=200,
...     download_link=None,
... )
>>> physical_book
<Book: [Physical] Python Tricks>

А чтобы добавить новую электронную книгу, нужно сделать следующее:

>>> virtual_book = Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='The Old Man and the Sea',
...     price=1500,
...     weight=0,
...     download_link='https://books.com/12345',
... )
>>> virtual_book
<Book: [Virtual] The Old Man and the Sea>

Теперь наши пользователи смогут добавить в корзину как простые книги, так и электронные:

>>> from sparse.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Python Tricks>, <Book: [Virtual] The Old Man and the Sea>]>

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

>>> Book.objects.create(
...     type=Book.TYPE_PHYSICAL,
...     name='Python Tricks',
...     price=1000,
...     weight=0,
...     download_link='http://books.com/54321',
... )

Эта книга, очевидно, весит 0 фунтов и имеет ссылку для скачивания. Другая электронная книга, очевидно, весит 100 г и не имеет ссылки для скачивания:

>>> Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='Python Tricks',
...     price=1000,
...     weight=100,
...     download_link=None,
... )

Это явно какая то ошибка. Начинаются проблемы с целостностью данных. Чтобы преодолеть эти проблемы, мы решаем добавить проверки целостности в модель:

from django.core.exceptions import ValidationError


class Book(models.Model):

    # ...

    def clean(self) -> None:
        if self.type == Book.TYPE_VIRTUAL:
            if self.weight != 0:
                raise ValidationError(
                    'A virtual product weight cannot exceed zero.'
                )

            if self.download_link is None:
                raise ValidationError(
                    'A virtual product must have a download link.'
                )

        elif self.type == Book.TYPE_PHYSICAL:
            if self.weight == 0:
                raise ValidationError(
                    'A physical product weight must exceed zero.'
                )

            if self.download_link is not None:
                raise ValidationError(
                    'A physical product cannot have a download link.'
                )

        else:
            assert False, f'Unknown product type "{self.type}"'

Для этого мы использовали встроенный механизм проверки Django. Но метод clean() вызывается автоматически только формами Django. Для объектов, которые создаются не через форму Django, мы должны обязательно явно проверить объект.

Чтобы сохранить целостность модели Book, нам нужно внести небольшие изменения в способ создания книг:

>>> book = Book(
...    type=Book.TYPE_PHYSICAL,
...    name='Python Tricks',
...    price=1000,
...    weight=0,
...    download_link='http://books.com/54321',
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A physical product weight must exceed zero.']}

>>> book = Book(
...    type=Book.TYPE_VIRTUAL,
...    name='Python Tricks',
...    price=1000,
...    weight=100,
...    download_link=None,
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A virtual product weight cannot exceed zero.']}

При создании объектов с использованием менеджера по умолчанию (Book.objects.create (…)) Django все равно создаст объект и сразу сохранит его в базе данных.

Теперь нам нужно проверять объект перед сохранением в базу данных. То есть сначала создать объект (Book (…)), потом проверить (book.full_clean ()) и только затем сохранить его (book.save()).

Денормализация:

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

Достоинства

  • Легко читать код и его поддерживать: Разреженная модель обычно является первым шагом, который мы предпринимаем, когда определенным типам объектов требуется больше информации.

Недостатки

  • Невозможно использовать ограничения базы данных NOT NULL: Нулевые значения NULL используются для атрибутов, которые не определены для всех типов объектов.
  • Сложная логика валидации: Сложная логика проверки необходима для обеспечения соблюдения правил целостности данных. Соотвественно сложная логика требует больше тестов.
  • Большое количество Null полей создает беспорядок:  Использование нескольких типов продуктов в одной модели усложняет понимание и сопровождение.
  • Появление новых типов требуют изменения схемы: Новые типы продуктов потребуют дополнительных полей и проверок.

Ситуация использования

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

Полуструктурированная модель

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

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

Чтобы устранить беспорядок, мы решаем оставить только общие поля (name и price) в модели. А остальные поля переместить в одно поле JSONField:

from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import JSONField
from django.db import models

class Book(models.Model):
    TYPE_PHYSICAL = 'physical'
    TYPE_VIRTUAL = 'virtual'
    TYPE_CHOICES = (
        (TYPE_PHYSICAL, 'Physical'),
        (TYPE_VIRTUAL, 'Virtual'),
    )
    type = models.CharField(
        max_length=20,
        choices=TYPE_CHOICES,
    )

    # Common attributes
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    extra = JSONField()

    def __str__(self) -> str:
        return f'[{self.get_type_display()}] {self.name}'


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(
        Book,
        related_name='+',
    )

JSONField:

В этом примере мы используем PostgreSQL в качестве базы данных. Django может задействовать встроенное поле JSON в PostgreSQL через django.contrib.postgres.fields.

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

Теперь наша модель Book более упорядочена. Общие атрибуты созданы стандартными полями. Атрибуты, которые не являются общими для всех типов продуктов, хранятся в дополнительном поле JSONField:

>>> from semi_structured.models import Book
>>> physical_book = Book(
...     type=Book.TYPE_PHYSICAL,
...     name='Python Tricks',
...     price=1000,
...     extra={'weight': 200},
... )
>>> physical_book.full_clean()
>>> physical_book.save()
<Book: [Physical] Python Tricks>

>>> virtual_book = Book(
...     type=Book.TYPE_VIRTUAL,
...     name='The Old Man and the Sea',
...     price=1500,
...     extra={'download_link': 'http://books.com/12345'},
... )
>>> virtual_book.full_clean()
>>> virtual_book.save()
<Book: [Virtual] The Old Man and the Sea>

>>> from semi_structured.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(physical_book, virtual_book)
>>> cart.books.all()
<QuerySet [<Book: [Physical] Python Tricks>, <Book: [Virtual] The Old Man and the Sea>]>

Избавление от беспорядка очень важно, но это имеет свою стоимость. Логика проверки целостности становиться намного сложнее:

from django.core.exceptions import ValidationError
from django.core.validators import URLValidator

class Book(models.Model):

    # ...

    def clean(self) -> None:

        if self.type == Book.TYPE_VIRTUAL:

            try:
                weight = int(self.extra['weight'])
            except ValueError:
                raise ValidationError(
                    'Weight must be a number'
                )
            except KeyError:
                pass
            else:
                if weight != 0:
                    raise ValidationError(
                        'A virtual product weight cannot exceed zero.'
                    )

            try:
                download_link = self.extra['download_link']
            except KeyError:
                pass
            else:
                # Will raise a validation error
                URLValidator()(download_link)

        elif self.type == Book.TYPE_PHYSICAL:

            try:
                weight = int(self.extra['weight'])
            except ValueError:
                raise ValidationError(
                    'Weight must be a number'
                 )
            except KeyError:
                pass
            else:
                if weight == 0:
                    raise ValidationError(
                        'A physical product weight must exceed zero.'
                     )

            try:
                download_link = self.extra['download_link']
            except KeyError:
                pass
            else:
                if download_link is not None:
                    raise ValidationError(
                        'A physical product cannot have a download link.'
                    )

        else:
            raise ValidationError(f'Unknown product type "{self.type}"')

Преимущество использования стандартных полей Django в том, что ORM проверяет как тип так и значение поля. При использовании JSONField необходимо самим проверить и тип и значение:

>>> book = Book.objects.create(
...     type=Book.TYPE_VIRTUAL,
...     name='Python Tricks',
...     price=1000,
...     extra={'weight': 100},
... )
>>> book.full_clean()
ValidationError: {'__all__': ['A virtual product weight cannot exceed zero.']}

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

Например, в PostgreSQL вы можете запросить все книги, которые весят более 100:

>>> Book.objects.filter(extra__weight__gt=100)
<QuerySet [<Book: [Physical] Python Tricks>]>

Однако не все другие базы данных поддерживают это.

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

Этот полуструктурированный подход напоминает архитектуру NoSQL и имеет много преимуществ и недостатков. JSONfield – это способ обойти строгую схему реляционной базы данных. Этот гибридный подход дает нам возможность объединять множество типов объектов в одну таблицу, сохраняя при этом некоторые преимущества реляционной, строго типизированной базы данных. Для многих распространенных случаев использования NoSQL может оказаться хорошим выбором.

Достоинства

  • Уменьшает беспорядок: Общие поля хранятся в модели. Другие поля хранятся в одном JSONfield
  • Проще добавлять новые типы: Новые типы продуктов не требуют изменения схемы.

Недостатки

  • Сложная и специализированная логика проверки: Проверка поля JSON требует проверки типов и значений. Эту проблему можно решить с помощью других решений для проверки данных JSON, таких как схема JSON.
  • Невозможно использовать ограничения базы данных: Нельзя использовать ограничения базы данных, такие как null, unique и foreign key, которые обеспечивают тип и целостность данных на уровне базы данных.
  • Ограничена поддержка БД для поля JSON: Не все БД поддерживают запросы и индексацию полей JSON.
  • Стандартная миграция БД не применима: Изменения схемы БД может потребовать обратной совместимости или специальных миграций.
  • Нет глубокой интеграции с системой метаданных базы данных: Метаданные о полях не хранятся в базе данных. Схема применяется только на уровне приложения.

Ситуация использования

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

Классическим вариантом использования полуструктурированного подхода является хранение событий (таких как журналы, аналитика и хранилища событий). Большинство событий имеют метку времени, тип и метаданные, такие как тип устройство, пользовательский агент, пользователь и т. д. Данные для каждого типа хранятся в поле JSONField. Для аналитики и регистрации событий важно иметь возможность добавлять новые типы событий с минимальными усилиями, поэтому этот подход идеален.

Абстрактная базовая модель

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

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

Продукт определяется использованием общих атрибутов, таких как имя и цена. С использованием объектно-ориентированного подхода мы можем рассматривать продукт как базовый класс или интерфейс. Каждый новый тип продукта, который мы будем добавлять, должен реализовывать новый класс продуктов и таким образом расширять его своими собственными атрибутами.

Django предлагает возможность создавать абстрактные базовые классы. Давайте определим абстрактный базовый класс Product и добавим две модели для Book и EBook:

from django.contrib.auth import get_user_model
from django.db import models


class Product(models.Model):
    class Meta:
        abstract = True

    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    def __str__(self) -> str:
        return self.name


class Book(Product):
    weight = models.PositiveIntegerField(
        help_text='in grams',
    )


class EBook(Product):
    download_link = models.URLField()

Обратите внимание, что и Book, и EBook наследуются от Product. Поля, определенные в базовом классе Product, наследуются, поэтому производным моделям Book и Ebook не нужно их повторять.

Чтобы теперь добавить новые продукты, мы используем производные классы:

>>> from abstract_base_model.models import Book
>>> book = Book.objects.create(name='Python Tricks', price=1000, weight=200)
>>> book
<Book: Python Tricks>

>>> ebook = EBook.objects.create(
...     name='The Old Man and the Sea',
...     price=1500,
...     download_link='http://books.com/12345',
... )
>>> ebook
<Book: The Old Man and the Sea>

Возможно, вы заметили, что модель Cart отсутствует. Мы можем попробовать создать модель Cart с полем ManyToMany для Product:

class Cart(models.Model):
    user = models.OneToOneField(
       get_user_model(),
       primary_key=True,
       on_delete=models.CASCADE,
    )
    items = models.ManyToManyField(Product)

Но если попытаемся связать поле ManyToMany с абстрактной моделью, то получим следующую ошибку:

abstract_base_model.Cart.items: (fields.E300) Field defines a relation with model 'Product', which is either not installed, or is abstract.

Использование foreign key возможно только при указание на конкретную таблицу. Абстрактная базовая модель Product существует только в коде, поэтому в базе данных нет таблицы Product. Django ORM будет создавать таблицы только для производных моделей Book и EBook.

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

class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    books = models.ManyToManyField(Book)
    ebooks = models.ManyToManyField(EBook)

Теперь мы можем добавить в корзину как книги, так и электронные книги:

>>> user = get_user_model().objects.first()
>>> cart = Cart.objects.create(user=user)
>>> cart.books.add(book)
>>> cart.ebooks.add(ebook)

Эта модель сейчас немного сложнее. Давайте попробуем запросить общую стоимость товаров в корзине:

>>> from django.db.models import Sum
>>> from django.db.models.functions import Coalesce
>>> (
...     Cart.objects
...     .filter(pk=cart.pk)
...     .aggregate(total_price=Sum(
...         Coalesce('books__price', 'ebooks__price')
...     ))
... )
{'total_price': 1000}

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

Достоинства

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

Недостатки

  • Требуется несколько внешних ключей: Для ссылки на все типы продуктов каждому типу необходим внешний ключ.
  • Труднее реализовать и поддерживать: Операции со всеми типами продуктов требуют проверки всех внешних ключей. Это усложняет код и усложняет обслуживание и тестирование.
  • Очень трудно масштабировать: Новые виды продукции требуют дополнительных моделей. Управление многими моделями может быть утомительным и очень трудно масштабируемым.

Ситуация использования

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

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

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

Конкретная базовая модель

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

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

Используя конкретный базовый класс, Django создаст таблицу в базе данных для Product. Модель Product будет иметь все общие поля, которые мы определим в базовой модели. Производные модели, такие как Book и EBook, будут ссылаться на таблицу Product, используя одно поле. Чтобы сослаться на Product, мы создадим внешний ключ к базовой модели:

from django.contrib.auth import get_user_model
from django.db import models


class Product(models.Model):
    name = models.CharField(
        max_length=100,
    )
    price = models.PositiveIntegerField(
        help_text='in cents',
    )

    def __str__(self) -> str:
        return self.name


class Book(Product):
    weight = models.PositiveIntegerField()


class EBook(Product):
    download_link = models.URLField()

Единственное отличие этого примера от предыдущего состоит в том, что модель Product не определена с abstract = True.

Для создания новых продуктов мы напрямую используем производные модели Book и EBook:

>>> from concrete_base_model.models import Book, EBook
>>> book = Book.objects.create(
...     name='Python Tricks',
...     price=1000,
...     weight=200,
... )
>>> book
<Book: Python Tricks>

>>> ebook = EBook.objects.create(
...     name='The Old Man and the Sea',
...     price=1500,
...     download_link='http://books.com/12345',
... )
>>> ebook
<Book: The Old Man and the Sea>

В случае конкретного базового класса интересно посмотреть, что происходит в базовой базе данных. Давайте посмотрим на таблицы, созданные Django в базе данных:

> \d concrete_base_model_product

Column |          Type          |                         Default
--------+-----------------------+---------------------------------------------------------
id     | integer                | nextval('concrete_base_model_product_id_seq'::regclass)
name   | character varying(100) |
price  | integer                |

Indexes:
   "concrete_base_model_product_pkey" PRIMARY KEY, btree (id)

Referenced by:
   TABLE "concrete_base_model_cart_items" CONSTRAINT "..." FOREIGN KEY (product_id) 
   REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED

   TABLE "concrete_base_model_book" CONSTRAINT "..." FOREIGN KEY (product_ptr_id) 
   REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED

   TABLE "concrete_base_model_ebook" CONSTRAINT "..." FOREIGN KEY (product_ptr_id) 
   REFERENCES concrete_base_model_product(id) DEFERRABLE INITIALLY DEFERRED

Таблица продуктов имеет два знакомых поля: name и price. Это общие поля, которые мы определили в модели Product. Django также создал для нас первичный ключ ID.

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

> \d concrete_base_model_book

    Column     |  Type
---------------+---------
product_ptr_id | integer
weight         | integer

Indexes:
   "concrete_base_model_book_pkey" PRIMARY KEY, btree (product_ptr_id)

Foreign-key constraints:
   "..." FOREIGN KEY (product_ptr_id) REFERENCES concrete_base_model_product(id) 
   DEFERRABLE INITIALLY DEFERRED

Модель Book имеет только два поля:

  • weight это поле, которое мы добавили в производную модель Book.
  • product_ptr_id является первичным элементом таблицы и внешним ключом для базовой модели продукта.

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

Давайте посмотрим на запрос, сгенерированный Django, чтобы получить одну книгу. Вот результаты print(Book.objects.filter(pk=1).query):

SELECT
    "concrete_base_model_product"."id",
    "concrete_base_model_product"."name",
    "concrete_base_model_product"."price",
    "concrete_base_model_book"."product_ptr_id",
    "concrete_base_model_book"."weight"
FROM
    "concrete_base_model_book"
    INNER JOIN "concrete_base_model_product" ON
        "concrete_base_model_book"."product_ptr_id" = "concrete_base_model_product"."id"
WHERE
    "concrete_base_model_book"."product_ptr_id" = 1

Чтобы получить одну книгу, Джанго присоединился к concrete_base_model_product и concrete_base_model_book через поле product_ptr_id. Название и цена указаны в таблице продуктов, а вес – в таблице книг.

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

class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )
    items = models.ManyToManyField(Product)

Добавление товаров в корзину такое же, как и раньше:

>>> from concrete_base_model.models import Cart
>>> cart = Cart.objects.create(user=user)
>>> cart.items.add(book, ebook)
>>> cart.items.all()
<QuerySet [<Book: Python Tricks>, <Book: The Old Man and the Sea>]>

Работать с общими полями также просто:

>>> from django.db.models import Sum
>>> cart.items.aggregate(total_price=Sum('price'))
{'total_price': 2500}

Миграция базовых классов в Джанго:

Когда создается производная модель, Django добавляет к bases атрибут миграции:

migrations.CreateModel(
      name='Book',
      fields=[...],
      bases=('concrete_base_model.product',),
  ),

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

TypeError: metaclass conflict: the metaclass of a derived class must 
be a (non-strict) subclass of the metaclasses of all its bases

Это известная проблема в Django (#23818, #23521, #26488). Чтобы обойти это, вы должны отредактировать исходную миграцию вручную и настроить атрибут base.

Достоинства

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

Недостатки

  • Новые типы продуктов требуют изменения схемы: Создание нового типа потребует новой модели.
  • Могут появиться неэффективные запросы: Данные для одного элемента находятся в двух таблицах. Выбор продукта требует объединения с базовой таблицей.
  • Невозможно получить доступ к расширенным данным из экземпляра базового класса: Поле type необходимо для получения дочернего элемента. Это добавляет сложности к коду. django-polymorphic – популярный плагин, который поможет устранить некоторые из этих проблем.

Ситуация использования

Подход использования конкретной базовой модели полезен, когда общих полей в базовом классе достаточно для удовлетворения большинства общих запросов.

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

Общий Foreign Key

Наследование иногда может быть неприятным делом. Оно заставляет вас создавать (возможно, преждевременные) абстракции, и это не всегда хорошо вписывается в ORM.

Основная проблема, с которой мы столкнулись, – это ссылки на различные товары из модели корзины. Сначала мы попытались объединить все типы продуктов в одну модель (разреженная модель, полуструктурированная модель) и получили беспорядок. Затем мы попытались разделить продукты на отдельные модели и создать единый интерфейс, используя конкретную базовую модель. В итоге мы получили сложную схему со многими соединениями.

Django предлагает специальный способ ссылки на любую модель в модуле с названием Generic Foreign Key. Generic Foreign Key (Общие внешние ключи) являются частью  Content Types framework, встроенного в Django. Content Types framework используется самим Django для отслеживания моделей. Это необходимо для некоторых базовых возможностей, таких как миграции.

Чтобы лучше понять, что такое content types и как они упрощают общие внешние ключи, давайте рассмотрим content type, связанный с моделью Book:

>>> from django.contrib.contenttypes.models import ContentType
>>> ct = ContentType.objects.get_for_model(Book)
>>> vars(ct)
{'_state': <django.db.models.base.ModelState at 0x7f1c9ea64400>,
'id': 22,
'app_label': 'concrete_base_model',
'model': 'book'}

Каждая модель имеет уникальный идентификатор. Если вы хотите сослаться на книгу с PK 54, вы можете сказать: «Получить объект с PK 54 в модели, представленной типом контента 22».

Generic Foreign Key реализован именно так. Чтобы создать общий внешний ключ, нужно определить два поля:

  • Ссылка на content type (модель)
  • Primary key (Первичный ключ) ссылочного объекта (атрибут pk экземпляра модели)

Чтобы реализовать отношение «многие ко многим» с помощью GenericForeignKey, необходимо вручную создать модель для соединения с элементами корзины.

Модель Cart остается примерно такой же, как была до сих пор:

from django.db import models
from django.contrib.auth import get_user_model


class Cart(models.Model):
    user = models.OneToOneField(
        get_user_model(),
        primary_key=True,
        on_delete=models.CASCADE,
    )

В отличие от предыдущих моделей Cart, больше не включает поле ManyToMany. Мы сделаем это самостоятельно.

Чтобы представить один товар в корзине, нам нужно указать как корзину, так и любой товар:

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType


class CartItem(models.Model):
    cart = models.ForeignKey(
        Cart,
        on_delete=models.CASCADE,
        related_name='items',
    )
    product_object_id = models.IntegerField()
    product_content_type = models.ForeignKey(
        ContentType,
        on_delete=models.PROTECT,
    )
    product = GenericForeignKey(
        'product_content_type',
        'product_object_id',
    )

Теперь чтобы добавить новый элемент в Cart, нужно указать content type и primary key:

>>> book = Book.objects.first()

>>> CartItem.objects.create(
...     product_content_type=ContentType.objects.get_for_model(book),
...     product_object_id=book.pk,
... )
>>> ebook = EBook.objects.first()

>>> CartItem.objects.create(
...    product_content_type=ContentType.objects.get_for_model(ebook),
...    product_object_id=ebook.pk,
... )

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

class Cart(models.Model):

    # ...

    def add_item(self, product) -> 'CartItem':
        product_content_type = ContentType.objects.get_for_model(product)

        return CartItem.objects.create(
            cart=self,
            product_content_type=product_content_type,
            product_object_id=product.pk,
        )

Добавление нового товара в корзину теперь намного проще:

>>> cart.add_item(book)
>>> cart.add_item(ebook)

Получение информации о товарах в корзине также несложно:

>>> cart.items.all()
<QuerySet [<CartItem: CartItem object (1)>, <CartItem: CartItem object (2)>]

>>> item = cart.items.first()
>>> item.product
<Book: Python Tricks>

>>> item.product.price
1000

Все хорошо! Но где же подвох?

Попробуем подсчитать общую стоимость товаров в корзине:

>>> from django.db.models import Sum
>>> cart.items.aggregate(total=Sum('product__price'))

FieldError: Field 'product' does not generate an automatic reverse 
relation and therefore cannot be used for reverse querying. 
If it is a GenericForeignKey, consider adding a GenericRelation.

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

Сообщение об ошибке упоминает GenericRelation. Используя GenericRelation, мы можем определить обратную связь между ссылочной моделью и моделью Item. Например, мы можем определить обратное отношение от модели Book к CartItem:

from django.contrib.contenttypes.fields import GenericRelation

class Book(model.Model):
    # ...
    cart_items = GenericRelation(
        'CartItem',
        'product_object_id',
        'product_content_type_id',
        related_query_name='books',
    )

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

>>> book.cart_items.count()
4

>>> CartItem.objects.filter(books__id=book.id).count()
4

Два запроса идентичны.

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

>>> sum(item.product.price for item in cart.items.all())
2500

Это один из основных недостатков Generic Foreign Key. Гибкость достигается за счет производительности. Очень сложно оптимизировать производительность, используя только Django ORM.

Структурный подтип

В абстрактном и конкретном подходе к базовому классу мы использовали именной подтип (nominal subtyping), который основан на иерархии классов. Mypy в состоянии обнаружить эту форму отношения между двумя классами и определить типы из него.

Mypy – это библиотека Python задача которой предоставить статическую проверку типов, которая как бы объединят преимущества динамической (или «утиной») типизации и статической типизации.

В подходе Generic relation мы использовали структурный подтип. Структурный подтип (Structural subtyping) существует, когда класс реализует все методы и атрибуты другого класса. Эта форма подтипирования очень полезна, когда вы хотите избежать прямой зависимости между модулями.

Mypy предоставляет способ использовать структурные подтипы с использованием Protocols.

Мы уже определили сущность продукта с общими методами и атрибутами. Теперь мы можем определить Protocol:

from typing_extensions import Protocol

class Product(Protocol):
    pk: int
    name: str
    price: int

    def __str__(self) -> str:
        ...

Примечание. Использование атрибутов класса и ellipses (…) в определении метода – это новые возможности в Python 3.7. В более ранних версиях Python невозможно определить протокол с использованием этого синтаксиса. Вместо многоточия в теле должны указаны методы. Атрибуты класса, такие как pk и name, могут быть определены с помощью декоратора @attribute, но он не будет работать с моделями Django.

Теперь мы можем использовать Product для добавления информации о типе. Например, в add_item() мы принимам экземпляр продукта и добавляем его в корзину:

def add_item(
    self,
    product: Product,
) -> 'CartItem':
    product_content_type = ContentType.objects.get_for_model(product)

    return CartItem.objects.create(
        cart=self,
        product_content_type=product_content_type,
        product_object_id=product.pk,
    )

Запуск mypy для этой функции не выдаст никаких предупреждений. Допустим, мы изменили product.pk на product.id, который не определен в протоколе Product:

def add_item(
    self,
    product: Product,
) -> 'CartItem':
    product_content_type = ContentType.objects.get_for_model(product)

    return CartItem.objects.create(
        cart=self,
        product_content_type=product_content_type,
        product_object_id=product.id,
    )

Тогда мы получим следующее предупреждение от Mypy:

$ mypy
models.py:62: error: "Product" has no attribute "id"

Примечание: Protocol еще не является частью Mypy. Это часть дополнительного пакета под названием mypy_extentions. Пакет разработан командой Mypy и включает в себя функции, которые, по их мнению, еще не готовы для использования в основном пакете Mypy.

Достоинства

  • Больше не нужны миграции для добавления новых типов продуктов: Общий внешний ключ может ссылаться на любую модель. Добавление нового типа продукта не требует миграции.
  • Любая модель может быть использована в качестве Item: Используя общий внешний ключ, любая модель может ссылаться на модель Item.
  • Встроенная поддержка в админке: Django имеет встроенную поддержку общих внешних ключей в админке. Она может содержать, например, информацию о ссылочных моделях на странице сведений. 
  • Автономный модуль: Нет прямой зависимости между модулем продуктов и модулем корзины. Это делает этот подход идеальным для существующих проектов и подключаемых модулей.

Недостатки

  • Может производить неэффективные запросы: ORM не может заранее определить, на какие модели ссылается общий внешний ключ. Это делает очень трудным оптимизацию запросов, которые выбирают несколько типов продуктов.
  • Труднее понять и поддерживать: Общий внешний ключ исключает некоторые функции Django ORM, которые требуют доступа к конкретным моделям продуктов. Доступ к информации из моделей продукта требует написания большего количества кода.
  • Typing требует Protocol: Mypy не может обеспечить проверку типов для универсальных моделей. требуется Protocol.

Ситуация использования

Общие внешние ключи – отличный выбор для подключаемых модулей или существующих проектов. Использование GenericForeignKey и структурный подтип абстрагируют любую прямую зависимость между модулями.

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

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

Например, при использовании общего внешнего ключа мы не смогли быстро получить цену всей корзины. Мы должны были получить каждый элемент отдельно и потом суммировать их. Вы можете решить эту конкретную проблему, указав цену товара в модели Item (подход разреженной модели). Это позволит вам запрашивать только модель товара, чтобы очень быстро получить общую цену.

Заключение

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

Теперь вы знаете, как планировать и реализовывать полиморфную модель с помощью ORM Django. Вы знакомы с несколькими подходами и понимаете их плюсы и минусы. Вы можете проанализировать свой вариант использования и выбрать оптимальный курс действий.

Автор: Haki Benita
Оригинал: Modeling Polymorphism in Django With Python

Была ли вам полезна эта статья?
[8 / 5]

Spread the love
Подписаться
Уведомление о
guest
5 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Александр
Александр
5 лет назад

Можно еще использовать Proxy Models.
Т.е. обьявлять одну, базовую, модель с которой будут связаны все остальные модели и при этом иметь множество прокси моделей с разной логикой.

Благодаря этому можно
1. Оптимизировать запросы в бд(у нас хоть и большая, но одна модель с простыми фильтрами и агрегациями без сторонних таблиц).
2. Всю логику выносить в forms/serializers/managers/entities и т.д. для каждого типа.
3. Бысто создавать админку для разных “типов” обьектов. По факту, это разные модели с разными менеджерами и админке все равно, как это хранится в БД.
4. Всю бизнес-логику придется убирать из моделей, так как для различных типов нужна различная логика работы с CRUD-ом. ИМХО, это лучше, чем все делать в моделях.
5. Не сложное масштабирование.

Из недостатков:
1. Сложнее логика работы, с учетом одной общей модели или множества прокси моделей, которые нужно учитывать.
2. Нельзя сразу получить список обьектов с различными типами. Нужно или делать надстройку над получением списка либо делать мерж запросов прокси моделей.

Nikoay
Nikoay
5 лет назад

Отличный перевод! Спасибо за работу.

Максим
4 лет назад

Привет. Все предложенные варианты показывают, насколько автор не разбирается в джанго. Что мешает использовать EAV? Зачем брать поля json или наследование, когда надо иметь список свойств характерных для данного типа товара, так еще и иметь возможность расширять и переопределять его onfly

Метод CLEAN не вызывается, если создавать модель не через форму джанго. Вопрос, в каком случае это произойдет? Да ни в каком, потому что внос товаров идет через экспорт или через формы джанго, и только через консоль можно создать объект напрямую, но что мешает тогда и в консоли через форму создавать обьект? и если уж слвсем невмоготу, то переопредели метод save, Он то уж точно запустится при создании модели. Исключения конечно будут, например глобальные изменения бд через комманды массового изменения записей (update) не вызывают save одного объекта, но в данной статье это не рассматривается.

Жаль, что так много людей не понимает всю элегентность и простоту джанго, но они упорно пишут статьи на тему сложностей в джанго.

Денис
Денис
4 лет назад
Reply to  Максим

EAV медленно работает и нужен когда очень много уникальных атрибутов.
Например, мы ведём учет помещений, общего адрес – площать, а все остальные атрибуты – количество окон мебель потолки стены цвет, газ вода розетки и т.д. уникальны. Тогда eav подойдёт.
Если есть магазин где книги концелярия и и электронные книги, я не уверен что это будет лучшим решением.