Persisting other entities inside preUpdate of Doctrine Entity Listener

后端 未结 4 1959
忘掉有多难
忘掉有多难 2020-12-09 16:44

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 val

相关标签:
4条回答
  • 2020-12-09 16:50

    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.

    0 讨论(0)
  • 2020-12-09 16:56

    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);
    
    0 讨论(0)
  • 2020-12-09 16:57

    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 讨论(0)
  • 2020-12-09 17:05

    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.

    0 讨论(0)
提交回复
热议问题