Persisting other entities inside preUpdate of Doctrine Entity Listener

21.9k views Asked by At

For clarity I continue here the discussion started here.

Inside a Doctrine Entity Listener, in the preUpdate method (where I have access to both the old and new value of any field of the entity) I'm trying to persist an entity unrelated to the focal one.

Basically I have entity A, and when I change a value in one of the fields I want to write, in the project_notification table, the fields oldValue, newValue plus others.

If I don't flush inside the preUpdate method, the new notification entity does not get stored in DB. If I flush it I enter into a infinite loop.

This is the preUpdate method:

public function preUpdate(ProjectTolerances $tolerances, PreUpdateEventArgs $event)
{
    if ($event->hasChangedField('riskToleranceFlag')) {
    $project = $tolerances->getProject();                
    $em = $event->getEntityManager();
    $notification = new ProjectNotification();
    $notification->setValueFrom($event->getOldValue('riskToleranceFlag'));
    $notification->setValueTo($event->getNewValue('riskToleranceFlag'));
    $notification->setEntity('Entity'); //TODO substitute with the real one
    $notification->setField('riskToleranceFlag');
    $notification->setProject($project);
    $em->persist($notification);


    // $em->flush(); // gives infinite loop
    }
}

Googling a bit I discovered that you cannot call the flush inside the listeners, and here it's suggested to store the stuff to be persisted in an array, to flush it later in the onFlush. Nonetheless it does not work (and probably it should not work, as the instance of the listener class gets destroyed after you call the preUpdate, so whatever you store in as protected attribute at the level of the class gets lost when you later call the onFlush, or am I missing something?).

Here is the updated version of the listener:

class ProjectTolerancesListener
{
    protected $toBePersisted = [];

    public function preUpdate(ProjectTolerances $tolerances, PreUpdateEventArgs $event)
    {
        $uow = $event->getEntityManager()->getUnitOfWork();
//        $hasChanged = false;

        if ($event->hasChangedField('riskToleranceFlag')) {
        $project = $tolerances->getProject();                
        $notification = new ProjectNotification();
        $notification->setValueFrom($event->getOldValue('riskToleranceFlag'));
        $notification->setValueTo($event->getNewValue('riskToleranceFlag'));
        $notification->setEntity('Entity'); //TODO substitute with the real one
        $notification->setField('riskToleranceFlag');
        $notification->setProject($project);

        if(!empty($this->toBePersisted))
            {
            array_push($toBePersisted, $notification);
            }
        else
            {
            $toBePersisted[0] = $notification;
            }
        }
    }

    public function postFlush(LifecycleEventArgs $event)
    {
        if(!empty($this->toBePersisted)) {

            $em = $event->getEntityManager();

            foreach ($this->toBePersisted as $element) {

                $em->persist($element);
            }

            $this->toBePersisted = [];
            $em->flush();
        }
    }
}

Maybe I can solve this by firing an event from inside the listener with all the needed info to perform my logging operations after the flush...but:

1) I don't know if I can do it

2) It seems a bit an overkill

Thank you!

5

There are 5 answers

2
Richard On BEST ANSWER

Don't use preUpdate, use onFlush - this allows you to access the UnitOfWork API & you can then persist entities.

E.g. (this is how I do it in 2.3, might be changed in newer versions)

    $this->getEntityManager()->persist($entity);
    $metaData = $this->getEntityManager()->getClassMetadata($className);
    $this->getUnitOfWork()->computeChangeSet($metaData, $entity);
1
Sergio Negri On

I give all the credits to Richard for pointing me into the right direction, so I'm accepting his answer. Nevertheless I also publish my answer with the complete code for future visitors.

class ProjectEntitySubscriber implements EventSubscriber
{
    public function getSubscribedEvents()
    {
        return array(
            'onFlush',
        );
    }

    public function onFlush(OnFlushEventArgs  $args)
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();

        foreach ($uow->getScheduledEntityUpdates() as $keyEntity => $entity) {
            if ($entity instanceof ProjectTolerances) {
                foreach ($uow->getEntityChangeSet($entity) as $keyField => $field) {
                    $notification = new ProjectNotification();
                    // place here all the setters
                    $em->persist($notification);
                    $classMetadata = $em->getClassMetadata('AppBundle\Entity\ProjectNotification');
                    $uow->computeChangeSet($classMetadata, $notification);
                }
            }
        }
    }
}
0
Patrick On

Using an Lifecycle Listener instead of an EntityListener might be better suited in this case (I find that the symfony docs provide a better overview over the different options). This is due to onFlush, a very powerful event, not being available for EntityListeners. This event is invoked after all changesets are computed and before the database actions are executed.

In this answer I explore the options using an Entity Listener.

Using preUpdate: This event provides a PreUpdateEventArgs which makes it easy to find all values that are going to be changed. However this event is triggered within UnitOfWork#commit, after the inserts have been processed. Hence there is now no possibility to add a new entity to be persisted within current transaction.

Using preFlush: This event occurs at the beginning of a flush operation. Changesets might not yet be available, but we can compare the original values with the current ones. This approach might not be suitable when there are many changes that are needed. Here is an example implementation:

    public function preFlush(Order $order, PreFlushEventArgs $eventArgs)
    {
        // Create a log entry when the state was changed
        $entityManager = $eventArgs->getEntityManager();
        $unitOfWork = $entityManager->getUnitOfWork();
        $originalEntityData = $unitOfWork->getOriginalEntityData($order);
        $newState = $order->getState();
        if (empty($originalEntityData)) {
            // We're dealing with a new order
            $oldState = "";
        } else {
            $stateProperty = 'state';
            $oldState = $originalEntityData[$stateProperty];
            // Same behavior as in \Doctrine\ORM\UnitOfWork:720: Existing
            // changeset is ignored when the property was changed
            $entityChangeSet = $unitOfWork->getEntityChangeSet($order);
            $stateChanges = $entityChangeSet[$stateProperty] ?? [];
            if ($oldState == $newState && $stateChanges) {
                $oldState = $stateChanges[0] ?? "";
                $newState = $stateChanges[1] ?? "";
            }
        }
        if ($oldState != $newState) {
            $statusLog = $this->createOrderStatusLog($order, $oldState, $newState);
            $unitOfWork->scheduleForInsert($statusLog);
            $unitOfWork->computeChangeSet($entityManager->getClassMetadata('App\Entity\OrderStatusLog'), $statusLog);
        }
    }

Using postFlush/postUpdate: Using these events would lead to a second database transaction, which is undesirable.

2
man On

As David Baucum stated, the initial question referred to Doctrine Entity Listeners, but as a solution, the op ended up using an Event Listener.

I am sure many more will stumble upon this topic, because of the infinite loop problem. For those that adopt the accepted answer, TAKE NOTE that the onFlush event (when using an Event Listener like above) is executed with ALL the entities that might be in queue for an update, whereas an Entity Listener is used only when doing something with the entity it was "assigned" to.

I setup a custom auditing system with symfony 4.4 and API Platform, and i managed to achieve the desired result with just an Entity Listener.

NOTE: Tested and working however, the namespaces and functions have been modified, and this is purely to demonstrate how to manipulate another entity inside a Doctrine Entity Listener.

// this goes into the main entity
/**
* @ORM\EntityListeners({"App\Doctrine\MyEntityListener"})
*/
<?
// App\Doctrine\MyEntityListener.php

namespace App\Doctrine;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\Security;

// whenever an Employee record is inserted/updated
// log changes to EmployeeAudit
use App\Entity\Employee;
use App\Entity\EmployeeAudit;

private $security;
private $currentUser;
private $em;
private $audit;

public function __construct(Security $security, EntityManagerInterface $em) {
    $this->security = $security;
    $this->currentUser = $security->getUser();
    $this->em = $em;
}

// HANDLING NEW RECORDS

/**
 * since prePersist is called only when inserting a new record, the only purpose of this method
 * is to mark our object as a new entry
 * this method might not be necessary, but for some reason, if we set something like
 * $this->isNewEntry = true, the postPersist handler will not pick up on that
 * might be just me doing something wrong
 *
 * @param Employee $obj
 * @ORM\PrePersist()
 */
public function prePersist(Employee $obj){
    if(!($obj instanceof Employee)){
        return;
    }
    $isNewEntry = !$obj->getId();
    $obj->markAsNewEntry($isNewEntry);// custom Employee method (just sets an internal var to true or false, which can later be retrieved)
}

/**
 * @param Employee $obj
 * @ORM\PostPersist()
 */
public function postPersist(Employee $obj){
    // in this case, we can flush our EmployeeAudit object safely
    $this->prepareAuditEntry($obj);
}

// END OF NEW RECORDS HANDLING

// HANDLING UPDATES

/**
 * @see {https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/events.html}
 * @param Employee $obj
 * @param PreUpdateEventArgs $args
 * @ORM\PreUpdate()
 */
public function preUpdate(Employee $obj, PreUpdateEventArgs $args){
    $entity = $args->getEntity();
    $changeset = $args->getEntityChangeSet();

    // we just prepare our EmployeeAudit obj but don't flush anything
    $this->audit = $this->prepareAuditEntry($obj, $changeset, $flush = false);
}

/**
 * @ORM\PostUpdate()
 */
public function postUpdate(){
    // if the preUpdate handler was called, $this->audit should exist
    // NOTE: the preUpdate handler DOES NOT get called, if nothing changed
    if($this->audit){
        $this->em->persist($this->audit);
        $this->em->flush();
    }
    // don't forget to unset this
    $this->audit = null;
}

// END OF HANDLING UPDATES

// AUDITOR

private function prepareAuditEntry(Employee $obj, $changeset = [], $flush = true){
    if(!($obj instanceof Employee) || !$obj->getId()){
        // at this point, we need a DB id
        return;
    }

    $audit = new EmployeeAudit();
    // this part was cut out, since it is custom
    // here you would set things to your EmployeeAudit object
    // either get them from $obj, compare with the changeset, etc...

    // setting some custom fields
    // in case it is a new insert, the changedAt datetime will be identical to the createdAt datetime
    $changedAt = $obj->isNewInsert() ? $obj->getCreatedAt() : new \DateTime('@'.strtotime('now'));
    $changedFields = array_keys($changeset);
    $changedCount = count($changedFields);
    $changedBy = $this->currentUser->getId();
    $entryId = $obj->getId();

    $audit->setEntryId($entryId);
    $audit->setChangedFields($changedFields);
    $audit->setChangedCount($changedCount);
    $audit->setChangedBy($changedBy);
    $audit->setChangedAt($changedAt);

    if(!$flush){
        return $audit;
    }
    else{
        $this->em->persist($audit);
        $this->em->flush();
    }
}

The idea is to NOT persist/flush anything inside preUpdate (except prepare your data, because you have access to the changeset and stuff), and do it postUpdate in case of updates, or postPersist in case of new inserts.

1
Fappie. On

Theres a little hack I came across today. Maybe it helps to future generations.

So basicly, in onFlush Listener I cant store anything (because of deadlock or something similar If I call flush in another repository) and in postFlush i dont have access to change sets.

So I registered it as Subscriber with both events (onFlush, postFlush) implemented and just have class variable private array $entityUpdateBuffer = []; where I temp store Entities scheduled to update from onFlush event.

class MyEntityEventSubscriber implements EventSubscriber
{

    private array $entityUpdateBuffer = [];

    public function __construct(private MyBusiness $myBusiness)
    {
    }

    public function getSubscribedEvents(): array
    {
        return [
            Events::onFlush,
            Events::postFlush,
        ];
    }

    public function onFlush(OnFlushEventArgs $args): void
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();

        $this->entityUpdateBuffer = $uow->getScheduledEntityUpdates();
    }

    public function postFlush(PostFlushEventArgs $args): void
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();

        foreach ($this->entityUpdateBuffer as $entity) {
            if (!$entity instanceof MyEntity) {
                continue;
            }

            $changeSet = $uow->getEntityChangeSet($entity);

            // Call whatever that uses $entity->getId() as reference
            $this->myBusiness->createChangeRecordWithEntityId(
                $entity->getId(),
                $changeSet,
            )
        }
    }
}