#[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.