Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Persisting other entities inside preUpdate of Doctrine Entity Listener

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!

like image 568
Sergio Negri Avatar asked Jun 09 '15 14:06

Sergio Negri


4 Answers

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);
                }
            }
        }
    }
}
like image 77
Sergio Negri Avatar answered Nov 06 '22 14:11

Sergio Negri


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);
like image 27
Richard Avatar answered Nov 06 '22 14:11

Richard


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.

like image 6
man Avatar answered Nov 06 '22 14:11

man


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,
            )
        }
    }
}
like image 2
Fappie. Avatar answered Nov 06 '22 16:11

Fappie.