Наследование Entity в Doctrine

от автора

В практике разработки веб-приложений иногда возникает необходимость расширения сущностей, которые представляют таблицы базы данных в коде. Для примера рассмотрим следующую ситуацию: в нашем проекте была реализация класса автотранспортного средства 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/