Назад к кейсам

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

12 мая, 2023 15 минут
Проблематика

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

  • Минимум рефакторинга уже имеющегося кода.

Технологии

  • PHP
  • Doctrine
  • ORM