You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
При миграции с Cycle ORM v1 на v2, прежде всего, обновите ORM v1 до последней версии,
а затем устраните все устаревшие (помеченные @deprecated) константы, методы и классы.
Пакет Database
Пакет spiral/database переезжает в
cycle/database.
Его развитие будет продолжено в составе Cycle.
Cycle ORM v2.0 использует cycle/database.
При миграции все классы Spiral\Database\* следует заменить на Cycle\Database\*.
doctrine/collections убрана из секции require в composer.json.
Если вы используете или собираетесь использовать эти коллекции, то следует установить их отдельно, а в конфигурацию Factory добавить DoctrineCollectionFactory.
Установить пакет аннотаций и атрибутов для Cycle ORM v2 можно командой composer:
composer require cycle/annotated "2.0.x-dev"
Композитные ключи
В атрибутах теперь можно указывать композитные ключи.
Первичный ключ сущности можно указать несколькими способами:
Опцией primary атрибута Table:
useCycle\Annotated\Annotation\Entity;
useCycle\Annotated\Annotation\Table;
#[Entity()]
#[Table(primary: ['id1', 'id2'])]
class User {} ```
Отдельным атрибутом PrimaryKey:
useCycle\Annotated\Annotation\Entity;
useCycle\Annotated\Annotation\Table\PrimaryKey;
#[Entity()]
#[PrimaryKey(['id1', 'id2'])]
class User {} ```
Опцией primary атрибута Column
useCycle\Annotated\Annotation\Column;
useCycle\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;
}
Для настройки составных ключей в связях используйте массивы:
useCycle\Annotated\Annotation\Column;
useCycle\Annotated\Annotation\Entity;
useCycle\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'])]
publicarray$comments = [];
}
Переименования параметров
В атрибуте Entity параметр constrain переименован в scope.
В атрибуте ManyToMany поправлена опечатка: though переименован в through.
Связь Many To Many
Две встречные связи Many To Many с параметром createIndex = true больше не создают два уникальных индекса
в перекрёстной таблице. Вместо этого создаётся один уникальный индекс и один неуникальный.
Кстати, с учётом того, что ORM v2 поддерживает сложные ключи, больше нет необходимости в отдельном поле id у Pivot сущности:
в качестве первичного ключа можно использовать поля, ссылающиеся на идентификаторы связываемых сущностей.
Это повлекло слом обратной совместимости на уровне внутреннего 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(newParameter([1, 2, 3]));
Пока не поддерживаются ассоциативные массивы (пример: $select->wherePK(['key1' => 1, 'key2' => 1]);), поэтому следует соблюдать
порядок передаваемых значений (порядок должен быть такой же, как в схеме).
Благодаря возможности PHP 8 передавать именованные аргументам, IDE подскажет возможные параметры и их типы.
В зависимости от желаемого способа конфигурирования соединения с БД, может быть выбран соответствующий конфиг-класс.
Например, одни и те же параметры для Postgres могут быть переданы в виде DSN строки или раздельными параметрами:
Перед нами стоит задача сохранить произвольный набор сущностей этих классов в реляционной базе данных,
следуя принципам реляционной модели.
Реляционные базы данных не поддерживают наследование. Давайте рассуждать.
Можно просто взять и засунуть все значения в одну таблицу, выделив один столбец под связь с классом сущности.
Но комплект атрибутов сущностей в зависимости от класса иерархии может меняться.
Если у классов 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;
publicint$age;
}
// Подклассыclass Pet extends Animal { // Соответствует таблица "pet" со столбцами `id` и `name`publicstring$name;
}
class Cat extends Pet { // Соответствует таблица "cat" со столбцами `id` и `frags`publicint$frags;
}
class Dog extends Pet { // Соответствует таблица "dog" со столбцами `id` и `trainingLevel`publicint$trainingLevel;
}
Схема ORM
Для конфигурирования наследования в схеме используется ключ Schema::PARENT,
значением которого может родительский класс или его роль.
Выгрузка определенного подкласса в иерархии приведёт к 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 ONpet.id=cat.idLEFT JOIN dog ONpet.id=dog.idLEFT JOIN hamster ONpet.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 = (newSelect($this->orm, Cat::class))->wherePK(42)->fetchOne();
(new \Cycle\ORM\Transaction())->delete($cat)->run();
// Удалится запись только из таблицы cat. В родительских таблицах запись останется:$cat = (newSelect($this->orm, Cat::class))->wherePK(42)->fetchOne(); // Null$pet = (newSelect($this->orm, Pet::class))->wherePK(42)->loadSubclasses(false)->fetchOne(); // Pet (id:42)$base = (newSelect($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;
publicstring$name; // Поле общее для всех классов
}
class Cat extends Pet {
publicint$frags; // Уникальное поле класса Cat
}
class Dog extends Pet {
publicint$trainingLevel; // Уникальное поле класса Dog
}
/* Таблица: id: int, primary _type: string // Столбец дискриминатора name: string frags: int, nullable, default=null trainingLevel: int, nullable, default=null */
Схема ORM
Для перечисления классов одной таблицы в схеме используется ключ Schema::CHILDREN,
значением которого является массив вида Значение дискриминатора => Роль или класс сущности.
Вы также можете указать название поля дискриминатора, используя опцию Schema::DISCRIMINATOR.
useCycle\ORM\SchemaInterfaceasSchema;
$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 теперь 8.0.
Свойства классов и сигнатуры методов типизированы более строго.
У некоторых методов внешнего API уточнены типы возвращаемых значений.
Это BC break изменение, поэтому убедитесь в том, что в имплементациях интерфейсов указаны такие же или иные типы согласно принципу LSP.
Например, \Cycle\ORM\RepositoryInterface в методах findByPK() и findOne() выставлен возвращаемый тип ?object. Если в вашем коде переопределяется один из этих методов, то возвращаемый тип должен быть ?object или более точный (например ?User).
Рассмотрим пример. Есть у нас сущность 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 {
publiciterable$posts;
private ?Profile$profile;
publicfunctiongetProfile(): Profile
{
if ($this->profile === null) {
$this->profile = newProfile();
}
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.
Пользовательские коллекции можно настраивать индивидуально для каждой связи, указывая псевдонимы и интерфейсы:
Настраиваются псевдонимы и интерфейсы в объекте класса \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)
);
Интерфейс коллекции используется для тех случаев, когда вы расширяете коллекции под свои нужды.
Важное отличие связи Many to Many от Has Many заключается в том, в ней участвуют Pivot'ы — промежуточные сущности
из кросс-таблицы.
Связь Many to Many переписана таким образом, что теперь нет необходимости таскать pivot'ы в коллекции сущности.
Вы можете использовать даже массивы. Однако, если возникнет необходимость в работе с pivot'ами, ваша фабрика коллекций
должна будет произвести коллекцию, реализующую интерфейс PivotedCollectionInterface. Пример такой фабрики —
DoctrineCollectionFactory.
Переписан алгоритм сохранения сущностей. Рекурсия развёрнута в очередь.
Команды DB облегчены и не участвуют в механизме подписки на изменения полей (forward).
Под новую логику переработаны связи, их интерфейсы и карта связей.
Пока это не дало буста к скорости, однако большие графы сохраняются с ощутимо меньшим потреблением памяти.
Теперь некоторые случаи спекулятивного сохраниния сущностей не работают.
Для каждой сущности проверяются родительские связи, даже если они явно не указаны отношениями BelongsTo или RefersTo.
Маппер и команды
Удалены интерфйсы ContextCarrierInterface и ProducerInterface, методы маппера queueCreate и queueUpdate возвращают более общий CommandInterface.
Удалены команды Nil, Condition, ContextSequence, Split.
Если вы реалзиуете свою команду, поддерживающую rollback или complete, то необходимо добавить соответствующий методу интерфейс.
Иначе команда после выполнения не задержится в транзакции.
Кроме того, типизация на первичном ключе не работала в случае значений, автогенерируемых на стороне БД:
lastInsertID просто не приводился к нужному типу после вставки записи.
Это могло приводить к проблемам в типизированном коде.
В целом даже не очень понятно, как подменить "тайпкастер" и как вообще повлиять на процесс приведения типов.
Теперь 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.
В качестве примера, заготовка для пользовательского класса тайпкастинга, могла бы выглядеть так:
В SchemaInterface::TYPECAST_HANDLER вы можете указать список тайпкастов.
В этом случае каждый элемент списка по очереди получит порцию пользовательских правил,
отфильтрованную предыдущим тайпкастом.
Типизация в Select::fetchData() и ORM::make()
В результате переработки тайпкаста процесс приведения типов был вынесен
из парсинга сырых данных БД на более поздний шаг (в ORM::make()).
Благодаря этому метод Select::fetchData() вернёт сырые данные, если передать аргумент typecast : false.
В сигнатуре метода ORM::make() тоже появился параметр typecast, установленный в false по умолчанию.
Если вы передаёте в ORM::make() сырые данные, то передайте аргумент typecast : false.