ba0708
ba0708

Reputation: 10599

doctrine2 extra lazy fetching of association

I have a Thread entity which has a OneToMany association with a Message entity. I am fetching a thread with a DQL query, and I want to limit its amount of messages to 10. Therefore I am setting the fetch mode to EXTRA_LAZY as below.

class Thread
{
    // ...

    /**
     * @var ArrayCollection
     * @ORM\OneToMany(targetEntity="Profile\Entity\Message", mappedBy="thread", fetch="EXTRA_LAZY")
     * @ORM\OrderBy({"timeSent" = "ASC"})
     */
    protected $messages;
}

This allows me to use the slice method to issue a LIMIT SQL query to the database. All good so far. Because my messages are encrypted, I need to decrypt them in my service layer before handling the thread object off to the controller (and ultimately view). To accomplish this, I am doing the following in my service:

foreach ($thread->getMessages()->slice(0, 10) as $message) {
    // Decrypt message
}

The call to slice triggers an SQL query that fetches 10 messages. In my view, I am doing the following to render the thread's messages:

$this->partialLoop()->setObjectKey('message');
echo $this->partialLoop('partial/thread/message.phtml', $thread->getMessages());

The problem is that this fetches the entire collection of messages from the database. If I call slice as in my service, the same SQL query with LIMIT 10 is issued to the database, which is not desirable.

How can I process a limited collection of messages in my service layer without issuing another SQL query in my view? That is, to have doctrine create a single SQL query, not two. I could simply decrypt my messages in my view, but that kind of defeats the purpose of having a service layer in this case. I could surely fetch the messages "manually" and add them to the thread object, but if I could do it automatically through the association, then that would be much preferred.

Thanks in advance!

Upvotes: 4

Views: 4630

Answers (4)

Jasper N. Brouwer
Jasper N. Brouwer

Reputation: 21817

How about a slightly different approach than most have suggested:

Slice

In the Thread entity, have a dedicated method for returning slices of messages:

class Thread
{
    // ...

    /**
     * @param  int      $offset
     * @param  int|null $length
     * @return array
     */
    public function getSliceOfMessages($offset, $length = null)
    {
        return $this->messages->slice($offset, $length);
    }
}

This would take care of easily retrieving a slice in the view, without the risk of fetching the entire collection.

Decrypting message content

Next you need the decrypted content of the messages.

I suggest you create a service that can handle encryption/decryption, and have the Message entity depend on it.

class Message
{
    // ...

    /**
     * @var CryptService
     */
    protected $crypt;

    /**
     * @param CryptService $crypt
     */
    public function __construct(CryptService $crypt)
    {
        $this->crypt = $crypt;
    }
}

Now you have to create Message entities by passing a CryptService to it. You can manage that in the service that creates Message entities.

But this will only take care of Message entities that you instantiate, not the ones Doctrine instantiates. For this, you can use the PostLoad event.

Create an event-listener:

class SetCryptServiceOnMessageListener
{
    /**
     * @var CryptService
     */
    protected $crypt;

    /**
     * @param CryptService $crypt
     */
    public function __construct(CryptService $crypt)
    {
        $this->crypt = $crypt;
    }

    /**
     * @param LifecycleEventArgs $event
     */
    public function postLoad(LifecycleEventArgs $event)
    {
        $entity = $args->getObject();

        if ($entity instanceof Message) {
            $message->setCryptService($this->crypt);
        }
    }
}

This event-listener will inject a CryptService into the Message entity whenever Doctrine loads one.

Register the event-listener in the bootstrap/configuration phase of your application:

$eventListener = new SetCryptServiceOnMessageListener($crypt);
$eventManager  = $entityManager->getEventManager();
$eventManager->addEventListener(array(Events::postLoad), $eventListener);

Add the setter to the Message entity:

class Message
{
    // ...

    /**
     * @param CryptService $crypt
     */
    public function setCryptService(CryptService $crypt)
    {
        if ($this->crypt !== null) {
            throw new \RuntimeException('We already have a crypt service, you cannot swap it.');
        }

        $this->crypt = $crypt;
    }
}

As you can see, the setter safeguards against swapping out the CryptService (you only need to set it when none is present).

Now a Message entity will always have a CryptService as dependency, whether you or Doctrine instantiated it!

Finally we can use the CryptService to encrypt and decrypt the content:

class Message
{
    // ...

    /**
     * @param string $content
     */
    public function setContent($content)
    {
        $this->content = $this->crypt->encrypt($content);
    }     

    /**
     * @return string
     */
    public function getContent()
    {
        return $this->crypt->decrypt($this->content);
    }
}

Usage

In the view you can do something like this:

foreach ($thread->getSliceOfMessages(0, 10) as $message) {
    echo $message->getContent();
}

As you can see this is dead simple!

Another pro is that the content can only exist in encrypted form in a Message entity. You can never accidentally store unencrypted content in the database.

Upvotes: 7

Dropaq
Dropaq

Reputation: 121

I believe you should have a different class for decrypted message and push them separately into your view. Because Message is actually your entity-model and should be used for mapping data into your db but your purpose is quite different.

Because as I understood from your code you make smth. like:

$message->setText(decrypt($message->getText));

and after that you making even worse

$thread->setMessages(new ArrayCollection($thread->getMessages()->slice(0, 10)));

Imagine then, that after you made this changes, somewhere in the code $em->flush() happens. You will loose every message except those 10, and they will be stored decrypted.

So you can push them as a separate array of decrypted messages (if you have only one thread on this view) or multi-dimensional array of messages with ThreadID as first level.

Or you can implement the answer Stock Overflaw gave you.

It's up to you, but you certainly should change your approach.

And by my opinion the best solution would be an implementation of decryption as ViewHelper.

Upvotes: 1

ba0708
ba0708

Reputation: 10599

It seems as if I figured it out. When calling slice, I get an array of entities. If I convert this array to an instance of \Doctrine\Common\Collections\ArrayCollection and call the setMessages method, it seems to work.

$messages = $thread->getMessages()->slice(0, 10);

foreach ($messages as $message) {
    // Decrypt message
}

$thread->setMessages(new ArrayCollection($messages));

Then, in my view, I can just call $thread->getMessages() as I normally would. This has the advantage that my view does not need to know that any decryption is happening or know about any other properties. The result is that only one SQL query is executed, which is the one "generated by" slice - exactly what I wanted.

Upvotes: 0

Stock Overflaw
Stock Overflaw

Reputation: 3321

The slice method's comment says:

Calling this method will only return the selected slice and NOT change the elements contained in the collection slice is called on.

So calling slice has no effect on the global PersistentCollection returned by your getMessages method: I don't think what you try to achieve here is doable.

As a workaround, you could declare a $availableMessages attribute in your Thread class, said attribute not being mapped to Doctrine. It would look like:

class Thread {
    /**
     * @var ArrayCollection
    */
    protected $availableMessages;
    ...
    public function __construct() {
        ...
        $this->availableMessages = new ArrayCollection();
    }
    ...
    public function getAvailableMessages() {
        return $this->availableMessages;
    }
    public function addAvailableMessage($m) {
        $this->availableMessages->add($m);
    }
    ...
}

When you process your messages in your service, you could:

$messages = $thread->getMessages()->slice(0, 10);
foreach ($messages as $message) {
    //do your process...
    ...
    //add the unpacked message to the proper "placeholder"
    $thread->addAvailableMessage($message);
}

Then in your view:

echo $this->partialLoop(
    'partial/thread/message.phtml',
    $thread->getAvailableMessages()
);

There might be some differences in your implementation, like you might prefer having an ID-indexed array instead of an ArrayCollection for $availableMessages, and/or use a set instead of an add method, but you get the idea here...

Anyway, this logic allows you to control the amount of output messages from the service layer, without any implication of later-called layers, which is what you want from what I understood :)

Hope this helps!

Upvotes: 3

Related Questions