Skip to content

Instantly share code, notes, and snippets.

@roxblnfk
Last active October 21, 2022 23:18
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save roxblnfk/e86fa6c591921b19dd8b9afaa1afd7cf to your computer and use it in GitHub Desktop.
CYCLE ORM v2 SUMMARY [Ru]

Cycle ORM V2 (dev)

Gist посвящён общим изменениям в Cycle ORM 2.0 относительно первой версии.
Разработка ведётся в ветке 2.0.x-dev, по мере поступления обновлений гист будет дополняться.

Установка:

В composer.json установить директиву minimum-stability: "dev",
затем выполнить composer require cycle/orm "2.0.x-dev".
Рекомендуется также установить "prefer-stable": true.

Установка с пакетом yii-cycle

В composer.json установить директиву minimum-stability: "dev",
затем выполнить composer require yiisoft/yii-cycle "2.0.x-dev".

BC breaks and migration to v2.0

При миграции с Cycle ORM v1 на v2, прежде всего, обновите ORM v1 до последней версии, а затем устраните все устаревшие (помеченные @deprecated) константы, методы и классы.

Пакет Database

Пакет spiral/database переезжает в cycle/database. Его развитие будет продолжено в составе Cycle.

Cycle ORM v2.0 использует cycle/database. При миграции все классы Spiral\Database\* следует заменить на Cycle\Database\*.

Подключения к базам данных конфигурируются с использованием DTO

Заваисимость от Doctrine Collection

doctrine/collections убрана из секции require в composer.json.
Если вы используете или собираетесь использовать эти коллекции, то следует установить их отдельно, а в конфигурацию Factory добавить DoctrineCollectionFactory.

Подробнее про пользовательские коллекции

Constrain => Scope

Всё, что было связано с Constrain в ORM, заменено на Scope:

  • константа SchemaInterface::CONSTRAIN, помеченная в ORM v1 устаревшей, удалена;
  • опция загрузчика 'constrain' => 'scope'
  • Select::setConstrain() => Select::setScope()
  • ConstrainInterface => ScopeInterface
  • прочая внутренняя кухня и тесты

Новые мапперы

Набор переменных, методов и их сигнатур в мапперах поменялся.
Детальнее в статьях:

Кроме того, по типу создаваемых сущностей, самих мапперов стало несколько.
Подробнее в статье Proxy Entities and Custom Collections.

Атрибуты и аннотации

Гист об атрибутах в Cycle ORM v1.

Установить пакет аннотаций и атрибутов для Cycle ORM v2 можно командой composer:

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

Композитные ключи

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

Первичный ключ сущности можно указать несколькими способами:

  • Опцией primary атрибута Table:
    use Cycle\Annotated\Annotation\Entity;
    use Cycle\Annotated\Annotation\Table;
    
    #[Entity()]
    #[Table(primary: ['id1', 'id2'])]
    class User {}  ```
  • Отдельным атрибутом PrimaryKey:
    use Cycle\Annotated\Annotation\Entity;
    use Cycle\Annotated\Annotation\Table\PrimaryKey;
    
    #[Entity()]
    #[PrimaryKey(['id1', 'id2'])]
    class User {}  ```
  • Опцией primary атрибута Column
    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 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 = [];
}

Переименования параметров

  • В атрибуте Entity параметр constrain переименован в scope.
  • В атрибуте ManyToMany поправлена опечатка: though переименован в through.

Связь Many To Many

Две встречные связи Many To Many с параметром createIndex = true больше не создают два уникальных индекса в перекрёстной таблице. Вместо этого создаётся один уникальный индекс и один неуникальный.

Кстати, с учётом того, что ORM v2 поддерживает сложные ключи, больше нет необходимости в отдельном поле id у Pivot сущности: в качестве первичного ключа можно использовать поля, ссылающиеся на идентификаторы связываемых сущностей.

Композитные ключи

Schema

В схеме PK (primary key) и связи теперь поддерживают композитные (множественные) ключи. Задаются они неассоциативными массивами:

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'],
        ],
      ]
    ]
  ],
];

Это повлекло слом обратной совместимости на уровне внутреннего API и около того.

Mapper

Немного изменились внутренности базового маппера. Скалярные свойства primaryKey и primaryColumn были удалены в пользу свойств-массивов primaryKeys и primaryColumns соответственно. Метод nextPrimaryKey() должен возвращать ассоциативный массив ключей.
В мапперах теперь следует учитывать плюрализм ключей.

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

Свойства и параметры многих методов, подразмевающие хранение и передачу ключей (как PK так и связей), переведены на массивы. Эти классы могут встречаться в маппере.

Select

wherePK(): для композитных ключей PK передаётся массивом. Параметр теперь является variadic на тот случай, если нужно передать несколько первичных ключей (раньше вы могли это сделать с помощью класса Parameter).

# Композитные ключи:
$select->wherePK([1, 1], [1, 2], [1, 3]);

# Обычные ключи:
$select->wherePK(1, 2, 3);
# или старый способ:
$select->wherePK(new Parameter([1, 2, 3]));

Пока не поддерживаются ассоциативные массивы (пример: $select->wherePK(['key1' => 1, 'key2' => 1]);), поэтому следует соблюдать порядок передаваемых значений (порядок должен быть такой же, как в схеме).

DTO конфиги в Database

Настройки подключения к базам данных в cycle/database раньше определялись массивами, теперь определяются объектами.

Рассмотрим пример:

$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
        ],
    ],
];

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

$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,
        ),
    ],
];

Благодаря возможности PHP 8 передавать именованные аргументам, IDE подскажет возможные параметры и их типы.

В зависимости от желаемого способа конфигурирования соединения с БД, может быть выбран соответствующий конфиг-класс. Например, одни и те же параметры для Postgres могут быть переданы в виде DSN строки или раздельными параметрами:

// DSN
new \Cycle\Database\Config\Postgres\DsnConnectionConfig(
    dsn:'pgsql:host=127.0.0.1;port=15432;dbname=spiral',
    user: 'postgres',
    password: 'postgres',
);
// Раздельные параметры
new \Cycle\Database\Config\Postgres\TcpConnectionConfig(
    database: 'spiral',
    host: '127.0.0.1',
    port: 15432,
    user: 'postgres',
    password: 'postgres',
);

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 позаботится об остальном.

Версия PHP и типизация

Минимальная версия PHP теперь 8.0.
Свойства классов и сигнатуры методов типизированы более строго.

У некоторых методов внешнего API уточнены типы возвращаемых значений.
Это BC break изменение, поэтому убедитесь в том, что в имплементациях интерфейсов указаны такие же или иные типы согласно принципу LSP.

Например, \Cycle\ORM\RepositoryInterface в методах findByPK() и findOne() выставлен возвращаемый тип ?object. Если в вашем коде переопределяется один из этих методов, то возвращаемый тип должен быть ?object или более точный (например ?User).

Proxy Entities and Custom Collections

Proxy Entity

Рассмотрим пример. Есть у нас сущность User, которая связана с другими сущностями связями HasOne и HasMany:

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

Когда мы загружаем сущность User кодом $user = (new Select($this->orm, User::class))->fetchOne();, который не подразумевает жадную загрузку связанных сущностей, то получаем сущность User, у которой связи на другие сущности являются ссылками (объекты класса ReferenceInterface).

В Cycle ORM v1 пользователи сталкивались с проблемами, когда эти ссылки надо было раскрывать. Да, иногда целесообразнее подгрузить связь одной сущности из большой коллекции, чем предзагружать связи для всей коллекции.
С этой задачей мог помочь наш отдельный пакет cycle/proxy-factory, задача которого подменять Reference на прокси-объект. При обращении к такому объекту связь автоматически подгружается:

$email = $user->profile->email; // при обращении к свойству profile прокси автоматически делает запрос в базу

Однако, в случае nullable One to One отношения мы не можем использовать такой код:

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

Ведь при отсутствии профиля в базе, прокси $user->profile не сможет превратить себя в null.

Проблемы были и с типизацией: в классе User не получится установить свойство profile с типом ?Profile, т.к. ORM без жадной загрузки будет пытаться записать туда ReferenceInterface.

В Cycle ORM v2 мы кое-что изменили. Теперь все сущности по умолчанию создаются как прокси.
Плюсы, которые мы получаем при этом:

  • Пользователь в привычном использовании не столкнётся с ReferenceInterface.
  • Типизация работает:
    class User {
        public iterable $posts;
        private ?Profile $profile;
        public function getProfile(): Profile
        {
            if ($this->profile === null) {
                $this->profile = new Profile();
            }
            return $this->profile;
        }
    }
  • Сохранили удобство использования ссылок для тех, кто ими пользовался:
    /** @var \Cycle\ORM\ORMInterface $orm */
    
    // Создаём прокси сущности User
    $user = $orm->make(User::class, ['name' => 'John']);
    
    // Мы знаем id группы, но загружать её не хотим. Нам этого достаточно, чтобы заполнить связь User>(BelongsTo)>Group
    $user->group = new \Cycle\ORM\Reference\Reference('user_group', ['id' => 1]);
    
    (new \Cycle\ORM\Transaction($orm))->persist($user)->run();
    
    $group = $user->group; // при желании мы можем подгрузить группу из кучи или БД, используя наш Reference
    Для получения сырых данных сущности пользуйтесь маппером: $rawData = $mapper()

Использование

Сами по себе правила создания сущностей определяются их мапперами. Вы можете сами задать, какие сущности будут создаваться как прокси, а какие — нет.

Мапперы из коробки:

  • \Cycle\ORM\Mapper\Mapper - генерирует прокси для классов сущностей.
  • \Cycle\ORM\Mapper\PromiseMapper - работает напрямую с классом сущности. Записывает объекты класса \Cycle\ORM\Reference\Promise в незагруженные связи.
  • \Cycle\ORM\Mapper\StdMapper - для работы с сущностями без класса. Генерирует объекты stdClass с объектами \Cycle\ORM\Reference\Promise на незагруженных связях.
  • \Cycle\ORM\Mapper\ClasslessMapper - для работы с сущностями без класса. Генерирует прокси.

Чтобы пользоваться прокси-сущностями, вам нужно соблюдать несколько простых правил:

  • Классы сущностей не должны быть финальными.
    Класс прокси расширяет класс сущности, и мы не хотели бы использовать для этого хаки.
  • Не используйте в приложении код вроде этого: get_class($entity) === User::class. Используйте $entity instanceof User.
  • Пишите код сущности без учёта того, что она может стать Proxy объектом.
    Пользуйтесь типизацией и приватными полями.
    Даже при прямом обращении к полю $this->profile связь будет раскрыта и вы не получите объект ReferenceInterface.

Сейчас мы выпускаем сырую версию прокси-сущностей. Поэтому имеются некоторые временные ограничения:

  • Старайтесь избегать использования приватных полей в сущностях.
    Используемый гидратор их не заполняет ¯\_(ツ)_/¯
  • Не пишите магические методы __get() и __set() в проксируемых сущностях. Они будут переопределены в прокси-объекте.

Custom Collections

Добавлена поддержка пользовательских коллекций для связей HasMany и ManyToMany.

Пользовательские коллекции можно настраивать индивидуально для каждой связи, указывая псевдонимы и интерфейсы:

use Cycle\ORM\Relation;
$schema = [
  User::class => [
    //...
    Schema::RELATIONS   => [
      'posts' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Post::class,
        Relation::COLLECTION_TYPE => null, // <= Используется коллекция по умолчанию
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'comments' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => 'doctrine', // <= Сопоставление по псевдониму `doctrine`
        Relation::SCHEMA => [ /*...*/ ],
      ],
      'tokens' => [
        Relation::TYPE => Relation::HAS_MANY,
        Relation::TARGET => Token::class,
        Relation::COLLECTION_TYPE => \Doctrine\Common\Collections\Collection::class, // <= Сопоставление по интерфейсу
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ],
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => \App\CommentsCollection::class, // <= Сопоставление по классу
                                                                     //    расширяемой коллекции
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

Настраиваются псевдонимы и интерфейсы в объекте класса \Cycle\ORM\Factory, который передаётся в конструктор ORM.

$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    // <= Фабрика коллекций по умолчанию
    ))
        ->withCollectionFactory(
            'doctrine',     // <= Псевдоним, который можно использовать в схеме
             $doctrineFactory,
              \Doctrine\Common\Collections\Collection::class // <= Интерфейс коллекций, которые может создавать фабрика
        )
        // Для работы фабрики Illuminate коллекций необходимо установить пакет `illuminate/collections`
        ->withCollectionFactory('illuminate', $illuminateFactory, \Illuminate\Support\Collection::class)
);

Интерфейс коллекции используется для тех случаев, когда вы расширяете коллекции под свои нужды.

// Расширяем коллекцию
class CommentCollection extends \Doctrine\Common\Collections\ArrayCollection {
    public function filterActive(): self { /* ... */ }
    public function filterHidden(): self { /* ... */ }
}

// Указываем в схеме
$schema = [
  Post::class => [
    //...
    Schema::RELATIONS   => [
      'comments' => [
        Relation::TYPE   => Relation::HAS_MANY,
        Relation::TARGET => Comment::class,
        Relation::COLLECTION_TYPE => CommentCollection::class,  // <=
        Relation::SCHEMA => [ /*...*/ ],
      ]
    ]
  ]
];

// Пользуемся
$user = (new Select($this->orm, User::class))->load('comments')->fetchOne();

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

Важное отличие связи Many to Many от Has Many заключается в том, в ней участвуют Pivot'ы — промежуточные сущности из кросс-таблицы.

Связь Many to Many переписана таким образом, что теперь нет необходимости таскать pivot'ы в коллекции сущности. Вы можете использовать даже массивы. Однако, если возникнет необходимость в работе с pivot'ами, ваша фабрика коллекций должна будет произвести коллекцию, реализующую интерфейс PivotedCollectionInterface. Пример такой фабрики — DoctrineCollectionFactory.

Transaction persist

Переписан алгоритм сохранения сущностей. Рекурсия развёрнута в очередь.
Команды DB облегчены и не участвуют в механизме подписки на изменения полей (forward).
Под новую логику переработаны связи, их интерфейсы и карта связей.

Пока это не дало буста к скорости, однако большие графы сохраняются с ощутимо меньшим потреблением памяти.

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

Маппер и команды

Удалены интерфйсы ContextCarrierInterface и ProducerInterface, методы маппера queueCreate и queueUpdate возвращают более общий CommandInterface.

Удалены команды Nil, Condition, ContextSequence, Split.
Если вы реалзиуете свою команду, поддерживающую rollback или complete, то необходимо добавить соответствующий методу интерфейс. Иначе команда после выполнения не задержится в транзакции.

Код пользовательских мапперов упрощен:

ORMInterface больше не содержит методов генерирования команд на сохранение или удаление сущности. Это решение ещё будет переосмыслено.

Typecast

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

Кроме того, типизация на первичном ключе не работала в случае значений, автогенерируемых на стороне БД: lastInsertID просто не приводился к нужному типу после вставки записи. Это могло приводить к проблемам в типизированном коде.

В целом даже не очень понятно, как подменить "тайпкастер" и как вообще повлиять на процесс приведения типов.

В ORM v2 мы постарались доработать эти вопросы. К слову, статья Column Wrappers будет по-прежнему актуальна и для v2.

Что нового?

MapperInterface::cast()

Теперь ORM, для конвертирования сырых данных в подготовленные (приведённые к своим типам), в первую очередь использует метод cast() в маппере сущности.

Для типизации обычных полей сущности маппер использует персональный для роли TypecastInterface объект. Данные связей типизируются самими связями через мапперы связанных сущностей.

В пользовательском маппере вы свободны переопределить метод cast() и направить процесс типизации в иное русло.

Схема ORM и TypecastInterface

При конфигурировании тайпкаста аннотациями, настройки переносятся в схему сущности в параметр SchemaInterface::TYPECAST в виде ассоциативного массива field => type, где type может быть обозначением одного из базовых типов (int, bool, float, datetime) или коллейблом.

Для исполнения конфигурации, определённой в SchemaInterface::TYPECAST, по умолчанию будет использоваться объект класса \Cycle\ORM\Parser\Typecast. Однако, вы можете подменить реализацию, указав класс или псевдоним в параметре SchemaInterface::TYPECAST_HANDLER. В этом случае ORM запросит пользовательскую реализацию из контейнера, подразумевая, что полученный результат является объектом класса \Cycle\ORM\Parser\TypecastInterface.

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

В качестве примера, заготовка для пользовательского класса тайпкастинга, могла бы выглядеть так:

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;
    }
}

Комопзиция тайпкастов

В SchemaInterface::TYPECAST_HANDLER вы можете указать список тайпкастов.
В этом случае каждый элемент списка по очереди получит порцию пользовательских правил, отфильтрованную предыдущим тайпкастом.

Типизация в Select::fetchData() и ORM::make()

В результате переработки тайпкаста процесс приведения типов был вынесен из парсинга сырых данных БД на более поздний шаг (в ORM::make()). Благодаря этому метод Select::fetchData() вернёт сырые данные, если передать аргумент typecast : false.

В сигнатуре метода ORM::make() тоже появился параметр typecast, установленный в false по умолчанию. Если вы передаёте в ORM::make() сырые данные, то передайте аргумент typecast : false.

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