Иногда на практике бывает необходимо расширить сущности, представляющие таблицы базы данных в коде.
В нашем примере имелись две одинаковые по смыслу сущности, которые имели общие поля и несли под собой один смысл — это сущности Car и Buggy.
На момент появления проблемы мы имели только сущность Car. При добавлении нового типа машины мы должны иметь возможность работать с ним как в качестве одного объединенного типа Auto, так и в качестве отдельного типа.
Разделение сущностей на два разных объекта неизбежно привело бы к крупному рефакторингу кода, переписыванию множества методов для работы с новым классом. Помимо этого, в будущем очень вероятна возможность появления новых видов родственных сущностей, из-за чего не хочется закладывать фундамент под их бесконечное клонирование.
Уделив время поиску решения, мы остановились на варианте наследования сущностей.
ORM Doctrine предлагает 3 варианта наследования сущностей. Разберем каждый из них по порядку, а также выясним плюсы и минусы каждого варианта.
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.
Плюсы:
- Таблицы создаются на каждый класс. Тем самым происходит экономия места на диске, за счет исключение нулевых полей в результате наследования.
- Из-за наличия внешнего ключа с родительской таблицей, исключаются конфликты из-за первичных ключей.
Минусы
- Медленные запросы через доктрину, из-за присутствия объединения нескольких таблиц.
- в случае внедрения в проект, придется переписывать старые методы и запросы.
SingleTable
Суть шаблона заключается в расположении полей нескольких классов в единой таблице СУБД. К примеру, это способствует уменьшению количества JOIN’ов при выборке данных из базы данных. Для реализации этого подхода нужно создать родительский класс и аннотировать его следующими аннотациями:
- @InheritanceType – указывает тип наследования.
- @DiscriminatorColumn (опционально) – указывает столбец в таблице базы данных, в котором хранится информация о типе строки относительно иерархии классов.
- @DiscriminatorMap (опционально) – указывает, какой записью в столбце @DiscriminatorColumn идентифицировать определенный тип.
Пример родительского класса из нашего кейса:
<?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']);
При выборе типа наследования мы опирались на следующие принципы: скорость выполнения выборок, уникальный первичный ключ в результате наследования.
Плюсы:
- не нужно переписывать обработку старых классов.
- первичный ключ у нас один, мы убираем риски конфликтов по уникальности.
- возможность сделать выборку как по родительскому, так и дочерним классам.
- эффективные и быстрые выборки за счет отсутствия джойнов и нескольких таблиц в запросе.
Минусы:
- потенциально большой объем таблицы, при наличии множества дочерних классов.
- “прожорливость” таблицы по занимаемому месту на диске за счет большого количества полей с гарантированным NULL-значением. Поля, которые есть у одной сущности и которых не бывает у других. Количество таких полей растет вместе с количеством родственных сущностей.
Каждый из типов имеет примерно одинаковое количество минусов и плюсов.
Для выбора правильного решения нужно тщательно оценивать перспективы развития сущностей у вас в проекте, их количества, разницы в полях, потребности в выборках разного рода и отталкиваться от этих вещей при выборе плана.
Проанализировав все возможные варианты, мы остановились на типе SingleTable.
При принятии решения мы учитывали следующие факторы:
-
Нам необходимо обращаться как к дочерним сущностям по отдельности, так и работать с объединенным списком (постраничная выборка, сортировка, фильтрация и так далее).
-
Мы знали, что дополнительных дочерних классов не будет.
-
Размеры таблицы не станут для нас критичным фактором, так как максимальное количество записей не будет превышать нескольких сотен.
-
Отличная скорость при выполнении запросов через Doctrine.
-
Минимум рефакторинга уже имеющегося кода.