Написать нам
Назад к кейсам

Наследование 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
IT-агентство Delaweb
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.