Consider a simple example of a class hierarchy:
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:
-
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.
-
Other solution is to allocate additional tables for the unique fields of each class. To get
Cat
entity, for example, we should JOIN tablescat
,animal
andpet
. 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.
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'],
],
]);
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.
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();
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.
// 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 ClassPet
and his child Classes:Cat
andDog
. - Discriminator value will be stored in column
pet_type
. - During selecting entity from a table, depending on the value of
per_type
column (cat
ordog
), ORM will instantiate entity of classCat
orDog
respectively. - If value of discriminator will differ from
cat
ordog
, the entity of Base Class will be instantiated.
- Can't use classless entities: entity must have class.
Therefore
stdMapper
andClasslessMapper
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')
.
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.