diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7c8ec81ae84..3a4b45179db 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -170,6 +170,16 @@ parameters: count: 1 path: src/Proxy/ProxyFactory.php + - + message: "#^Call to an undefined method ReflectionClass\\\\:\\:newLazyGhost\\(\\)\\.$#" + count: 1 + path: src/Proxy/ProxyFactory.php + + - + message: "#^Call to an undefined method ReflectionProperty\\:\\:setRawValueWithoutLazyInitialization\\(\\)\\.$#" + count: 1 + path: src/Proxy/ProxyFactory.php + - message: "#^Call to an undefined static method Doctrine\\\\ORM\\\\Proxy\\\\ProxyFactory\\:\\:createLazyGhost\\(\\)\\.$#" count: 1 diff --git a/src/Configuration.php b/src/Configuration.php index b30764eb2b9..dfd56a5640f 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -30,6 +30,8 @@ use function is_a; use function strtolower; +use const PHP_VERSION_ID; + /** * Configuration container for all configuration options of Doctrine. * It combines all configuration options from DBAL & ORM. @@ -595,6 +597,20 @@ public function setSchemaIgnoreClasses(array $schemaIgnoreClasses): void $this->attributes['schemaIgnoreClasses'] = $schemaIgnoreClasses; } + public function isLazyProxyEnabled(): bool + { + return $this->attributes['lazyProxy'] ?? false; + } + + public function setLazyProxyEnabled(bool $lazyProxy): void + { + if (PHP_VERSION_ID < 80400) { + throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.'); + } + + $this->attributes['lazyProxy'] = $lazyProxy; + } + /** * To be deprecated in 3.1.0 * diff --git a/src/Proxy/ProxyFactory.php b/src/Proxy/ProxyFactory.php index b2d114a6698..4cb03456d2c 100644 --- a/src/Proxy/ProxyFactory.php +++ b/src/Proxy/ProxyFactory.php @@ -163,8 +163,43 @@ public function __construct( * @param class-string $className * @param array $identifier */ - public function getProxy(string $className, array $identifier): InternalProxy + public function getProxy(string $className, array $identifier): object { + if ($this->em->getConfiguration()->isLazyProxyEnabled()) { + $classMetadata = $this->em->getClassMetadata($className); + $entityPersister = $this->uow->getEntityPersister($className); + + $proxy = $classMetadata->reflClass->newLazyGhost(static function ($object) use ($identifier, $entityPersister): void { + $entityPersister->loadById($identifier, $object); + }); + + foreach ($identifier as $idField => $value) { + $classMetadata->reflFields[$idField]->setRawValueWithoutLazyInitialization($proxy, $value); + } + + // todo: this skipLazyInitialization for properites calculation must be moved into ClassMetadata partially + $identifiers = array_flip($classMetadata->getIdentifierFieldNames()); + $filter = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE; + $reflector = $classMetadata->getReflectionClass(); + + while ($reflector) { + foreach ($reflector->getProperties($filter) as $property) { + $name = $property->name; + + if ($property->isStatic() || (($classMetadata->hasField($name) || $classMetadata->hasAssociation($name)) && ! isset($identifiers[$name]))) { + continue; + } + + $property->skipLazyInitialization($proxy); + } + + $filter = ReflectionProperty::IS_PRIVATE; + $reflector = $reflector->getParentClass(); + } + + return $proxy; + } + $proxyFactory = $this->proxyFactories[$className] ?? $this->getProxyFactory($className); return $proxyFactory($identifier); @@ -182,6 +217,10 @@ public function getProxy(string $className, array $identifier): InternalProxy */ public function generateProxyClasses(array $classes, string|null $proxyDir = null): int { + if ($this->em->getConfiguration()->isLazyProxyEnabled()) { + return 0; + } + $generated = 0; foreach ($classes as $class) { diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 85a33620aa8..ba4eefc9e04 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -49,6 +49,7 @@ use Doctrine\Persistence\PropertyChangedListener; use Exception; use InvalidArgumentException; +use ReflectionObject; use RuntimeException; use Stringable; use Throwable; @@ -64,6 +65,7 @@ use function array_values; use function assert; use function current; +use function get_class; use function get_debug_type; use function implode; use function in_array; @@ -2383,7 +2385,11 @@ public function createEntity(string $className, array $data, array &$hints = []) } if ($this->isUninitializedObject($entity)) { - $entity->__setInitialized(true); + if ($this->em->getConfiguration()->isLazyProxyEnabled()) { + $class->reflClass->markLazyObjectAsInitialized($entity); + } else { + $entity->__setInitialized(true); + } } else { if ( ! isset($hints[Query::HINT_REFRESH]) @@ -3040,6 +3046,11 @@ public function initializeObject(object $obj): void if ($obj instanceof PersistentCollection) { $obj->initialize(); } + + if ($this->em->getConfiguration()->isLazyProxyEnabled()) { + $reflection = new ReflectionObject($obj); + $reflection->initializeLazyObject($obj); + } } /** @@ -3049,6 +3060,10 @@ public function initializeObject(object $obj): void */ public function isUninitializedObject(mixed $obj): bool { + if ($this->em->getConfiguration()->isLazyProxyEnabled() && ! ($obj instanceof Collection)) { + return $this->em->getClassMetadata(get_class($obj))->reflClass->isUninitializedLazyObject($obj); + } + return $obj instanceof InternalProxy && ! $obj->__isInitialized(); } diff --git a/tests/Tests/ORM/Functional/BasicFunctionalTest.php b/tests/Tests/ORM/Functional/BasicFunctionalTest.php index fe03a864060..7758c5baf2d 100644 --- a/tests/Tests/ORM/Functional/BasicFunctionalTest.php +++ b/tests/Tests/ORM/Functional/BasicFunctionalTest.php @@ -557,7 +557,7 @@ public function testSetToOneAssociationWithGetReference(): void $this->_em->persist($article); $this->_em->flush(); - self::assertFalse($userRef->__isInitialized()); + self::assertTrue($this->isUninitializedObject($userRef)); $this->_em->clear(); @@ -592,7 +592,7 @@ public function testAddToToManyAssociationWithGetReference(): void $this->_em->persist($user); $this->_em->flush(); - self::assertFalse($groupRef->__isInitialized()); + self::assertTrue($this->isUninitializedObject($groupRef)); $this->_em->clear(); @@ -940,8 +940,7 @@ public function testManyToOneFetchModeQuery(): void ->setParameter(1, $article->id) ->setFetchMode(CmsArticle::class, 'user', ClassMetadata::FETCH_EAGER) ->getSingleResult(); - self::assertInstanceOf(InternalProxy::class, $article->user, 'It IS a proxy, ...'); - self::assertFalse($this->isUninitializedObject($article->user), '...but its initialized!'); + self::assertFalse($this->isUninitializedObject($article->user)); $this->assertQueryCount(2); } diff --git a/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php b/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php index 0cc8776ba50..df8ca3b9187 100644 --- a/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php +++ b/tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php @@ -34,6 +34,10 @@ protected function setUp(): void { parent::setUp(); + if ($this->_em->getConfiguration()->isLazyProxyEnabled()) { + self::markTestSkipped('This test is not applicable when lazy proxy is enabled.'); + } + $this->createSchemaForModels( CmsUser::class, CmsTag::class, diff --git a/tests/Tests/ORM/Functional/ReferenceProxyTest.php b/tests/Tests/ORM/Functional/ReferenceProxyTest.php index 55f65956757..a9e9d1952a3 100644 --- a/tests/Tests/ORM/Functional/ReferenceProxyTest.php +++ b/tests/Tests/ORM/Functional/ReferenceProxyTest.php @@ -248,7 +248,6 @@ public function testCommonPersistenceProxy(): void assert($entity instanceof ECommerceProduct); $className = DefaultProxyClassNameResolver::getClass($entity); - self::assertInstanceOf(InternalProxy::class, $entity); self::assertTrue($this->isUninitializedObject($entity)); self::assertEquals(ECommerceProduct::class, $className); @@ -257,7 +256,7 @@ public function testCommonPersistenceProxy(): void $proxyFileName = $this->_em->getConfiguration()->getProxyDir() . DIRECTORY_SEPARATOR . str_replace('\\', '', $restName) . '.php'; self::assertTrue(file_exists($proxyFileName), 'Proxy file name cannot be found generically.'); - $entity->__load(); + $this->initializeObject($entity); self::assertFalse($this->isUninitializedObject($entity)); } } diff --git a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php index a3350aa5e6c..ae0a36b0f92 100644 --- a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php +++ b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php @@ -11,7 +11,6 @@ use Doctrine\ORM\Cache\Exception\CacheException; use Doctrine\ORM\Cache\QueryCacheEntry; use Doctrine\ORM\Cache\QueryCacheKey; -use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\Tests\Models\Cache\Attraction; @@ -939,7 +938,6 @@ public function testResolveAssociationCacheEntry(): void self::assertNotNull($state1->getCountry()); $this->assertQueryCount(1); self::assertInstanceOf(State::class, $state1); - self::assertInstanceOf(InternalProxy::class, $state1->getCountry()); self::assertEquals($countryName, $state1->getCountry()->getName()); self::assertEquals($stateId, $state1->getId()); @@ -957,7 +955,6 @@ public function testResolveAssociationCacheEntry(): void self::assertNotNull($state2->getCountry()); $this->assertQueryCount(0); self::assertInstanceOf(State::class, $state2); - self::assertInstanceOf(InternalProxy::class, $state2->getCountry()); self::assertEquals($countryName, $state2->getCountry()->getName()); self::assertEquals($stateId, $state2->getId()); } @@ -1031,7 +1028,6 @@ public function testResolveToManyAssociationCacheEntry(): void $this->assertQueryCount(1); self::assertInstanceOf(State::class, $state1); - self::assertInstanceOf(InternalProxy::class, $state1->getCountry()); self::assertInstanceOf(City::class, $state1->getCities()->get(0)); self::assertInstanceOf(State::class, $state1->getCities()->get(0)->getState()); self::assertSame($state1, $state1->getCities()->get(0)->getState()); @@ -1048,7 +1044,6 @@ public function testResolveToManyAssociationCacheEntry(): void $this->assertQueryCount(0); self::assertInstanceOf(State::class, $state2); - self::assertInstanceOf(InternalProxy::class, $state2->getCountry()); self::assertInstanceOf(City::class, $state2->getCities()->get(0)); self::assertInstanceOf(State::class, $state2->getCities()->get(0)->getState()); self::assertSame($state2, $state2->getCities()->get(0)->getState()); diff --git a/tests/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php b/tests/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php index 7ed7732526c..5b8919af6f5 100644 --- a/tests/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php +++ b/tests/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php @@ -198,8 +198,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // load from cache $this->getQueryLog()->reset()->enable(); @@ -212,8 +210,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // invalidate cache $this->_em->persist(new State('foo', $this->_em->find(Country::class, $this->countries[0]->getId()))); @@ -231,8 +227,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); // load from cache $this->getQueryLog()->reset()->enable(); @@ -245,7 +239,5 @@ public function testRepositoryCacheFindAllToOneAssociation(): void self::assertInstanceOf(State::class, $entities[1]); self::assertInstanceOf(Country::class, $entities[0]->getCountry()); self::assertInstanceOf(Country::class, $entities[1]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry()); - self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry()); } } diff --git a/tests/Tests/ORM/Functional/Ticket/DDC1238Test.php b/tests/Tests/ORM/Functional/Ticket/DDC1238Test.php index 7a3cce370fd..72dc3496e74 100644 --- a/tests/Tests/ORM/Functional/Ticket/DDC1238Test.php +++ b/tests/Tests/ORM/Functional/Ticket/DDC1238Test.php @@ -57,11 +57,11 @@ public function testIssueProxyClear(): void $user2 = $this->_em->getReference(DDC1238User::class, $userId); - //$user->__load(); + //$this->initializeObject($user); self::assertIsInt($user->getId(), 'Even if a proxy is detached, it should still have an identifier'); - $user2->__load(); + $this->initializeObject($user2); self::assertIsInt($user2->getId(), 'The managed instance still has an identifier'); } diff --git a/tests/Tests/ORM/Functional/Ticket/GH10808Test.php b/tests/Tests/ORM/Functional/Ticket/GH10808Test.php index 0e893233442..49334b53bcd 100644 --- a/tests/Tests/ORM/Functional/Ticket/GH10808Test.php +++ b/tests/Tests/ORM/Functional/Ticket/GH10808Test.php @@ -49,18 +49,11 @@ public function testDQLDeferredEagerLoad(): void // Clear the EM to prevent the recovery of the loaded instance, which would otherwise result in a proxy. $this->_em->clear(); + self::assertTrue($this->_em->getUnitOfWork()->isUninitializedObject($deferredLoadResult->child)); + $eagerLoadResult = $query->setHint(UnitOfWork::HINT_DEFEREAGERLOAD, false)->getSingleResult(); - self::assertNotEquals( - GH10808AppointmentChild::class, - get_class($deferredLoadResult->child), - '$deferredLoadResult->child should be a proxy', - ); - self::assertEquals( - GH10808AppointmentChild::class, - get_class($eagerLoadResult->child), - '$eagerLoadResult->child should not be a proxy', - ); + self::assertFalse($this->_em->getUnitOfWork()->isUninitializedObject($eagerLoadResult->child)); } } diff --git a/tests/Tests/OrmFunctionalTestCase.php b/tests/Tests/OrmFunctionalTestCase.php index edec9ca261e..7bee4958914 100644 --- a/tests/Tests/OrmFunctionalTestCase.php +++ b/tests/Tests/OrmFunctionalTestCase.php @@ -22,6 +22,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\ORM\Proxy\InternalProxy; use Doctrine\ORM\Tools\DebugUnitOfWorkListener; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\ToolsException; @@ -193,6 +194,7 @@ use function var_export; use const PHP_EOL; +use const PHP_VERSION_ID; /** * Base testcase class for all functional ORM testcases. @@ -941,6 +943,12 @@ protected function getEntityManager( $this->isSecondLevelCacheEnabled = true; } + $enableLazyProxy = getenv('ENABLE_LAZY_PROXY'); + + if (PHP_VERSION_ID >= 80400 && $enableLazyProxy) { + $config->setLazyProxyEnabled(true); + } + $config->setMetadataDriverImpl( $mappingDriver ?? new AttributeDriver([ realpath(__DIR__ . '/Models/Cache'), @@ -1118,4 +1126,9 @@ final protected function isUninitializedObject(object $entity): bool { return $this->_em->getUnitOfWork()->isUninitializedObject($entity); } + + final protected function initializeObject(object $entity): void + { + $this->_em->getUnitOfWork()->initializeObject($entity); + } }