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