Skip to content

Instantly share code, notes, and snippets.

@roxblnfk
Last active Nov 18, 2021
Embed
What would you like to do?
CYCLE ORM v2 SUMMARY [En]

Cycle ORM V2 (dev)

Gist is dedicated to general changes in Cycle ORM 2.0 in relation to the first version.
It is being developed in the 2.0.x-dev branch and will be updated as updates are made.

Installation:

In the composer.json set the directive minimum-stability: "dev",
then run composer require cycle/orm "2.0.x-dev" (or add by hand).

Installing with the yii-cycle package:

In the composer.json set the directive minimum-stability: "dev",
then run composer require yiisoft/yii-cycle "2.0.x-dev".

BC breaks and migration to v2.0

When migrating from Cycle ORM v1 to v2, first of all upgrade ORM v1 to the latest version, and then remove all obsolete (marked @deprecated) constants, methods, and classes.

Database package

The package spiral/database moves to cycle/database. Its development will continue as part of Cycle.

Cycle ORM v2.0 uses cycle/database. All Spiral\Database\* classes must be replaced by Cycle\Database\* during migration.

Database connections configured using DTOs

Doctrine Collection

The doctrine/collections has been removed from the require section in composer.json. If you use or intend to use these collections, install them separately and add DoctrineCollectionFactory to the Factory configuration.

More about custom collections

Constrain => Scope

Everything associated with Constrain in the ORM is replaced by Scope:

  • the SchemaInterface::CONSTRAIN constant, marked obsolete in ORM v1, is removed;
  • Loader option 'constrain' => 'scope'
  • Select::setConstrain() => Select::setScope()
  • ConstrainInterface => ScopeInterface
  • Other internal kitchen and tests

New mappers

The set of variables, methods and their signatures in mappers has changed. More details in the articles:

In addition, by the type of created entities, there are several mappers themselves.
Read more in Proxy Entities and Custom Collections.

Attributes and annotations

You can install the annotations and attributes package for Cycle ORM v2 with the composer command:

composer require cycle/annotated "2.0.x-dev"

Composite keys.

Composite keys can now be specified in attributes.

The primary key of an entity can be specified in several ways:

  • The primary option of the Table attribute:
    use Cycle\Annotated\Annotation\Entity;
    use Cycle\Annotated\Annotation\Table;
    
    #[Entity()]
    #[Table(primary: ['id1', 'id2'])
    class User {}
  • By a separate attribute PrimaryKey:
    use Cycle\Annotated\Annotation\Entity;
    use Cycle\Annotated\Annotation\Table\PrimaryKey;
    
    #[Entity()]
    #[PrimaryKey(['id1', 'id2'])]
    class User {}
  • The primary option of the Column attribute
    use Cycle\Annotated\Annotation\Column;
    use Cycle\Annotated\Annotation\Entity;
    
    #[Entity()]
    class Pivot {
        #[Column(type: 'bigInteger', primary: true)]
        public ?int $post_id = null;
        #[Column(type: 'bigInteger', primary: true)]
        public ?int $comment_id = null;
    }

Use arrays to set up composite keys in relations:

use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;

#[Entity()]
class User {
    #[Column(type: 'bigInteger', primary: true)]
    public ?int $field1 = null;
    #[Column(type: 'bigInteger', primary: true)]
    public ?int $field2 = null;

    #[HasMany(target: 'comment', innerKey: ['field1', 'field2'], outerKey: ['field1', 'field2'])
    public array $comments = [];
}

Parameter renames

  • In the Entity attribute, the constrain parameter is renamed to scope.
  • In the attribute ManyToMany a typo is corrected: though is renamed to through.

'Many To Many' relation

The two counter links Many To Many with parameter createIndex = true no longer create two unique indexes in the cross table. Instead, one unique index and one non-unique index are created.

Given that ORM v2 supports complex keys, there is no longer a need for a separate id field at the Pivot entity: as a primary key, you can use fields that refer to the identifiers of linked entities.

Composite keys

Schema.

In the PK (primary key) schema and links now support composite (multiple) keys. They are defined as non-associative arrays:

return [
  User::class => [
    // ...
    Schema::PRIMARY_KEY => ['key1', 'key2'],                          // <===
    Schema::COLUMNS     => [
      'key1' => 'field1',
      'key2' => 'field2',
      // ...
    ],
    Schema::RELATIONS   => [
      'posts' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Post::class,
        Relation::SCHEMA => [
          Relation::CASCADE   => true,
          Relation::INNER_KEY => ['key1', 'key2'],                    // <===
          Relation::OUTER_KEY => ['parent_key1', 'parent_key2'],      // <===
          Relation::ORDER_BY  => ['key1' => 'asc', 'key2' => 'asc'],
        ],
      ]
    ]
  ],
];

This entailed breaking backward compatibility at the internal API level and so on.

Mapper.

The internals of the base mapper have changed a bit. The scalar properties primaryKey and primaryColumn have been removed In favor of the array properties primaryKeys and primaryColumns respectively. The nextPrimaryKey() method should return an associative key array.
In mappers, key plurality should now be considered.

Database Command (Insert / Update / Delete), State and Node

The properties and parameters of many methods that store and pass keys (both PKs and links) have been moved to arrays. These classes may be found in the mapper.

Select

wherePK(): for composite keys, PK is passed by array. The parameter is now variadic in case you want to pass multiple PKs (Previously, you could do this with a Parameter class).

# Composite keys:
$select->wherePK([1, 1], [1, 2], [1, 3]);

# Ordinary keys:
$select->wherePK(1, 2, 3);
# or the old way:
$select->wherePK(new Parameter([1, 2, 3]))

As long as associative arrays are not supported (example: $select->wherePK(['key1' => 1, 'key2' => 1]);), so you should keep the order of the passed values (the order must be the same as in the schema).

DTO configs for Database

Database connection settings in cycle/database used to be defined by arrays, but now they are defined by objects.

Let's look at an example:

$config = [
    'default' => 'default',
    'aliases' => [],
    'databases' => [
        'default' => ['connection' => 'sqlite'],
    ],
    'connections' => [
        'sqlite' => [
            'driver' => \Cycle\Database\Driver\SQLite\SQLiteDriver::class,
            'connection' => 'sqlite:runtime/database.db',
            'username' => '',
            'password' => '',
        ],
        'postgres'  => [
            'driver'     => Database\Driver\Postgres\PostgresDriver::class,
            'conn'       => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral',
            'user'       => 'postgres',
            'pass'       => 'postgres',
            'queryCache' => 100
        ],
    ],
];

This configuration will now look as follows:

$config = [
    'default' => 'default',
    'aliases' => [],
    'databases' => [
        'default' => ['connection' => 'sqlite'],
    ],
    'connections' => [
        'sqlite' => new \Cycle\Database\Config\SQLiteDriverConfig(
            connection: new \Cycle\Database\Config\SQLite\FileConnectionConfig(
                database: 'runtime/database.db'
            ),
        ),
        'postgres' => new \Cycle\Database\Config\PostgresDriverConfig(
            connection: new Database\Config\Postgres\DsnConnectionConfig(
                dsn:'pgsql:host=127.0.0.1;port=15432;dbname=spiral',
                user: 'postgres',
                password: 'postgres',
            ),
            schema: 'public',
            queryCache: true,
        ),
    ],
];

Thanks to PHP 8's ability to pass named arguments, the IDE will suggest possible parameters and their types.

Depending on the desired way of configuring the database connection, an appropriate config class can be selected. For example, the same parameters for Postgres can be passed as a DSN string or separate parameters:

// DSN
new \Cycle\Database\Config\Postgres\DsnConnectionConfig(
    dsn:'pgsql:host=127.0.0.1;port=15432;dbname=spiral',
    user: 'postgres',
    password: 'postgres',
);
// Separate parameters
new \Cycle\Database\Config\Postgres\TcpConnectionConfig(
    database: 'spiral',
    host: '127.0.0.1',
    port: 15432,
    user: 'postgres',
    password: 'postgres',
);

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.

PHP version and typing

The minimum version of PHP is now 8.0.
Class properties and method signatures are more strictly typed.

Some external API methods have clarified their return types.
This is a BC break change, so make sure to specify the same or different types according to the LSP principle in the implementations of the interfaces.

For example, \Cycle\ORM\RepositoryInterface methods findByPK() and findOne() have the return type ? object specified. If your code overrides one of these methods, the return type should be ?object or a more precise one (e.g. ?User).

Proxy Entity

Let's look at an example. We have an entity User, which is related to other entities by the relation HasOne and HasMany:

User {
    id: int
    name: string
    profile: ?Profile (HasOne, nullable, lazy load)
    posts: collection (HasMany, lazy load)
}

When we load the entity User using code $user = (new Select($this->orm, User::class))->fetchOne(); (without eager loading of related entities), then we get the User entity, in which relations to other entities are references (objects of the ReferenceInterface class).

In Cycle ORM v1, users faced issues when these references had to be resolved. Yes, sometimes it is more expedient to load the relation of one entity from a large collection than to preload relations for the entire collection.
Our separate package cycle/proxy-factory could help with this issue, the task of which is to replace Reference with a proxy object. When accessing such a proxy object, the reference is automatically resolved:

$email = $user->profile->email; // when accessing the profile property, the proxy object automatically
                                // makes a request to the database

However, in the case of a nullable One to One relation, we cannot use this code:

$userHasProfile = $user->profile === null;

Indeed, the proxy $user->profile will not be able to rewrite itself into null if the required profile does not exist in the DB.

There were also problems with typing: in the User class it is not possible to set the profile property with the ?Profile type, because ORM without eager loading tries to write ReferenceInterface there.

We have changed a few things in Cycle ORM v2. Now all entities are created as proxies by default.

The advantages that we get by doing it:

  • The user in the usual use will not encounter the ReferenceInterface.
  • Property typing works:
    class User {
        public iterable $posts;
        private ?Profile $profile;
        public function getProfile(): Profile
        {
            if ($this->profile === null) {
                $this->profile = new Profile();
            }
            return $this->profile;
        }
    }
  • We have preserved the usability of references for those who used them:
    /** @var \Cycle\ORM\ORMInterface $orm */
    
    // Create a proxy for the User entity
    $user = $orm->make(User::class, ['name' => 'John']);
    
    // We know the group id, but we don't want to load it from DB.
    // This is enough for us to fill in the User>(belongsTo)>Group relation
    $user->group = new \Cycle\ORM\Reference\Reference('user_group', ['id' => 1]);
    
    (new \Cycle\ORM\Transaction($orm))->persist($user)->run();
    
    $group = $user->group; // if desired, we can load a group from the heap or database using our Reference
    To get raw entity data, use the mapper: $rawData = $mapper()

Usage

The rules for creating entities are determined by their mappers. You can set which entities will be created as proxies and which ones will not.

Mappers from the box:

  • \Cycle\ORM\Mapper\Mapper - generates proxies for entity classes.
  • \Cycle\ORM\Mapper\PromiseMapper - works directly with the entity class. It also writes objects of the \Cycle\ORM\Reference\Promise class to unloaded relation properties.
  • \Cycle\ORM\Mapper\StdMapper - for working with classless entities. Generates stdClass objects with \Cycle\ORM\Reference\Promise objects on unloaded relation properties.
  • \Cycle\ORM\Mapper\ClasslessMapper - for working with classless entities. Generates proxy entities.

To use proxy entities, you need to follow a few simple rules:

  • Entity classes should not be final.
    The proxy class extends the entity class, and we would not like to use hacks for this.
  • Do not use code like this in the application: get_class($entity) === User::class. Use $entity instanceof User.
  • Write the code of the entity without taking into account the fact that it can become a proxy object.
    Use typing and private fields.
    Even if you directly access the $this->profile field, the relation will be loaded and you will not get a ReferenceInterface object.

Custom Collections

We've added support for custom collections for the hasMany and ManyToMany relations.

Custom collections can be configured individually for each relation by specifying aliases and interfaces (base classes):

use Cycle\ORM\Relation;
$schema = [
  User::class => [
    //...
    Schema::RELATIONS   => [
      'posts' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Post::class,
        Relation::COLLECTION_TYPE => null, // <= The default collection is used
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'comments' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => 'doctrine', // <= Matching by the alias `doctrine`
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'tokens' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Token::class,
        Relation::COLLECTION_TYPE => \Doctrine\Common\Collections\Collection::class, // <= Matching by the class
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ],
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => \App\CommentsCollection::class,    // <= Mapping by the class of
                                                                        //    an extendable collection
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

Aliases and interfaces can be configured in the \Cycle\ORM\Factory object, which is passed to the ORM class constructor.

$arrayFactory = new \Cycle\ORM\Collection\ArrayCollectionFactory();
$doctrineFactory = new \Cycle\ORM\Collection\DoctrineCollectionFactory();
$illuminateFactory = new \Cycle\ORM\Collection\IlluminateCollectionFactory();
$orm = new \Cycle\ORM\ORM(
    (new \Cycle\ORM\Factory(
        $dbal,
        null,
        null,
        $arrayFactory    // <= Default Collection Factory
    ))
        ->withCollectionFactory(
            'doctrine',     // <= An alias that can be used in the DB Schema
             $doctrineFactory,
              \Doctrine\Common\Collections\Collection::class // <= Interface for collections that the factory can create
        )
        // For the Illuminate Collections factory to work, you need to install the `illuminate/collections` package
        ->withCollectionFactory('illuminate', $illuminateFactory, \Illuminate\Support\Collection::class)
);

The collection interface is used for those cases when you extend collections to suit your needs.

// Extend the collection
class CommentCollection extends \Doctrine\Common\Collections\ArrayCollection {
    public function filterActive(): self { /* ... */ }
    public function filterHidden(): self { /* ... */ }
}

// Specify it in the DB Schema
$schema = [
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => CommentCollection::class,  // <=
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

// Use it
$user = (new Select($this->orm, User::class))->load('comments')->fetchOne();

/** @var CommentCollection $comments */
$comments = $user->comments->filterActive()->filterHidden();

An important difference between the 'Many to Many' and 'Has Many' relations is that it involves Pivots — intermediate entities from the cross-table.

The 'Many to Many' relation has been rewritten in such a way that now there is no need to collect pivots in the entity collection. You can even use arrays. However, if there is a need to work with pivots, your collection factory will have to produce a collection that implements the PivotedCollectionInterface interface. An example of such a factory is DoctrineCollectionFactory.

Transaction persist

Rewritten algorithm for entity persistence. Recursion is deployed to the queue. DB commands are lightened and do not participate in the mechanism of signing on changes of fields (forward). Connections, their interfaces and the connection map have been redesigned for the new logic.

It has not given a speed boost yet, but large graphs are saved with significantly less memory consumption.

Now some cases of speculative entity persistence don't work. Parent relationships are checked for each entity, even if they are not explicitly specified by BelongsTo or RefersTo relationships.

Mapper and commands.

Removed interfaces ContextCarrierInterface and ProducerInterface, the mapper methods queueCreate and queueUpdate return the more general CommandInterface.

Removed commands Nil, Condition, ContextSequence, Split.
If you implement your own command that supports rollback or complete, you have to add an interface corresponding to the method. Otherwise, the command will not stay in the transaction after execution.

The code for custom mappers is simplified:

The ORMInterface no longer contains methods to generate commands to save or delete the entity. This solution is yet to be rethought.

Typecast

In ORM v1 it is already possible to specify types for individual fields, but the topic of custom types was not fully disclosed.

Besides, typing on primary key didn't work in case of auto-generated values on database side: lastInsertID simply wasn't converted to the right type after inserting record. This could lead to problems in typed code.

In general, it's not even very clear how to substitute "typecaster" and how to affect the typecasting process at all.

In ORM v2 we have tried to improve these issues. By the way, the Column Wrappers article will still be relevant for v2.

MapperInterface::cast()

Now the ORM, to convert raw data into prepared data ( casted to its types), first uses the cast() method in the entity mapper.

The entity mapper uses an entity-specific TypecastInterface object to type the entity fields. Relation data is typed by the relations themselves through the associated entity mappers.

In the custom mapper you are free to override the cast() method to change the typing process.

ORM Schema and TypecastInterface

If you use annotations to configure a typecast, the settings are written to the entity schema in the SchemaInterface::TYPECAST parameter as an associative array field => type, where type can be one of the base types (int, bool, float, datetime) or a callable.

To perform the configuration defined in SchemaInterface::TYPECAST, the instance of \Cycle\ORM\\Parser\Typecast will be used by default. However, you can replace the implementation by specifying a class or alias in the SchemaInterface::TYPECAST_HANDLER parameter. In this case, the ORM will get the custom implementation from the container, implying that the result is an instance of class \Cycle\ORM\Parser\TypecastInterface.

The TypecastInterface objects are created once per role and cached in the mapper and EntityRegistry.

As an example, a draft for a custom typecast class might look like this:

final class AutoTypecast implements \Cycle\ORM\Parser\TypecastInterface
{
    /** @var array<string, callable> */
    private array $rules = [];

    public function __construct(
        \Cycle\ORM\ORMInterface $orm,
        string $role
    ) {
        $class = $orm->getSchema()->define($role, \Cycle\ORM\SchemaInterface::ENTITY);
        // Some magic with reflection to prepare callables
        // ...
    }

    public function setRules(array $values): array
    {
        return $values;
    }

    public function cast(array $values): array
    {
        return $this->applyRules($values);
    }

    private function applyRules(array $values): array
    {
        // Use prepared callables
        foreach (array_intersect_key($this->rules, $values) as $field => $rule) {
            $values[$field] = $rule($values[$field]);
        }
        return $values;
    }
}

Cmposite typeecast

In SchemaInterface::TYPECAST_HANDLER you can specify a list of tipecast definitions. In this case, each item in the list will in turn get a portion of the custom rules filtered out by the previous typecast implementation.

Typecast in Select::fetchData() and ORM::make()

As a result of the redesign, the typecasting process has been moved out of the raw database parsing to a later step (in ORM::make()). This means the Select::fetchData() method return raw data if you pass the typecast: false argument.

The ORM::make() method signature also has the typecast parameter set to false by default. If you pass raw data to the ORM::make(), you should pass the typecast: false argument.

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