#[Entity]
class Comment {
#[Column(type="integer", primary: true, typecast="int")]
public ?int $id = null;
#[Column(type="integer", name="post_id" typecast="int")]
public int $postId;
#[BelongsTo(target: Post::class, innerKey="postId")]
public Post $post;
#[Column(type="text")]
public string $content;
}☝ Очень упрощённая сущность комментария в Cycle ORM.
👇 Команда и хэндлер на сохранение комментария.
// Store comment command DTO
final readonly class StoreCommentCommand {
/**
* @param int<1, max> $postId
* @param non-empty-string $content
*/
public function construct(
public int $postId,
public string $content,
) {}
}
final readonly class StoreCommentHandler {
public function __construct(
private EntityManagerInterface $em,
) {}
#[Handler]
public function __invoke(StoreCommentCommand $command): StoreCommentResult
{
$comment = new Comment();
$comment->postId = $command->postId;
$comment->content = $command->content;
$this-em->persist($comment)->run();
return new StoreCommentResult(id: $comment->id);
}
}Всё просто, наглядно и понятно. Что может пойти не так? Нет, этот текст не про транзакции. Тем более EntityManager по умолчанию сам завернёт всё в транзакцию.
Если мы посмотрим SQL-лог, то увидим, что после запроса INSERT INTO comment... следует запрос SELECT ... FROM post ...
Загружается сущность поста. Но почему?
Ведь в Cycle ORM связи по умолчанию ленивые, т.е. не будут загружаться, пока не потребуются.
Бага? Естественно бага! Как мы её пропустили и не замечали раньше?! Бегом исправлять! Пишем тест, ковыряемся в кишках. Первая догадка — при синхронизации состояний, маппер наполняющий сущность, случайно дёргает и запрашивает связь, а она "резолвится", т.е. загружается. Пилим фикс.
Что потом? А потом падают тесты, в которых прописано и тестируется ровно такое, "неправильное", поведение.
Сам же и создавал когда-то эти тесты и это поведение. Давайте разбираться, почему оно правильное.
Cycle ORM, на самом деле, очень крут. В нём есть несколько готовых мапперов (картографов?), которые являются gamechanger'ами.
- Самый топорно прямой и внутренне простой маппер — это
PromiseMapper. Все незагруженные связи превращает в ссылки (Reference), которые можно вручную загружать. Этот маппер только для хардкора. StdMapperтакой же топорный, но при этом с ним уже не нужны иные классы для сущности, кромеstdClass. Вы не ослышались: Cycle ORM умеет работать с сущностями без класса! Можно было бы ещё сделать иarrayMapper, но вроде как бесполезно: будет работать только на чтение и не будет отличаться от метода->fetchData().- С
ClasslessMapperвсё ещё не нужно описывать класс для сущности. Но уже тут появляются прокси, о которых ниже. - И, наконец,
ProxyMapper(класс\Cycle\ORM\Mapper\Mapper) — маппер по умолчанию. Самый удобный в использовании, но накладывает ряд ограничений.
С таким набором можно делать страшные вещи. При этом на каждой сущности может быть свой маппер.
Поговорим о Proxy. Чтобы связи были ленивыми и пользоваться ими были удобно, нужно добавить немного магии. Чтобы добавить и спрятать магию, нужно иметь контроль над кодом класса. Маппер ClasslessMapper использует свой класс для classless сущностей. Но ProxyMapper работает с пользовательскими классами сущностей. Это приводит к первому ограничению, эффект которого вы могли заметить в начале статьи — класс сущности не может быть финальным, т.к. ProxyMapper расширяет пользовательский класс и добавляет магию под капот. Такие прокси имеют окончание Cycle ORM Proxy в название класса.
Под капотом прокси есть всё, что нужно. И если загрузить из базы сущность Comment через ProxyMapper, запросить в ней незагруженную связь $post = $comment->post;, то в дело вступит магический метод __get(). Работать с этим действительно удобно и приятно. Однако, прогружать связь лучше заранее.
Можно было бы много чего написать вокруг этой темы, всё это интересно и познавательно... но всё-таки почему Post загружается после сохранения Comment?
Взглянем на код хендлера, откинув лишнее. Ответ кроется в этой строчке:
$comment = new Comment();Дело в том, что мы здесь оперируем не прокси-объектом. А значит ORM не сможет спрятать ленивую загрузку за магией. Какие у ORM есть варианты? Вот сущность:
#[Entity]
class Comment {
// ... fields ...
#[BelongsTo(target: Post::class)]
public Post $post;
}В ORM используется много хаков, но легально заменить класс у объекта сущности нельзя (Comment => Comment Cycle ORM Proxy).
Тип у связи строгий: Post. Ссылку (Reference), как это делают другие мапперы, вставить не получится; пустым тоже оставлять нельзя.
Вот ORM и заполняет связь тем, что имеет. А если не имеет, то берёт из базы.
Если флоу у вас такой же, как в этом примере (заполняете связи по ID), то вот пачка решений:
- Если весь код в проекте хардкорный, все связи вы заранее предзагружаете, то плюшки с удобством раскрытия ленивых связей вам не нужны — берите
PromiseMapper. - Если вы дополните тип связи классом
\Cycle\ORM\Reference\Referenceили его интерфейсом\Cycle\ORM\Reference\ReferenceInterface, то на непрокси-сущности, вместо запроса к БД, в это поле запишетсяReferenceобъект.#[Entity] class Comment { // ... fields ... #[BelongsTo(target: Post::class)] public Reference|Post $post; }
- Но самое универсальное решение — просто создавать сущности через ORM:
$comment = $orm->make(Comment::class, ['content' => $content]); // Поля можно докидывать и потом $comment->postId = $postId;
Кстати, в раннем списке изменений Cycle ORM 2.0 можно почитать о том, как прокси работали в первом Cycle.

