Skip to content

Instantly share code, notes, and snippets.

@roxblnfk
Last active July 26, 2023 18:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save roxblnfk/dc2773aaaa8b43ee46b0b331873d90c8 to your computer and use it in GitHub Desktop.
Save roxblnfk/dc2773aaaa8b43ee46b0b331873d90c8 to your computer and use it in GitHub Desktop.
Cycle ORM до связей жадный

Cycle ORM до связей жадный

Preview

#[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: Mappers & Entity Proxy

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 в название класса.

Pasted image 20230726014836

Под капотом прокси есть всё, что нужно. И если загрузить из базы сущность 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.

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