#[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.
// 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 ORM is actually really cool. It has several ready-to-use mappers that are game changers.
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.ClasslessMapper
, you still don't need to define a class for the entity. But this is where proxies described below come into play.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.
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
?
Let's take a look at the handler's code, removing the unnecessary parts. The answer lies in this line:
$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:
#[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.
If your workflow is similar to the one in this example (where you populate relations according to ID), here's a set of solutions:
PromiseMapper
.\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.
#[Entity]
class Comment {
// ... fields ...
#[BelongsTo(target: Post::class)]
public Reference|Post $post;
}
$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.