В практике разработки веб-приложений иногда возникает необходимость расширения сущностей, которые представляют таблицы базы данных в коде. Для примера рассмотрим следующую ситуацию: в нашем проекте была реализация класса автотранспортного средства Car, но спустя некоторое время появилась возможность ввести еще один класс автотранспортного средства под названием Buggy. Новый класс, имел одинаковые поля и представлял схожую концепцию. Нам важно было иметь возможность работать с ним как с объединенным типом Auto, а также как с отдельным типом.
Разделение сущностей на два отдельных объекта привело бы к значительному рефакторингу кода и переписыванию множества методов для работы с новым классом. Кроме того, в будущем возможно появление новых родственных сущностей, и мы не хотели создавать фундамент для их бесконечного клонирования. После изучения возможных решений, мы остановились на готовом механизме наследования сущностей.
Варианты решения
ORM Doctrine предлагает три варианта наследования сущностей, и давайте рассмотрим каждый из них по порядку, а также выясним их преимущества и недостатки.
MappedSuperclass
Сопоставленный суперкласс — это абстрактный или конкретный класс, который не является сущностью, но обеспечивает постоянное состояние сущности и хранение информации о сопоставлении для своих подклассов. Основная цель такого сопоставленного суперкласса заключается в определении общей информации о состоянии и сопоставлении, которая применима для нескольких классов сущностей.
Сопоставленные суперклассы, подобно обычным несопоставленным классам, могут располагаться в середине иерархии наследования, которая обычно отображается с использованием одной таблицы или таблицы наследования классов.
MappedSuperclass не может быть самостоятельной сущностью, и он не поддерживает запросы. Постоянные связи, определенные в суперклассе, должны быть однонаправленными. Это означает, что ассоциации «один-ко-многим» вообще невозможны. Кроме того, обращения «многие-ко-многим» возможны только в том случае, если сопоставленный суперкласс используется только в одном объекте одновременно. Поддержка осуществляется с помощью функции наследования одиночной или объединенной таблицы.
Пример наследования в коде:
<?php use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\MappedSuperclass; use Doctrine\ORM\Mapping\Entity; #[MappedSuperclass] class Parent { #[Column(type: 'integer')] protected int $mapped1; #[Column(type: 'string')] protected string $mapped2; } #[Entity] class Сhild extends Parent { #[Id, Column(type: 'integer')] private int|null $id = null; #[Column(type: 'string')] private string $name; // ... more fields and methods }
В миграции мы получили таблицу, в которой отображения определены непосредственно в суперклассе и наследуются подклассами.
Плюсы
-
Нет необходимости вносить изменения на уровне базы данных.
-
Простой вариант объединения части полей родственных сущностей в один класс, сохраняющий чистоту кода и централизованное управление общими полями, что особенно актуально, если родственных сущностей много.
Минусы
-
Отсутствие возможности выполнять запросы к родительскому классу.
-
Необходимость написания сложных запросов для объединения двух таблиц.
-
Конфликт при запросах из-за повторов первичных ключей.
-
Не дает особых преимуществ, кроме чистоты кода.
JoinedTable
Стратегия наследования таблиц классов предполагает сопоставление каждого класса в иерархии с несколькими таблицами: собственной таблицей и таблицами всех родительских классов. При этом таблица дочернего класса связывается с таблицей родительского класса с помощью внешнего ключа. В Doctrine ORM эта стратегия реализуется с использованием дискриминатора, который находится в верхней таблице иерархии. Дискриминатор представляет собой простой способ осуществления полиморфных запросов с учетом наследования таблиц классов. Родительский класс будет выглядеть следующим образом:
<?php namespace DataLayerBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity * @ORM\InheritanceType("JOINED") * @ORM\DiscriminatorColumn(name = "discr", type = "string") * @ORM\DiscriminatorMap({"parent_entity" = "ParentEntity", "child_entity" = "AppBundle\Entity\ChildEntity"}) */ class ParentEntity { /** * @var int * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @var string * * @ORM\Column(name="name", type="string", length=255) */ protected $name; }
Пример дочернего класса:
<?php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use DataLayerBundle\Entity\ParentEntity; /** * @ORM\Table(name="child_entity") */ class ChildEntity extends ParentEntity { /** * @var int * * @ORM\Column(name="name", type="integer") */ protected $name; /** * @var int * * @ORM\Column(name="some_int", type="integer") */ protected $someInt; }
В результате создаются две таблицы: по одной для каждой сущности в иерархии классов. Каждая таблица содержит только поля, объявленные в соответствующем классе сущности. Важно отметить, что создается внешний ключ child_entity.id -> parent_entity.id.
Плюсы
-
Создание таблиц для каждого класса позволяет экономить место на диске, так как избегаются нулевые поля, которые возникают при наследовании.
-
Присутствие внешнего ключа с родительской таблицей помогает избежать конфликтов с первичными ключами.
Минусы
-
Запросы через Doctrine обрабатываются дольше из-за объединения нескольких таблиц.
-
При внедрении в проект потребуется переписывать старые методы и запросы, чтобы учесть новую структуру таблиц.
SingleTable
Это подход, при котором поля нескольких классов размещаются в одной таблице в базе данных, что позволяет сократить количество операций объединения JOIN при выборке из СУБД. Для реализации этого подхода необходимо создать родительский класс и применить следующие аннотации:
-
@InheritanceType: указывает тип наследования.
-
@DiscriminatorColumn (опционально): указывает столбец в таблице базы данных, где будет храниться информация о типе записи относительно иерархии классов.
-
@DiscriminatorMap (опционально): определяет соответствие значений в столбце, указанном в @DiscriminatorColumn, с конкретными типами записей.
Таким образом, при использовании SingleTable вся информация о полях различных классов хранится в одной таблице, и тип каждой записи определяется значением в дискриминаторном столбце. Это упрощает выборку данных и уменьшает необходимость использования операций JOIN.
Пример родительского класса из нашего кейса:
<?php namespace App\Entity; /** * @Entity * @InheritanceType("SINGLE_TABLE") * @DiscriminatorColumn(name="type", type="string") * @DiscriminatorMap({"auto" = "Auto", "buggy" = "Buggy", "car" = "Car"}) * @Gedmo\SoftDeleteable(fieldName="deletedAt", timeAware=false, hardDelete=true) */ class Auto { use IdentifiableEntityTrait; use SoftDeleteableEntity; use ManualTimestampableEntity; public const TYPE_BUGGY = 'buggy'; public const TYPE_LAWNMOWER = 'mower'; public const TRANSLATION_ERROR_NOT_FOUND = 'entity.auto.error.notFound'; /** * @ORM\Column(type="string", length=255, nullable=true) */ protected ?string $name = null; /** * @ORM\ManyToOne(targetEntity=Status::class, inversedBy="cars", cascade={"persist"}) */ protected ?Status $status = null; /** * @ORM\Column(type="string", length=255) */ protected ?string $id_car = null; /** * @ORM\Column(type="json", nullable=true) */ protected ?array $location = []; }
Пример дочернего класса Buggy:
<?php namespace App\Entity; use App\Repository\BuggyRepository; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass=BuggyRepository::class) */ class Buggy extends Auto { public const TRANSLATION_BUGGY_ERROR_CONFLICT = 'entity.buggy.error.conflict'; }
И класса Car:
<?php namespace App\Entity; /** * @ORM\Entity(repositoryClass=CarRepository::class) */ class Car extends Auto { /** * @ORM\ManyToOne(targetEntity=Blade::class, inversedBy="cars") */ private ?Blade $blade = null; /** * @ORM\Column(type="string", length=255, nullable=true) */ private ?string $color = null; /** * @ORM\Column(type="string", length=255, nullable=true) */ private ?string $flight_mode = null; }
После миграции у нас появляется одна таблица auto, которая содержит все поля дочерних классов, а также добавляется поле дискриминатора, название которого мы указали в аннотации. Для фильтрации данных по этому полю можно использовать следующую конструкцию:
$query ->andWhere('Auto INSTANCE OF :type_auto') ->setParameter('type_auto', $request->get('filter')['type']);
Выбирая тип наследования, мы оперировали следующими критериями: скорость выполнения запросов и уникальный первичный ключ, получаемый в результате наследования.
Плюсы
-
Нет необходимости переписывать обработку существующих классов.
-
Снижение рисков возникновения конфликтов, поскольку первичный ключ один.
-
Выборка может осуществляться из дочернего и родительского классов.
-
Более быстрая обработка запросов в СУБД за счет отсутствия JOIN-операций и нескольких таблиц в запросе.
Минусы
-
Потенциально большой размер таблицы при наличии множества дочерних классов.
-
Требуется большой объем памяти для хранения таблицы на диске из-за большого количества полей, которые существуют только у определенных сущностей и отсутствуют у других. Количество таких полей возрастает с увеличением числа родственных сущностей.
Итог
Каждый из представленных типов наследования имеет свои плюсы и минусы. Чтобы выбрать правильное решение, необходимо тщательно оценить перспективы развития сущностей в вашем проекте, их количество, различия в полях, потребности в различных типах выборок.
После анализа всех возможных вариантов мы приняли решение использовать тип наследования SingleTable. За этим решением стояли следующие потребности и факторы:
-
Возможность обращения как к дочерним сущностям по отдельности, так и к общему списку (с постраничной выборкой, сортировкой, фильтрацией).
-
Небольшое количество родственных классов. Так мы не усложним код и исключим вероятность ошибок.
-
Ожидаемое количество записей, не превышающее нескольких сотен. Место, занимаемой таблицей на диске, не станет для нас проблемой.
-
Быстрая обработка запросов через ORM Doctrine.
-
Минимальный рефакторинг кода.
Таким образом, выбор наследования типа SingleTable обусловлен сочетанием удобства работы с данными, производительности запросов и минимальным вмешательством в существующий код.
ссылка на оригинал статьи https://habr.com/ru/articles/741772/
Добавить комментарий