Cycle ORM hungry for relations

Cycle ORM hungry for relations

php
#[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;
}

☝ This is a simplified Comment entity class in Cycle ORM.
👇 Here's the command and handler for saving an entity of the Comment class.

php
// 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);
    }
}

It’s all simple, clear and intuitive. What can possibly go wrong? This text is definitely not about transactions. Especially since the EntityManager itself will wrap everything in a transaction by default.

If we look at the SQL log, we’ll see that the INSERT INTO comment... query is followed by the SELECT ... FROM post ... query.
The post entity is being loaded. But why?
After all, Cycle ORM relations are lazy by default, i.e. won't be loaded until they’re needed.

Is it a bug? Definitely a bug! How did I miss it and fail to notice it before?! Let’s fix it right away! We write a test, delve into the details. My first guess is that when synchronizing states, the mapper filling the entity accidentally triggers and requests a relation, and it "gets resolved", i.e. is loading. Let’s fix it up.
What then? Then other ORM tests fail, in which exactly such "incorrect" behavior is prescribed and tested.

I once myself created these tests and this behavior. Let's figure out why it's correct.

Cycle: Mappers & Entity Proxy

Cycle ORM is actually really cool. It has several ready-to-use mappers that are game changers.

  • The most straightforward and internally simple mapper is the PromiseMapper. It turns all unloaded relations into references that can be manually loaded. This mapper is for hardcore cases.
  • StdMapper is similarly basic, but with it, you don't need any other classes for entity besides stdClass. You heard it right: Cycle ORM can work with classless entities! You could potentially create an arrayMapper, but it seems pointless: it would only work for reading and wouldn't differ from the ->fetchData() method.
  • With ClasslessMapper, you still don't need to define a class for the entity. But this is where proxies described below come into play.
  • Finally, there's ProxyMapper (class \Cycle\ORM\Mapper\Mapper) — the default mapper. The most user-friendly option, but it imposes certain limitations.

With such a toolkit, you can do some impressive things. Moreover, each entity can have its own mapper.

Let's talk about Proxy. Here you need a touch of magic for relations to be lazy and easy to use. To add and conceal this magic, you need control over the class code. ClasslessMapper uses its own class for classless entities. However, ProxyMapper works with user-defined entity classes. This leads to the first limitation, the effect of which you might have noticed at the beginning of the article: the entity class cannot be final, as ProxyMapper extends the user-defined class and adds magic under the hood. Such proxies end with Cycle ORM Proxy in their class name.

XDebug

The proxy has everything you need under the hood. And if you load the Comment entity from the database through ProxyMapper, and then request an unloaded relation in it like $post = $comment->post;, the magical method __get() will come into play. Working with it is truly convenient and enjoyable. However, it's still better to load the relation in advance.
We can dwell on this topic for long; it’s all interesting and informative but still, why is Post loaded after persisting Comment?

What happened?

Let's take a look at the handler's code, removing the unnecessary parts. The answer lies in this line:

php
$comment = new Comment();

The thing is, here we are not dealing with a proxy object. And that means the ORM won't be able to hide lazy loading behind its magic.
What options does the ORM have? Here's the entity:

php
#[Entity]
class Comment {
    // ... fields ...
    #[BelongsTo(target: Post::class)]
    public Post $post;
}

Many hacks are used in ORM, but it's not legally possible to replace the class of an entity object (Comment => Comment Cycle ORM Proxy).
The relation type is strict: Post. You can't insert a Reference object, as other mappers do, and you can't leave it empty either.
So, the ORM populates the relation with what it has. And if there’s nothing, ORM fetches it from the database.

What do we do?

If your workflow is similar to the one in this example (where you populate relations according to ID), here's a set of solutions:

  • If your entire project codebase is hardcore, where you pre-load all the relations in advance, then you might not need the convenience of expanding lazy relations. In this case, opt for PromiseMapper.
  • If you enhance the relation type with the class \Cycle\ORM\Reference\Reference or its interface \Cycle\ORM\Reference\ReferenceInterface, the Reference object will be stored in this field instead of querying the database on a non-proxy entity.
    php
    #[Entity]
        class Comment {
        // ... fields ...
        #[BelongsTo(target: Post::class)]
        public Reference|Post $post;
    }
    
  • However, the most one-size-fits-all solution is to simply create entities through the ORM:
    php
    $comment = $orm->make(Comment::class, ['content' => $content]);
    

By the way, if you’d like to read about how proxies worked in the first version of Cycle, check out the early changelog of Cycle ORM 2.0.