1
0
mirror of synced 2025-01-25 09:41:40 +03:00

Merge branch 'hotfix/#1001-DDC-3005-defer-postload-event-after-fully-populated-associations'

Close #1001
This commit is contained in:
Marco Pivetta 2015-01-13 00:54:31 +01:00
commit a906295c65
11 changed files with 436 additions and 21 deletions

View File

@ -184,13 +184,6 @@ the life-time of their registered entities.
invoked, after all references to entities have been removed from the unit of invoked, after all references to entities have been removed from the unit of
work. This event is not a lifecycle callback. work. This event is not a lifecycle callback.
.. warning::
Note that the postLoad event occurs for an entity
before any associations have been initialized. Therefore it is not
safe to access associations in a postLoad callback or event
handler.
.. warning:: .. warning::
Note that the postRemove event or any events triggered after an entity removal Note that the postRemove event or any events triggered after an entity removal

View File

@ -96,6 +96,8 @@ class DefaultCollectionHydrator implements CollectionHydrator
$collection->hydrateSet($index, $entity); $collection->hydrateSet($index, $entity);
}); });
$this->uow->hydrationComplete();
return $list; return $list;
} }
} }

View File

@ -151,6 +151,10 @@ class DefaultEntityHydrator implements EntityHydrator
$this->uow->registerManaged($entity, $key->identifier, $data); $this->uow->registerManaged($entity, $key->identifier, $data);
} }
return $this->uow->createEntity($entry->class, $data, $hints); $result = $this->uow->createEntity($entry->class, $data, $hints);
$this->uow->hydrationComplete();
return $result;
} }
} }

View File

@ -149,6 +149,8 @@ class DefaultQueryCache implements QueryCache
$this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey); $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey);
} }
$this->uow->hydrationComplete();
return null; return null;
} }
@ -176,6 +178,8 @@ class DefaultQueryCache implements QueryCache
$this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey); $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey);
} }
$this->uow->hydrationComplete();
return null; return null;
} }
@ -196,6 +200,8 @@ class DefaultQueryCache implements QueryCache
$result[$index] = $this->uow->createEntity($entityEntry->class, $data, self::$hints); $result[$index] = $this->uow->createEntity($entityEntry->class, $data, self::$hints);
} }
$this->uow->hydrationComplete();
return $result; return $result;
} }

View File

@ -145,8 +145,10 @@ class ObjectHydrator extends AbstractHydrator
$this->resultPointers = array(); $this->resultPointers = array();
if ($eagerLoad) { if ($eagerLoad) {
$this->_em->getUnitOfWork()->triggerEagerLoads(); $this->_uow->triggerEagerLoads();
} }
$this->_uow->hydrationComplete();
} }
/** /**

View File

@ -47,6 +47,17 @@ class SimpleObjectHydrator extends AbstractHydrator
$this->class = $this->getClassMetadata(reset($this->_rsm->aliasMap)); $this->class = $this->getClassMetadata(reset($this->_rsm->aliasMap));
} }
/**
* {@inheritdoc}
*/
protected function cleanup()
{
parent::cleanup();
$this->_uow->triggerEagerLoads();
$this->_uow->hydrationComplete();
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@ -0,0 +1,103 @@
<?php
/*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* This software consists of voluntary contributions made by many individuals
* and is licensed under the MIT license. For more information, see
* <http://www.doctrine-project.org>.
*/
namespace Doctrine\ORM\Internal;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\ListenersInvoker;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
/**
* Class, which can handle completion of hydration cycle and produce some of tasks.
* In current implementation triggers deferred postLoad event.
*
* @author Artur Eshenbrener <strate@yandex.ru>
* @since 2.5
*/
final class HydrationCompleteHandler
{
/**
* @var ListenersInvoker
*/
private $listenersInvoker;
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var array[]
*/
private $deferredPostLoadInvocations = array();
/**
* Constructor for this object
*
* @param ListenersInvoker $listenersInvoker
* @param EntityManagerInterface $em
*/
public function __construct(ListenersInvoker $listenersInvoker, EntityManagerInterface $em)
{
$this->listenersInvoker = $listenersInvoker;
$this->em = $em;
}
/**
* Method schedules invoking of postLoad entity to the very end of current hydration cycle.
*
* @param ClassMetadata $class
* @param object $entity
*/
public function deferPostLoadInvoking(ClassMetadata $class, $entity)
{
$invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postLoad);
if ($invoke === ListenersInvoker::INVOKE_NONE) {
return;
}
$this->deferredPostLoadInvocations[] = array($class, $invoke, $entity);
}
/**
* This method should me called after any hydration cycle completed.
*
* Method fires all deferred invocations of postLoad events
*/
public function hydrationComplete()
{
$toInvoke = $this->deferredPostLoadInvocations;
$this->deferredPostLoadInvocations = array();
foreach ($toInvoke as $classAndEntity) {
list($class, $invoke, $entity) = $classAndEntity;
$this->listenersInvoker->invoke(
$class,
Events::postLoad,
$entity,
new LifecycleEventArgs($entity, $this->em),
$invoke
);
}
}
}

View File

@ -20,6 +20,7 @@
namespace Doctrine\ORM; namespace Doctrine\ORM;
use Doctrine\DBAL\LockMode; use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Internal\HydrationCompleteHandler;
use Exception; use Exception;
use InvalidArgumentException; use InvalidArgumentException;
use UnexpectedValueException; use UnexpectedValueException;
@ -276,6 +277,13 @@ class UnitOfWork implements PropertyChangedListener
*/ */
protected $hasCache = false; protected $hasCache = false;
/**
* Helper for handling completion of hydration
*
* @var HydrationCompleteHandler
*/
private $hydrationCompleteHandler;
/** /**
* Initializes a new UnitOfWork instance, bound to the given EntityManager. * Initializes a new UnitOfWork instance, bound to the given EntityManager.
* *
@ -288,6 +296,7 @@ class UnitOfWork implements PropertyChangedListener
$this->listenersInvoker = new ListenersInvoker($em); $this->listenersInvoker = new ListenersInvoker($em);
$this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled(); $this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled();
$this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory()); $this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory());
$this->hydrationCompleteHandler = new HydrationCompleteHandler($this->listenersInvoker, $em);
} }
/** /**
@ -2801,11 +2810,8 @@ class UnitOfWork implements PropertyChangedListener
} }
if ($overrideLocalValues) { if ($overrideLocalValues) {
$invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postLoad); // defer invoking of postLoad event to hydration complete step
$this->hydrationCompleteHandler->deferPostLoadInvoking($class, $entity);
if ($invoke !== ListenersInvoker::INVOKE_NONE) {
$this->listenersInvoker->invoke($class, Events::postLoad, $entity, new LifecycleEventArgs($entity, $this->em), $invoke);
}
} }
return $entity; return $entity;
@ -3379,4 +3385,15 @@ class UnitOfWork implements PropertyChangedListener
return $id1 === $id2 || implode(' ', $id1) === implode(' ', $id2); return $id1 === $id2 || implode(' ', $id1) === implode(' ', $id2);
} }
/**
* This method called by hydrators, and indicates that hydrator totally completed current hydration cycle.
* Unit of work able to fire deferred events, related to loading events here.
*
* @internal should be called internally from object hydrators
*/
public function hydrationComplete()
{
$this->hydrationCompleteHandler->hydrationComplete();
}
} }

View File

@ -236,6 +236,9 @@ class CmsUser
} }
} }
/**
* @return CmsEmail
*/
public function getEmail() { return $this->email; } public function getEmail() { return $this->email; }
public function setEmail(CmsEmail $email = null) { public function setEmail(CmsEmail $email = null) {

View File

@ -1,6 +1,7 @@
<?php <?php
namespace Doctrine\Tests\ORM\Functional; namespace Doctrine\Tests\ORM\Functional;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\Models\CMS\CmsUser;
use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsPhonenumber;
use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsAddress;
@ -203,6 +204,37 @@ class PostLoadEventTest extends \Doctrine\Tests\OrmFunctionalTestCase
$phonenumbersCol->first(); $phonenumbersCol->first();
} }
/**
* @group DDC-3005
*/
public function testAssociationsArePopulatedWhenEventIsFired()
{
$checkerListener = new PostLoadListenerCheckAssociationsArePopulated();
$this->_em->getEventManager()->addEventListener(array(Events::postLoad), $checkerListener);
$qb = $this->_em->getRepository('Doctrine\Tests\Models\CMS\CmsUser')->createQueryBuilder('u');
$qb->leftJoin('u.email', 'email');
$qb->addSelect('email');
$qb->getQuery()->getSingleResult();
$this->assertTrue($checkerListener->checked, 'postLoad event is not invoked');
$this->assertTrue($checkerListener->populated, 'Association of email is not populated in postLoad event');
}
/**
* @group DDC-3005
*/
public function testEventRaisedCorrectTimesWhenOtherEntityLoadedInEventHandler()
{
$eventManager = $this->_em->getEventManager();
$listener = new PostLoadListenerLoadEntityInEventHandler();
$eventManager->addEventListener(array(Events::postLoad), $listener);
$this->_em->find('Doctrine\Tests\Models\CMS\CmsUser', $this->userId);
$this->assertSame(1, $listener->countHandledEvents('Doctrine\Tests\Models\CMS\CmsUser'), 'Doctrine\Tests\Models\CMS\CmsUser should be handled once!');
$this->assertSame(1, $listener->countHandledEvents('Doctrine\Tests\Models\CMS\CmsEmail'), '\Doctrine\Tests\Models\CMS\CmsEmail should be handled once!');
}
private function loadFixture() private function loadFixture()
{ {
$user = new CmsUser; $user = new CmsUser;
@ -248,3 +280,46 @@ class PostLoadListener
echo 'Should never be called!'; echo 'Should never be called!';
} }
} }
class PostLoadListenerCheckAssociationsArePopulated
{
public $checked = false;
public $populated = false;
public function postLoad(LifecycleEventArgs $event)
{
$object = $event->getObject();
if ($object instanceof CmsUser) {
if ($this->checked) {
throw new \RuntimeException('Expected to be one user!');
}
$this->checked = true;
$this->populated = null !== $object->getEmail();
}
}
}
class PostLoadListenerLoadEntityInEventHandler
{
private $firedByClasses = array();
public function postLoad(LifecycleEventArgs $event)
{
$object = $event->getObject();
$class = ClassUtils::getClass($object);
if (!isset($this->firedByClasses[$class])) {
$this->firedByClasses[$class] = 1;
} else {
$this->firedByClasses[$class]++;
}
if ($object instanceof CmsUser) {
$object->getEmail()->getEmail();
}
}
public function countHandledEvents($className)
{
return isset($this->firedByClasses[$className]) ? $this->firedByClasses[$className] : 0;
}
}

View File

@ -0,0 +1,199 @@
<?php
/*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* This software consists of voluntary contributions made by many individuals
* and is licensed under the MIT license. For more information, see
* <http://www.doctrine-project.org>.
*/
namespace Doctrine\Tests\ORM\Internal;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\ListenersInvoker;
use Doctrine\ORM\Events;
use Doctrine\ORM\Internal\HydrationCompleteHandler;
use PHPUnit_Framework_TestCase;
use stdClass;
/**
* Tests for {@see \Doctrine\ORM\Internal\HydrationCompleteHandler}
*
* @covers \Doctrine\ORM\Internal\HydrationCompleteHandler
*/
class HydrationCompleteHandlerTest extends PHPUnit_Framework_TestCase
{
/**
* @var \Doctrine\ORM\Event\ListenersInvoker|\PHPUnit_Framework_MockObject_MockObject
*/
private $listenersInvoker;
/**
* @var \Doctrine\ORM\EntityManagerInterface|\PHPUnit_Framework_MockObject_MockObject
*/
private $entityManager;
/**
* @var HydrationCompleteHandler
*/
private $handler;
/**
* {@inheritDoc}
*/
protected function setUp()
{
$this->listenersInvoker = $this->getMock('Doctrine\ORM\Event\ListenersInvoker', array(), array(), '', false);
$this->entityManager = $this->getMock('Doctrine\ORM\EntityManagerInterface');
$this->handler = new HydrationCompleteHandler($this->listenersInvoker, $this->entityManager);
}
/**
* @dataProvider testGetValidListenerInvocationFlags
*
* @param int $listenersFlag
*/
public function testDefersPostLoadOfEntity($listenersFlag)
{
/* @var $metadata \Doctrine\ORM\Mapping\ClassMetadata */
$metadata = $this->getMock('Doctrine\ORM\Mapping\ClassMetadata', array(), array(), '', false);
$entity = new stdClass();
$entityManager = $this->entityManager;
$this
->listenersInvoker
->expects($this->any())
->method('getSubscribedSystems')
->with($metadata)
->will($this->returnValue($listenersFlag));
$this->handler->deferPostLoadInvoking($metadata, $entity);
$this
->listenersInvoker
->expects($this->once())
->method('invoke')
->with(
$metadata,
Events::postLoad,
$entity,
$this->callback(function (LifecycleEventArgs $args) use ($entityManager, $entity) {
return $entity === $args->getEntity() && $entityManager === $args->getObjectManager();
}),
$listenersFlag
);
$this->handler->hydrationComplete();
}
/**
* @dataProvider testGetValidListenerInvocationFlags
*
* @param int $listenersFlag
*/
public function testDefersPostLoadOfEntityOnlyOnce($listenersFlag)
{
/* @var $metadata \Doctrine\ORM\Mapping\ClassMetadata */
$metadata = $this->getMock('Doctrine\ORM\Mapping\ClassMetadata', array(), array(), '', false);
$entity = new stdClass();
$this
->listenersInvoker
->expects($this->any())
->method('getSubscribedSystems')
->with($metadata)
->will($this->returnValue($listenersFlag));
$this->handler->deferPostLoadInvoking($metadata, $entity);
$this->listenersInvoker->expects($this->once())->method('invoke');
$this->handler->hydrationComplete();
$this->handler->hydrationComplete();
}
/**
* @dataProvider testGetValidListenerInvocationFlags
*
* @param int $listenersFlag
*/
public function testDefersMultiplePostLoadOfEntity($listenersFlag)
{
/* @var $metadata1 \Doctrine\ORM\Mapping\ClassMetadata */
/* @var $metadata2 \Doctrine\ORM\Mapping\ClassMetadata */
$metadata1 = $this->getMock('Doctrine\ORM\Mapping\ClassMetadata', array(), array(), '', false);
$metadata2 = $this->getMock('Doctrine\ORM\Mapping\ClassMetadata', array(), array(), '', false);
$entity1 = new stdClass();
$entity2 = new stdClass();
$entityManager = $this->entityManager;
$this
->listenersInvoker
->expects($this->any())
->method('getSubscribedSystems')
->with($this->logicalOr($metadata1, $metadata2))
->will($this->returnValue($listenersFlag));
$this->handler->deferPostLoadInvoking($metadata1, $entity1);
$this->handler->deferPostLoadInvoking($metadata2, $entity2);
$this
->listenersInvoker
->expects($this->exactly(2))
->method('invoke')
->with(
$this->logicalOr($metadata1, $metadata2),
Events::postLoad,
$this->logicalOr($entity1, $entity2),
$this->callback(function (LifecycleEventArgs $args) use ($entityManager, $entity1, $entity2) {
return in_array($args->getEntity(), array($entity1, $entity2), true)
&& $entityManager === $args->getObjectManager();
}),
$listenersFlag
);
$this->handler->hydrationComplete();
}
public function testSkipsDeferredPostLoadOfMetadataWithNoInvokedListeners()
{
/* @var $metadata \Doctrine\ORM\Mapping\ClassMetadata */
$metadata = $this->getMock('Doctrine\ORM\Mapping\ClassMetadata', array(), array(), '', false);
$entity = new stdClass();
$this
->listenersInvoker
->expects($this->any())
->method('getSubscribedSystems')
->with($metadata)
->will($this->returnValue(ListenersInvoker::INVOKE_NONE));
$this->handler->deferPostLoadInvoking($metadata, $entity);
$this->listenersInvoker->expects($this->never())->method('invoke');
$this->handler->hydrationComplete();
}
public function testGetValidListenerInvocationFlags()
{
return array(
array(ListenersInvoker::INVOKE_LISTENERS),
array(ListenersInvoker::INVOKE_CALLBACKS),
array(ListenersInvoker::INVOKE_MANAGER),
array(ListenersInvoker::INVOKE_LISTENERS | ListenersInvoker::INVOKE_CALLBACKS),
array(ListenersInvoker::INVOKE_LISTENERS | ListenersInvoker::INVOKE_MANAGER),
array(ListenersInvoker::INVOKE_LISTENERS | ListenersInvoker::INVOKE_CALLBACKS | ListenersInvoker::INVOKE_MANAGER),
);
}
}