Skip to content

Commit b9973a7

Browse files
committed
Introduce PHP 8.4 lazy proxy/ghost API.
1 parent 5077ae4 commit b9973a7

12 files changed

+98
-45
lines changed

phpstan-baseline.neon

+1-7
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ parameters:
583583
path: src/EntityManager.php
584584

585585
-
586-
message: '#^Method Doctrine\\ORM\\EntityManager\:\:getReference\(\) should return \(T of object\)\|null but returns Doctrine\\ORM\\Proxy\\InternalProxy\.$#'
586+
message: '#^Method Doctrine\\ORM\\EntityManager\:\:getReference\(\) should return \(T of object\)\|null but returns object\.$#'
587587
identifier: return.type
588588
count: 1
589589
path: src/EntityManager.php
@@ -2322,12 +2322,6 @@ parameters:
23222322
count: 1
23232323
path: src/Proxy/ProxyFactory.php
23242324

2325-
-
2326-
message: '#^Method Doctrine\\ORM\\Proxy\\ProxyFactory\:\:getProxy\(\) return type with generic interface Doctrine\\ORM\\Proxy\\InternalProxy does not specify its types\: T$#'
2327-
identifier: missingType.generics
2328-
count: 1
2329-
path: src/Proxy/ProxyFactory.php
2330-
23312325
-
23322326
message: '#^Method Doctrine\\ORM\\Proxy\\ProxyFactory\:\:loadProxyClass\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#'
23332327
identifier: missingType.generics

src/Configuration.php

+16
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
use function is_a;
3131
use function strtolower;
3232

33+
use const PHP_VERSION_ID;
34+
3335
/**
3436
* Configuration container for all configuration options of Doctrine.
3537
* It combines all configuration options from DBAL & ORM.
@@ -593,6 +595,20 @@ public function setSchemaIgnoreClasses(array $schemaIgnoreClasses): void
593595
$this->attributes['schemaIgnoreClasses'] = $schemaIgnoreClasses;
594596
}
595597

598+
public function isLazyProxyEnabled(): bool
599+
{
600+
return $this->attributes['lazyProxy'] ?? false;
601+
}
602+
603+
public function setLazyProxyEnabled(bool $lazyProxy): void
604+
{
605+
if (PHP_VERSION_ID < 80400) {
606+
throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.');
607+
}
608+
609+
$this->attributes['lazyProxy'] = $lazyProxy;
610+
}
611+
596612
/**
597613
* To be deprecated in 3.1.0
598614
*

src/Proxy/ProxyFactory.php

+40-1
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,43 @@ public function __construct(
163163
* @param class-string $className
164164
* @param array<mixed> $identifier
165165
*/
166-
public function getProxy(string $className, array $identifier): InternalProxy
166+
public function getProxy(string $className, array $identifier): object
167167
{
168+
if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
169+
$classMetadata = $this->em->getClassMetadata($className);
170+
$entityPersister = $this->uow->getEntityPersister($className);
171+
172+
$proxy = $classMetadata->reflClass->newLazyGhost(static function ($object) use ($identifier, $entityPersister): void {
173+
$entityPersister->loadById($identifier, $object);
174+
});
175+
176+
foreach ($identifier as $idField => $value) {
177+
$classMetadata->reflFields[$idField]->setRawValueWithoutLazyInitialization($proxy, $value);
178+
}
179+
180+
// todo: this skipLazyInitialization for properites calculation must be moved into ClassMetadata partially
181+
$identifiers = array_flip($classMetadata->getIdentifierFieldNames());
182+
$filter = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE;
183+
$reflector = $classMetadata->getReflectionClass();
184+
185+
while ($reflector) {
186+
foreach ($reflector->getProperties($filter) as $property) {
187+
$name = $property->name;
188+
189+
if ($property->isStatic() || (($classMetadata->hasField($name) || $classMetadata->hasAssociation($name)) && ! isset($identifiers[$name]))) {
190+
continue;
191+
}
192+
193+
$property->skipLazyInitialization($proxy);
194+
}
195+
196+
$filter = ReflectionProperty::IS_PRIVATE;
197+
$reflector = $reflector->getParentClass();
198+
}
199+
200+
return $proxy;
201+
}
202+
168203
$proxyFactory = $this->proxyFactories[$className] ?? $this->getProxyFactory($className);
169204

170205
return $proxyFactory($identifier);
@@ -182,6 +217,10 @@ public function getProxy(string $className, array $identifier): InternalProxy
182217
*/
183218
public function generateProxyClasses(array $classes, string|null $proxyDir = null): int
184219
{
220+
if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
221+
return 0;
222+
}
223+
185224
$generated = 0;
186225

187226
foreach ($classes as $class) {

src/UnitOfWork.php

+16-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
use Doctrine\Persistence\PropertyChangedListener;
4848
use Exception;
4949
use InvalidArgumentException;
50+
use ReflectionObject;
5051
use RuntimeException;
5152
use Stringable;
5253
use UnexpectedValueException;
@@ -61,6 +62,7 @@
6162
use function array_values;
6263
use function assert;
6364
use function current;
65+
use function get_class;
6466
use function get_debug_type;
6567
use function implode;
6668
use function in_array;
@@ -2378,7 +2380,11 @@ public function createEntity(string $className, array $data, array &$hints = [])
23782380
}
23792381

23802382
if ($this->isUninitializedObject($entity)) {
2381-
$entity->__setInitialized(true);
2383+
if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
2384+
$class->reflClass->markLazyObjectAsInitialized($entity);
2385+
} else {
2386+
$entity->__setInitialized(true);
2387+
}
23822388
} else {
23832389
if (
23842390
! isset($hints[Query::HINT_REFRESH])
@@ -3034,6 +3040,11 @@ public function initializeObject(object $obj): void
30343040
if ($obj instanceof PersistentCollection) {
30353041
$obj->initialize();
30363042
}
3043+
3044+
if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
3045+
$reflection = new ReflectionObject($obj);
3046+
$reflection->initializeLazyObject($obj);
3047+
}
30373048
}
30383049

30393050
/**
@@ -3043,6 +3054,10 @@ public function initializeObject(object $obj): void
30433054
*/
30443055
public function isUninitializedObject(mixed $obj): bool
30453056
{
3057+
if ($this->em->getConfiguration()->isLazyProxyEnabled() && ! ($obj instanceof Collection)) {
3058+
return $this->em->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj);
3059+
}
3060+
30463061
return $obj instanceof InternalProxy && ! $obj->__isInitialized();
30473062
}
30483063

tests/Tests/ORM/Functional/BasicFunctionalTest.php

+3-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use Doctrine\ORM\Mapping\ClassMetadata;
1010
use Doctrine\ORM\ORMInvalidArgumentException;
1111
use Doctrine\ORM\PersistentCollection;
12-
use Doctrine\ORM\Proxy\InternalProxy;
1312
use Doctrine\ORM\Query;
1413
use Doctrine\ORM\UnitOfWork;
1514
use Doctrine\Tests\IterableTester;
@@ -557,7 +556,7 @@ public function testSetToOneAssociationWithGetReference(): void
557556
$this->_em->persist($article);
558557
$this->_em->flush();
559558

560-
self::assertFalse($userRef->__isInitialized());
559+
self::assertTrue($this->isUninitializedObject($userRef));
561560

562561
$this->_em->clear();
563562

@@ -592,7 +591,7 @@ public function testAddToToManyAssociationWithGetReference(): void
592591
$this->_em->persist($user);
593592
$this->_em->flush();
594593

595-
self::assertFalse($groupRef->__isInitialized());
594+
self::assertTrue($this->isUninitializedObject($groupRef));
596595

597596
$this->_em->clear();
598597

@@ -940,8 +939,7 @@ public function testManyToOneFetchModeQuery(): void
940939
->setParameter(1, $article->id)
941940
->setFetchMode(CmsArticle::class, 'user', ClassMetadata::FETCH_EAGER)
942941
->getSingleResult();
943-
self::assertInstanceOf(InternalProxy::class, $article->user, 'It IS a proxy, ...');
944-
self::assertFalse($this->isUninitializedObject($article->user), '...but its initialized!');
942+
self::assertFalse($this->isUninitializedObject($article->user));
945943
$this->assertQueryCount(2);
946944
}
947945

tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ protected function setUp(): void
3434
{
3535
parent::setUp();
3636

37+
if ($this->_em->getConfiguration()->isLazyProxyEnabled()) {
38+
self::markTestSkipped('This test is not applicable when lazy proxy is enabled.');
39+
}
40+
3741
$this->createSchemaForModels(
3842
CmsUser::class,
3943
CmsTag::class,

tests/Tests/ORM/Functional/ReferenceProxyTest.php

+1-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Doctrine\Common\Proxy\Proxy as CommonProxy;
88
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
9-
use Doctrine\ORM\Proxy\InternalProxy;
109
use Doctrine\Tests\Models\Company\CompanyAuction;
1110
use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
1211
use Doctrine\Tests\Models\ECommerce\ECommerceProduct2;
@@ -248,7 +247,6 @@ public function testCommonPersistenceProxy(): void
248247
assert($entity instanceof ECommerceProduct);
249248
$className = DefaultProxyClassNameResolver::getClass($entity);
250249

251-
self::assertInstanceOf(InternalProxy::class, $entity);
252250
self::assertTrue($this->isUninitializedObject($entity));
253251
self::assertEquals(ECommerceProduct::class, $className);
254252

@@ -257,7 +255,7 @@ public function testCommonPersistenceProxy(): void
257255
$proxyFileName = $this->_em->getConfiguration()->getProxyDir() . DIRECTORY_SEPARATOR . str_replace('\\', '', $restName) . '.php';
258256
self::assertTrue(file_exists($proxyFileName), 'Proxy file name cannot be found generically.');
259257

260-
$entity->__load();
258+
$this->initializeObject($entity);
261259
self::assertFalse($this->isUninitializedObject($entity));
262260
}
263261
}

tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php

-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
use Doctrine\ORM\Cache\Exception\CacheException;
1212
use Doctrine\ORM\Cache\QueryCacheEntry;
1313
use Doctrine\ORM\Cache\QueryCacheKey;
14-
use Doctrine\ORM\Proxy\InternalProxy;
1514
use Doctrine\ORM\Query;
1615
use Doctrine\ORM\Query\ResultSetMapping;
1716
use Doctrine\Tests\Models\Cache\Attraction;
@@ -939,7 +938,6 @@ public function testResolveAssociationCacheEntry(): void
939938
self::assertNotNull($state1->getCountry());
940939
$this->assertQueryCount(1);
941940
self::assertInstanceOf(State::class, $state1);
942-
self::assertInstanceOf(InternalProxy::class, $state1->getCountry());
943941
self::assertEquals($countryName, $state1->getCountry()->getName());
944942
self::assertEquals($stateId, $state1->getId());
945943

@@ -957,7 +955,6 @@ public function testResolveAssociationCacheEntry(): void
957955
self::assertNotNull($state2->getCountry());
958956
$this->assertQueryCount(0);
959957
self::assertInstanceOf(State::class, $state2);
960-
self::assertInstanceOf(InternalProxy::class, $state2->getCountry());
961958
self::assertEquals($countryName, $state2->getCountry()->getName());
962959
self::assertEquals($stateId, $state2->getId());
963960
}
@@ -1031,7 +1028,6 @@ public function testResolveToManyAssociationCacheEntry(): void
10311028

10321029
$this->assertQueryCount(1);
10331030
self::assertInstanceOf(State::class, $state1);
1034-
self::assertInstanceOf(InternalProxy::class, $state1->getCountry());
10351031
self::assertInstanceOf(City::class, $state1->getCities()->get(0));
10361032
self::assertInstanceOf(State::class, $state1->getCities()->get(0)->getState());
10371033
self::assertSame($state1, $state1->getCities()->get(0)->getState());
@@ -1048,7 +1044,6 @@ public function testResolveToManyAssociationCacheEntry(): void
10481044

10491045
$this->assertQueryCount(0);
10501046
self::assertInstanceOf(State::class, $state2);
1051-
self::assertInstanceOf(InternalProxy::class, $state2->getCountry());
10521047
self::assertInstanceOf(City::class, $state2->getCities()->get(0));
10531048
self::assertInstanceOf(State::class, $state2->getCities()->get(0)->getState());
10541049
self::assertSame($state2, $state2->getCities()->get(0)->getState());

tests/Tests/ORM/Functional/SecondLevelCacheRepositoryTest.php

-9
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Doctrine\Tests\ORM\Functional;
66

7-
use Doctrine\ORM\Proxy\InternalProxy;
87
use Doctrine\Tests\Models\Cache\Country;
98
use Doctrine\Tests\Models\Cache\State;
109
use PHPUnit\Framework\Attributes\Group;
@@ -198,8 +197,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void
198197
self::assertInstanceOf(State::class, $entities[1]);
199198
self::assertInstanceOf(Country::class, $entities[0]->getCountry());
200199
self::assertInstanceOf(Country::class, $entities[0]->getCountry());
201-
self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry());
202-
self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry());
203200

204201
// load from cache
205202
$this->getQueryLog()->reset()->enable();
@@ -212,8 +209,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void
212209
self::assertInstanceOf(State::class, $entities[1]);
213210
self::assertInstanceOf(Country::class, $entities[0]->getCountry());
214211
self::assertInstanceOf(Country::class, $entities[1]->getCountry());
215-
self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry());
216-
self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry());
217212

218213
// invalidate cache
219214
$this->_em->persist(new State('foo', $this->_em->find(Country::class, $this->countries[0]->getId())));
@@ -231,8 +226,6 @@ public function testRepositoryCacheFindAllToOneAssociation(): void
231226
self::assertInstanceOf(State::class, $entities[1]);
232227
self::assertInstanceOf(Country::class, $entities[0]->getCountry());
233228
self::assertInstanceOf(Country::class, $entities[1]->getCountry());
234-
self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry());
235-
self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry());
236229

237230
// load from cache
238231
$this->getQueryLog()->reset()->enable();
@@ -245,7 +238,5 @@ public function testRepositoryCacheFindAllToOneAssociation(): void
245238
self::assertInstanceOf(State::class, $entities[1]);
246239
self::assertInstanceOf(Country::class, $entities[0]->getCountry());
247240
self::assertInstanceOf(Country::class, $entities[1]->getCountry());
248-
self::assertInstanceOf(InternalProxy::class, $entities[0]->getCountry());
249-
self::assertInstanceOf(InternalProxy::class, $entities[1]->getCountry());
250241
}
251242
}

tests/Tests/ORM/Functional/Ticket/DDC1238Test.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ public function testIssueProxyClear(): void
5757

5858
$user2 = $this->_em->getReference(DDC1238User::class, $userId);
5959

60-
//$user->__load();
60+
//$this->initializeObject($user);
6161

6262
self::assertIsInt($user->getId(), 'Even if a proxy is detached, it should still have an identifier');
6363

64-
$user2->__load();
64+
$this->initializeObject($user2);
6565

6666
self::assertIsInt($user2->getId(), 'The managed instance still has an identifier');
6767
}

tests/Tests/ORM/Functional/Ticket/GH10808Test.php

+3-12
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
use Doctrine\Tests\OrmFunctionalTestCase;
1616
use PHPUnit\Framework\Attributes\Group;
1717

18-
use function get_class;
19-
2018
#[Group('GH10808')]
2119
class GH10808Test extends OrmFunctionalTestCase
2220
{
@@ -49,18 +47,11 @@ public function testDQLDeferredEagerLoad(): void
4947
// Clear the EM to prevent the recovery of the loaded instance, which would otherwise result in a proxy.
5048
$this->_em->clear();
5149

50+
self::assertTrue($this->_em->getUnitOfWork()->isUninitializedObject($deferredLoadResult->child));
51+
5252
$eagerLoadResult = $query->setHint(UnitOfWork::HINT_DEFEREAGERLOAD, false)->getSingleResult();
5353

54-
self::assertNotEquals(
55-
GH10808AppointmentChild::class,
56-
get_class($deferredLoadResult->child),
57-
'$deferredLoadResult->child should be a proxy',
58-
);
59-
self::assertEquals(
60-
GH10808AppointmentChild::class,
61-
get_class($eagerLoadResult->child),
62-
'$eagerLoadResult->child should not be a proxy',
63-
);
54+
self::assertFalse($this->_em->getUnitOfWork()->isUninitializedObject($eagerLoadResult->child));
6455
}
6556
}
6657

tests/Tests/OrmFunctionalTestCase.php

+12
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@
193193
use function var_export;
194194

195195
use const PHP_EOL;
196+
use const PHP_VERSION_ID;
196197

197198
/**
198199
* Base testcase class for all functional ORM testcases.
@@ -941,6 +942,12 @@ protected function getEntityManager(
941942
$this->isSecondLevelCacheEnabled = true;
942943
}
943944

945+
$enableLazyProxy = getenv('ENABLE_LAZY_PROXY');
946+
947+
if (PHP_VERSION_ID >= 80400 && $enableLazyProxy) {
948+
$config->setLazyProxyEnabled(true);
949+
}
950+
944951
$config->setMetadataDriverImpl(
945952
$mappingDriver ?? new AttributeDriver([
946953
realpath(__DIR__ . '/Models/Cache'),
@@ -1118,4 +1125,9 @@ final protected function isUninitializedObject(object $entity): bool
11181125
{
11191126
return $this->_em->getUnitOfWork()->isUninitializedObject($entity);
11201127
}
1128+
1129+
final protected function initializeObject(object $entity): void
1130+
{
1131+
$this->_em->getUnitOfWork()->initializeObject($entity);
1132+
}
11211133
}

0 commit comments

Comments
 (0)