diff --git a/docs/en/reference/events.rst b/docs/en/reference/events.rst index 1a1728392..2762e5fc7 100644 --- a/docs/en/reference/events.rst +++ b/docs/en/reference/events.rst @@ -184,13 +184,6 @@ the life-time of their registered entities. invoked, after all references to entities have been removed from the unit of 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:: Note that the postRemove event or any events triggered after an entity removal diff --git a/lib/Doctrine/ORM/Cache/DefaultCollectionHydrator.php b/lib/Doctrine/ORM/Cache/DefaultCollectionHydrator.php index a74b88dfb..cedd891da 100644 --- a/lib/Doctrine/ORM/Cache/DefaultCollectionHydrator.php +++ b/lib/Doctrine/ORM/Cache/DefaultCollectionHydrator.php @@ -96,6 +96,8 @@ class DefaultCollectionHydrator implements CollectionHydrator $collection->hydrateSet($index, $entity); }); + $this->uow->hydrationComplete(); + return $list; } } diff --git a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php index 6f22900e1..1190b38fd 100644 --- a/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php +++ b/lib/Doctrine/ORM/Cache/DefaultEntityHydrator.php @@ -151,6 +151,10 @@ class DefaultEntityHydrator implements EntityHydrator $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; } } diff --git a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php index 927d46ebe..87bb13663 100644 --- a/lib/Doctrine/ORM/Cache/DefaultQueryCache.php +++ b/lib/Doctrine/ORM/Cache/DefaultQueryCache.php @@ -149,6 +149,8 @@ class DefaultQueryCache implements QueryCache $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey); } + $this->uow->hydrationComplete(); + return null; } @@ -176,6 +178,8 @@ class DefaultQueryCache implements QueryCache $this->cacheLogger->entityCacheMiss($assocRegion->getName(), $assocKey); } + $this->uow->hydrationComplete(); + return null; } @@ -196,6 +200,8 @@ class DefaultQueryCache implements QueryCache $result[$index] = $this->uow->createEntity($entityEntry->class, $data, self::$hints); } + $this->uow->hydrationComplete(); + return $result; } diff --git a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php index d4e84da92..ecbf9dcc4 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php @@ -145,8 +145,10 @@ class ObjectHydrator extends AbstractHydrator $this->resultPointers = array(); if ($eagerLoad) { - $this->_em->getUnitOfWork()->triggerEagerLoads(); + $this->_uow->triggerEagerLoads(); } + + $this->_uow->hydrationComplete(); } /** diff --git a/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php index ea2664291..20c75ad30 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/SimpleObjectHydrator.php @@ -47,6 +47,17 @@ class SimpleObjectHydrator extends AbstractHydrator $this->class = $this->getClassMetadata(reset($this->_rsm->aliasMap)); } + /** + * {@inheritdoc} + */ + protected function cleanup() + { + parent::cleanup(); + + $this->_uow->triggerEagerLoads(); + $this->_uow->hydrationComplete(); + } + /** * {@inheritdoc} */ @@ -79,7 +90,7 @@ class SimpleObjectHydrator extends AbstractHydrator if ($metaMappingDiscrColumnName = array_search($discrColumnName, $this->_rsm->metaMappings)) { $discrColumnName = $metaMappingDiscrColumnName; } - + if ( ! isset($sqlResult[$discrColumnName])) { throw HydrationException::missingDiscriminatorColumn($entityName, $discrColumnName, key($this->_rsm->aliasMap)); } diff --git a/lib/Doctrine/ORM/Internal/HydrationCompleteHandler.php b/lib/Doctrine/ORM/Internal/HydrationCompleteHandler.php new file mode 100644 index 000000000..4da71cefa --- /dev/null +++ b/lib/Doctrine/ORM/Internal/HydrationCompleteHandler.php @@ -0,0 +1,103 @@ +. + */ + +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 + * @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 + ); + } + } +} diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index c0e681bce..758754202 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -20,6 +20,7 @@ namespace Doctrine\ORM; use Doctrine\DBAL\LockMode; +use Doctrine\ORM\Internal\HydrationCompleteHandler; use Exception; use InvalidArgumentException; use UnexpectedValueException; @@ -276,6 +277,13 @@ class UnitOfWork implements PropertyChangedListener */ protected $hasCache = false; + /** + * Helper for handling completion of hydration + * + * @var HydrationCompleteHandler + */ + private $hydrationCompleteHandler; + /** * Initializes a new UnitOfWork instance, bound to the given EntityManager. * @@ -283,11 +291,12 @@ class UnitOfWork implements PropertyChangedListener */ public function __construct(EntityManager $em) { - $this->em = $em; - $this->evm = $em->getEventManager(); - $this->listenersInvoker = new ListenersInvoker($em); - $this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled(); - $this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory()); + $this->em = $em; + $this->evm = $em->getEventManager(); + $this->listenersInvoker = new ListenersInvoker($em); + $this->hasCache = $em->getConfiguration()->isSecondLevelCacheEnabled(); + $this->identifierFlattener = new IdentifierFlattener($this, $em->getMetadataFactory()); + $this->hydrationCompleteHandler = new HydrationCompleteHandler($this->listenersInvoker, $em); } /** @@ -2801,11 +2810,8 @@ class UnitOfWork implements PropertyChangedListener } if ($overrideLocalValues) { - $invoke = $this->listenersInvoker->getSubscribedSystems($class, Events::postLoad); - - if ($invoke !== ListenersInvoker::INVOKE_NONE) { - $this->listenersInvoker->invoke($class, Events::postLoad, $entity, new LifecycleEventArgs($entity, $this->em), $invoke); - } + // defer invoking of postLoad event to hydration complete step + $this->hydrationCompleteHandler->deferPostLoadInvoking($class, $entity); } return $entity; @@ -3379,4 +3385,15 @@ class UnitOfWork implements PropertyChangedListener 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(); + } } diff --git a/tests/Doctrine/Tests/Models/CMS/CmsUser.php b/tests/Doctrine/Tests/Models/CMS/CmsUser.php index 2bae6ed4f..c95cce4ea 100644 --- a/tests/Doctrine/Tests/Models/CMS/CmsUser.php +++ b/tests/Doctrine/Tests/Models/CMS/CmsUser.php @@ -236,6 +236,9 @@ class CmsUser } } + /** + * @return CmsEmail + */ public function getEmail() { return $this->email; } public function setEmail(CmsEmail $email = null) { @@ -387,7 +390,7 @@ class CmsUser ) ) )); - + $metadata->addSqlResultSetMapping(array( 'name' => 'mappingMultipleJoinsEntityResults', 'entities' => array(array( diff --git a/tests/Doctrine/Tests/ORM/Functional/PostLoadEventTest.php b/tests/Doctrine/Tests/ORM/Functional/PostLoadEventTest.php index 6cc464bab..9dcff9cb6 100644 --- a/tests/Doctrine/Tests/ORM/Functional/PostLoadEventTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/PostLoadEventTest.php @@ -1,6 +1,7 @@ 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() { $user = new CmsUser; @@ -248,3 +280,46 @@ class PostLoadListener 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; + } +} diff --git a/tests/Doctrine/Tests/ORM/Internal/HydrationCompleteHandlerTest.php b/tests/Doctrine/Tests/ORM/Internal/HydrationCompleteHandlerTest.php new file mode 100644 index 000000000..b767bd225 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Internal/HydrationCompleteHandlerTest.php @@ -0,0 +1,199 @@ +. + */ + +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), + ); + } +}