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

Комментарии (4)


  1. DmitriyGordinskiy
    14.06.2023 16:24
    +1

    Чем вызвана необходимость использовать аннотации вместо атрибутов в примерах после первого?

    *а в тегах еще и php5, при том что атрибуты появились только в 8 версии.


    1. delaweb Автор
      14.06.2023 16:24

      Хорошо подметили, не ту версию php в тегах указали. Уже убрали)


  1. rpsv
    14.06.2023 16:24
    -2

    А можно ведь было сделать отдельные классы на сущности домена, мапить их при сохранении в таблицу и не насиловать бедные ActiveRecord'ы из доктрины :)


    1. powernic
      14.06.2023 16:24

      Ваш комментарий вообще мимо. В доктрине как раз не используется ActiveRecord а DataMapper. Так что с этим как раз все в порядке