Skip to content

Instantly share code, notes, and snippets.

@roxblnfk
Last active August 18, 2021 13:58
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save roxblnfk/0376dc7f1eec34262994fda3d4cdef36 to your computer and use it in GitHub Desktop.
Save roxblnfk/0376dc7f1eec34262994fda3d4cdef36 to your computer and use it in GitHub Desktop.
Inheritance Mapping

Inheritance Mapping

Consider a simple example of a class hierarchy:

animals

Task: Save an arbitrary set of entities of these classes in relational database, following the principles of the relational model.

Proposition:

Relational databases doesn't support inheritance.

Possible solutions:

  1. One way is to use one table for all entity classes, selecting one column for associating with the entity class. But the set of attributes of an entity can be very different between two subclasses of the same hierarchy. The problem of this approach is: MANY unused columns per record. As many as the number of unique fields the classes will have.

  2. Other solution is to allocate additional tables for the unique fields of each class. To get Cat entity, for example, we should JOIN tables cat, animal and pet. Disadvantage of this approach is the more parents with tables entity class has, the more uncomfortable saving of entity become: need to update and insert values into all tables.

As can be seen, there is no easy way to map a class hierarchy to relational database tables.

Can ORM take over this task by providing the user with a familiar interface? Yes, it can.
The solutions above, does not describe any kind of innovative approach and is a loose interpretation of known forms of inheritance: JTI (Joined Table Inheritance) and STI (Single Table Inheritance).

The ability to implement STI was initially available in Cycle ORM v1 in some way extent.
STI Support has been improved In Cycle ORM v2, and JTI support has been added.

Joined Table Inheritance

in JTI each entity in class hierarchy map to individual table.
Each table contains only columns of the class associated with it and identifier column, need for joining tables.
Note: JTI in Doctrine ORM called Class Table Inheritance strategy.

// Base Class
class Animal {              // Matches table "animal" with `id` and` age` columns
    public ?int $id;
    public int $age;
}
// Subclasses
class Pet extends Animal {  // Matches table "pet" with `id` and `name` columns
    public string $name;
}
class Cat extends Pet {     // Matches table "cat" with `id` и `frags` columns
    public int $frags;
}
class Dog extends Pet {     // Matches table "dog" with `id` и `trainingLevel` columns
    public int $trainingLevel;
}
ORM Schema

Schema::PARENT option is used to configure inheritance in the schema, the value can be parent class or its role.

use Cycle\ORM\SchemaInterface as Schema;

$schema = new \Cycle\ORM\Schema([
    Animal::class => [
        Schema::ROLE        => 'animal',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'animal',
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'age'],
        Schema::TYPECAST    => ['id' => 'int', 'age' => 'int'],
    ],
    Pet::class => [
        Schema::ROLE        => 'pet',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'pet',
        Schema::PARENT      => Animal::class,               // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'name'],
        Schema::TYPECAST    => ['id' => 'int'],
    ],
    Cat::class => [
        Schema::ROLE        => 'cat',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'cat',
        Schema::PARENT      => 'pet',                       // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'frags'],
        Schema::TYPECAST    => ['id' => 'int', 'frags' => 'int'],
    ],
    Dog::class => [
        Schema::ROLE        => 'dog',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'dog',
        Schema::PARENT      => 'pet',                       // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'trainingLevel'],
        Schema::TYPECAST    => ['id' => 'int', 'trainingLevel' => 'int'],
    ],
]);

Features of JTI in Cycle ORM

Loading a specific subclass in the hierarchy will result in an SQL query, containing an INNER JOIN to all tables in its inheritance path. For example, loading data for the entity Cat, ORM will execute a query like this:

SELECT ... FROM cat INNER JOIN pet USING (id) INNER JOIN animal USING (id) ...

If the entity being loaded is a base class, then all tables corresponding to subclasses will be loaded by default.
Thus, loading the class Pet, ORM will execute a query like this:

SELECT ...
FROM pet
INNER JOIN animal USING (id)
LEFT JOIN cat ON pet.id = cat.id
LEFT JOIN dog ON pet.id = dog.id
LEFT JOIN hamster ON pet.id = hamster.id
...

To disable automatic subclasses tables joining, use the loadSubclasses(false) method:

/** @var Pet[] */
$cat = (new \Cycle\ORM\Select($this->orm, Pet::class))
    ->loadSubclasses(false)->fetchAll();

Note, that when implementing JTI method, the Primary Key of the base class table must be used as unique index in all tables of child entities, but it can be auto-incremental only in the table of the base class.

You can define different columns to be a Primary Key for each entity in class hierarchy, however the value of this columns will be the same. The default behaviour, where the Primary Key of the subclass is matched with the Primary Key of the parent, can be changed with option SchemaInterface::PARENT_KEY.

Deleting the entity of certain subclass will result in the execution of a query to delete a record from the corresponding table only.

use \Cycle\ORM\Select;

$cat = (new Select($this->orm, Cat::class))->wherePK(42)->fetchOne();
(new \Cycle\ORM\Transaction())->delete($cat)->run();

// Deletes record from cat table only. Record still exists in parent tables:

$cat = (new Select($this->orm, Cat::class))->wherePK(42)->fetchOne(); // Null
$pet = (new Select($this->orm, Pet::class))->wherePK(42)->loadSubclasses(false)->fetchOne(); // Pet (id:42)
$base = (new Select($this->orm, Aimal::class))->wherePK(42)->loadSubclasses(false)->fetchOne(); // Animal (id:42)

ORM relies on foreign keys, which will result in data deletion from tables below in the inheritance hierarchy.
If no foreign keys is set, the commands for deletion should be set in the mapper of deleting entity.

Relations in JTI

You can use any relations in ALL classes of hierarchy.
Eager relations of subclasses and parent classes ALWAYS loads automatically.
Lazy parent class relations can be loaded by hands the same way, as if these relations were originally have been configured for the requested role:

$cat = (new \Cycle\ORM\Select($this->orm, Cat::class))
    ->load('thread_balls')    // Cat class relation
    ->load('current_owner')   // Pet class relation
    ->load('parents')         // Animal class relation
    ->wherePK(42)->fetchOne();

Attention! Do not load relations to the JTI hierarchy in one query. The resulting combination of LEFT and INNER joins, will most likely result in as incorrect resulting query.

$cat = (new \Cycle\ORM\Select($this->orm, Owner::class))
    ->load('pet', ['method' => Select::SINGLE_QUERY]) // <= pet is subclass of inheritance hierarchy
    ->wherePK(42)->fetchOne();

Single Table Inheritance

STI implies the use of one table for several subclasses of the hierarchy. This means, all attributes of specified classes in hierarchy are located in one common table. Special discriminator column used to determine which data belongs to which class.

If some subclass has an attribute, that NOT COMMON to ALL other classes of that table, then it should be saved in a column with default value set.
Otherwise, saving neighboring classes will cause an error.

Example

// Base Class
class Pet extends Animal {
    public ?int $id;
    public string $name;        // Common Field for all classes
}
class Cat extends Pet {
    public int $frags;          // Unique Field of Cat class
}
class Dog extends Pet {
    public int $trainingLevel;  // Unique Field of Dog Class
}
/* Таблица:
    id: int, primary
    _type: string               // Discriminator column
    name: string
    frags: int, nullable, default=null
    trainingLevel: int, nullable, default=null
 */
ORM Scheme

Schema::CHILDREN option is used to enum classes of one table. The value of this key is array of the form Discriminator Value => Role or entity Class.

Use Schema::DISCRIMINATOR option to set the name of discriminator field.

use Cycle\ORM\SchemaInterface as Schema;

$schema = new \Cycle\ORM\Schema([
    Pet::class => [
        Schema::ROLE        => 'role_pet',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'pet',
        Schema::CHILDREN    => [                // list of the subclasses
            'cat' => Cat::class,
            'dog' => 'role_dog',                // Can use role name instead of class name
        ],
        Schema::DISCRIMINATOR => 'pet_type',    // Discriminator field name
        Schema::PRIMARY_KEY => 'id',

        //In Base Class Schema should be Listed all Child Classes Fields along with Discriminator Field
        Schema::COLUMNS     => [
            'id',
            'pet_type' => 'type_column',        // Configuring Discriminator field in table
            'name',
            'frags',                            // Field from class Cat
            'trainingLevel'                     // Field from class Dog
        ],
        Schema::TYPECAST    => ['id' => 'int', 'frags' => 'int', 'trainingLevel' => 'int'],
    ],
    Cat::class => [
        Schema::ROLE => 'role_cat',
    ],
    Dog::class => [
        Schema::ROLE => 'role_dog',
    ],
]);

Describing this Schema:

  • Table pet used for storing entities of Base Class Pet and his child Classes: Cat and Dog.
  • Discriminator value will be stored in column pet_type.
  • During selecting entity from a table, depending on the value of per_type column (cat or dog), ORM will instantiate entity of class Cat or Dog respectively.
  • If value of discriminator will differ from cat or dog, the entity of Base Class will be instantiated.

Specifics of STI:

  • Can't use classless entities: entity must have class. Therefore stdMapper and ClasslessMapper mappers are not STI-compatible.
  • Base Class can be Abstract, but you should guarantee, that all possible values of Discriminator column correspond to their subclasses.
  • All Unique Columns of all Subclasses should have Default Value.
  • ORM will set the desired value of the Discriminator field when saving record to the database, no need to pre-fill Discriminator field in the entity.
  • Request for an entity of a certain class from the general table is not accompanied by a filtering condition by value discriminator. This will be fixed in the future, but now, if necessary, you should add an expression like: ->where('_type', '=', 'cat').

Relations in STI

You can use any relations in Base Class. They will automatically be applied to subclasses.


By using different forms of inheritance you can implement different strategies. Combine STI and JTI within the one hierarchy for the reasons of Expediency, and Cycle ORM will take care of the rest.

Inheritance Mapping

Рассмотрим простой пример иерархии классов:

animals

Перед нами стоит задача сохранить произвольный набор сущностей этих классов в реляционной базе данных, следуя принципам реляционной модели.
Реляционные базы данных не поддерживают наследование. Давайте рассуждать.
Можно просто взять и засунуть все значения в одну таблицу, выделив один столбец под связь с классом сущности.
Но комплект атрибутов сущностей в зависимости от класса иерархии может меняться. Если у классов Cat, Dog и Hamster будет по несколько уникальных полей, то сложить их всех в одну таблицу будет проблематично: слишком много неиспользуемых столбцов на каждую запись.
Мы можем пойти по другому пути: выделить дополнительные таблицы для уникальных полей каждого класса. Тогда, чтобы получить сущность Cat, нам придётся выполнить JOIN-запрос, соединяющий таблицы cat, animal и pet. Однако, в этом случае чем больше у класса сущности будет родительских классов с таблицами, тем неудобнее процесс сохранения сущности: обновлять и вставлять значения надо во все таблицы.

Возникает проблема: в реляционных базах данных нет простого способа сопоставить иерархию классов с таблицами базы данных.

Может ли ORM взять на себя эту задачу, предоставив пользователю привычный интерфейс? Может.
Рассуждения выше, на самом деле, не описывают какой-то инновационный подход и являются вольной трактовкой известных форм наследования: JTI (Joined Table Inheritance) и STI (Single Table Inheritance).

Возможность реализовать STI в той или иной мере изначально доступна в Cycle ORM v1. В Cycle ORM v2 произведена доработка STI и добавлена поддержка JTI.

Joined Table Inheritance

В JTI каждый класс в иерархии классов сопоставляется с отдельной таблицей. При этом каждая таблица содержит столбцы только сопоставленного с ней класса и столбец идентификатора, необходимый для объединения таблиц. JTI в Doctrine ORM называется стратегией Class Table Inheritance.

// Базовый класс
class Animal {              // Соответствует таблица "animal" со столбцами `id` и `age`
    public ?int $id;
    public int $age;
}
// Подклассы
class Pet extends Animal {  // Соответствует таблица "pet" со столбцами `id` и `name`
    public string $name;
}
class Cat extends Pet {     // Соответствует таблица "cat" со столбцами `id` и `frags`
    public int $frags;
}
class Dog extends Pet {     // Соответствует таблица "dog" со столбцами `id` и `trainingLevel`
    public int $trainingLevel;
}
Схема ORM

Для конфигурирования наследования в схеме используется ключ Schema::PARENT, значением которого может родительский класс или его роль.

use Cycle\ORM\SchemaInterface as Schema;

$schema = new \Cycle\ORM\Schema([
    Animal::class => [
        Schema::ROLE        => 'animal',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'animal',
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'age'],
        Schema::TYPECAST    => ['id' => 'int', 'age' => 'int'],
    ],
    Pet::class => [
        Schema::ROLE        => 'pet',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'pet',
        Schema::PARENT      => Animal::class,               // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'name'],
        Schema::TYPECAST    => ['id' => 'int'],
    ],
    Cat::class => [
        Schema::ROLE        => 'cat',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'cat',
        Schema::PARENT      => 'pet',                       // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'frags'],
        Schema::TYPECAST    => ['id' => 'int', 'frags' => 'int'],
    ],
    Dog::class => [
        Schema::ROLE        => 'dog',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'dog',
        Schema::PARENT      => 'pet',                       // <=
        Schema::PRIMARY_KEY => 'id',
        Schema::COLUMNS     => ['id', 'trainingLevel'],
        Schema::TYPECAST    => ['id' => 'int', 'trainingLevel' => 'int'],
    ],
]);

Особенности JTI в Cycle ORM

Выгрузка определенного подкласса в иерархии приведёт к SQL-запросу, содержащему INNER JOIN ко всем таблицам в его пути наследования. Например, загружая данные для сущности Cat, ORM выполнит запрос вида

SELECT ... FROM cat INNER JOIN pet USING (id) INNER JOIN animal USING (id) ...

Если загружаемая сущность является базовым классом, то по умолчанию будут загружены все таблицы подклассов. Таким образом, загружая класс Pet, ORM выполнит запрос вида:

SELECT ...
FROM pet
INNER JOIN animal USING (id)
LEFT JOIN cat ON pet.id = cat.id
LEFT JOIN dog ON pet.id = dog.id
LEFT JOIN hamster ON pet.id = hamster.id
...

Чтобы отменить автоматическое присоединение таблиц подклассов, используйте метод loadSubclasses(false):

/** @var Pet[] */
$cat = (new \Cycle\ORM\Select($this->orm, Pet::class))
    ->loadSubclasses(false)->fetchAll();

При реализации такой формы наследования следует учесть, что первичный ключ базового класса должен проецироваться уникальным индексом во все таблицы наследуемых сущностей. При этом автоинкрементным он может быть только в таблице базового класса.
Для каждого класса в иерархии вы можете определить разные поля для первичного ключа, однако значение у них всё-равно будет общим.
Поведение по умолчанию, при котором первичный ключ подкласса стыкуется с первичным ключом родителя, можно изменить опцией SchemaInterface::PARENT_KEY.

Удаление сущности определённого подкласса приведёт к выполнению запроса на удаление записи только из таблицы, соответствующей подклассу сущности.

use \Cycle\ORM\Select;

$cat = (new Select($this->orm, Cat::class))->wherePK(42)->fetchOne();
(new \Cycle\ORM\Transaction())->delete($cat)->run();

// Удалится запись только из таблицы cat. В родительских таблицах запись останется:

$cat = (new Select($this->orm, Cat::class))->wherePK(42)->fetchOne(); // Null
$pet = (new Select($this->orm, Pet::class))->wherePK(42)->loadSubclasses(false)->fetchOne(); // Pet (id:42)
$base = (new Select($this->orm, Aimal::class))->wherePK(42)->loadSubclasses(false)->fetchOne(); // Animal (id:42)

Здесь ORM полагается на внешние ключи, которые приведут к удалению данных из таблиц ниже по иерархии наследования. В случае, если внешние ключи отсутствуют, необходимые команды на удаление следует поместить в маппер удаляемой сущности.

Связи в JTI

Вы можете использовать любые связи во всех классах иерархии.
Жадные связи подклассов и родительских классов всегда подгружаются автоматически.
Ленивые связи родительских классов можно подгрузить вручную точно так же, как если бы эти связи изначально были сконфигурированы для запрашиваемой роли:

$cat = (new \Cycle\ORM\Select($this->orm, Cat::class))
    ->load('thread_balls')    // связь класса Cat
    ->load('current_owner')   // связь класса Pet
    ->load('parents')         // связь класса Animal
    ->wherePK(42)->fetchOne();

Внимание! Не загружайте связи к JTI иерархии в одном запросе. Получившаяся комбинация LEFT и INNER запросов, скорее всего, приведёт к некорректному результату запроса.

$cat = (new \Cycle\ORM\Select($this->orm, Owner::class))
    ->load('pet', ['method' => Select::SINGLE_QUERY]) // <= pet - подкласс иерархии наследования
    ->wherePK(42)->fetchOne();

Single Table Inheritance

STI подразумевает использование одной таблицы для нескольких подклассов иерархии.
Таким образом, в одной общей таблице размещаются все атрибуты указанных подклассов.
Для определения принадлежности данных к классу используется столбец дискриминатора.

Если какой-то подкласс имеет атрибут, не являющийся общими для всех остальных классов таблицы, то сохранять его следует в столбце таблицы, имеющем какое-либо значение по умолчанию. В противном случае сохранение соседних классов в эту таблицу приведёт к ошибке.

Пример

// Базовый класс
class Pet extends Animal {
    public ?int $id;
    public string $name;        // Поле общее для всех классов
}
class Cat extends Pet {
    public int $frags;          // Уникальное поле класса Cat
}
class Dog extends Pet {
    public int $trainingLevel;  // Уникальное поле класса Dog
}
/* Таблица:
    id: int, primary
    _type: string               // Столбец дискриминатора
    name: string
    frags: int, nullable, default=null
    trainingLevel: int, nullable, default=null
 */
Схема ORM

Для перечисления классов одной таблицы в схеме используется ключ Schema::CHILDREN, значением которого является массив вида Значение дискриминатора => Роль или класс сущности.

Вы также можете указать название поля дискриминатора, используя опцию Schema::DISCRIMINATOR.

use Cycle\ORM\SchemaInterface as Schema;

$schema = new \Cycle\ORM\Schema([
    Pet::class => [
        Schema::ROLE        => 'role_pet',
        Schema::MAPPER      => Mapper::class,
        Schema::DATABASE    => 'default',
        Schema::TABLE       => 'pet',
        Schema::CHILDREN    => [                // Список подклассов
            'cat' => Cat::class,
            'dog' => 'role_dog',                // Вместо класса можно использовать имя роли
        ],
        Schema::DISCRIMINATOR => 'pet_type',    // Поле дискриминатора
        Schema::PRIMARY_KEY => 'id',
        // В схеме базового класса перечисляются все поля подклассов, а также поле дискриминатора:
        Schema::COLUMNS     => [
            'id',
            'pet_type' => 'type_column',        // Конфигурирование имени столбца в таблице
            'name',
            'frags',                            // Поле из класса Cat
            'trainingLevel'                     // Поле из класса Dog
        ],
        Schema::TYPECAST    => ['id' => 'int', 'frags' => 'int', 'trainingLevel' => 'int'],
    ],
    Cat::class => [
        Schema::ROLE => 'role_cat',
    ],
    Dog::class => [
        Schema::ROLE => 'role_dog',
    ],
]);

Из этой схемы следует:

  • Таблица pet используется для хранения сущностей базового класса Pet и его наследников: Cat и Dog.
  • Значение дискриминатора будет храниться в столбце pet_type.
  • При выборке сущности из таблицы в зависимости от значения поля pet_type (cat или dog) сущность будет класса Cat или Dog соответственно.
  • Если значение дискриминатора будет отличаться от cat или dog, то создастся сущность базового класса Pet.

Особенности STI:

  • Нельзя использовать classless сущности: у сущности всегда должен быть класс. Соответственно, мапперы stdMapper и ClasslessMapper с STI не совместимы.
  • Базовый класс может быть абстрактным. Но в этом случае вы должны гарантировать, что все значения столбца дискриминатора в таблице соответствуют своим подклассам.
  • Не общие для всех сущностей столбцы таблицы должны иметь значение по умолчанию.
  • Нет необходимости размещать или предварительно заполнять поле дискриминатора в сущности. ORM сама подставит нужное значение при сохранении в базу данных.
  • Запрос сущности определённого класса из общей таблицы не сопровождается фильтрующим условием по значению дискриминатора. В будущем это будет исправлено, а сейчас при необходимости следует добавлять выражение вида ->where('_type', '=', 'cat').

Связи в STI

Вы можете использовать любые связи в базовом классе. Они автоматически будут применены к подклассам.


Совмещая разные формы наследования, вы можете осуществлять разные стратегии.
Комбинируйте STI и JTI в пределах одной иерархии из соображения целесообразности, а Cycle ORM позаботится об остальном.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment