Skip to content

Commit 20ac541

Browse files
committed
Add support for PHP 8.4 Lazy Objects with configuration flag
1 parent 15405f6 commit 20ac541

File tree

11 files changed

+296
-27
lines changed

11 files changed

+296
-27
lines changed

.github/workflows/continuous-integration.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ jobs:
7777
dependencies: "highest"
7878
symfony-version: "stable"
7979
proxy: "proxy-manager"
80+
# Test with Native Lazy Objects
81+
- php-version: "8.4"
82+
mongodb-version: "8.0"
83+
driver-version: "stable"
84+
dependencies: "highest"
85+
symfony-version: "stable"
86+
proxy: "native"
8087
# Test with extension 1.21
8188
- topology: "server"
8289
php-version: "8.2"
@@ -163,4 +170,5 @@ jobs:
163170
env:
164171
DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }}
165172
USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}"
173+
USE_NATIVE_LAZY_OBJECTS: ${{ matrix.proxy == 'native' && '1' || '0' }}"
166174
CRYPT_SHARED_LIB_PATH: ${{ steps.setup-mongodb.outputs.crypt-shared-lib-path }}

lib/Doctrine/ODM/MongoDB/Configuration.php

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
use function trigger_deprecation;
4848
use function trim;
4949

50+
use const PHP_VERSION_ID;
51+
5052
/**
5153
* Configuration class for the DocumentManager. When setting up your DocumentManager
5254
* you can optionally specify an instance of this class as the second argument.
@@ -145,7 +147,8 @@ class Configuration
145147

146148
private bool $useTransactionalFlush = false;
147149

148-
private bool $useLazyGhostObject = false;
150+
private bool $lazyGhostObject = false;
151+
private bool $nativeLazyObjects = false;
149152

150153
private static string $version;
151154

@@ -686,26 +689,49 @@ public function isTransactionalFlushEnabled(): bool
686689
* Generate proxy classes using Symfony VarExporter's LazyGhostTrait if true.
687690
* Otherwise, use ProxyManager's LazyLoadingGhostFactory (deprecated)
688691
*/
689-
public function setUseLazyGhostObject(bool $flag): void
692+
public function setLazyGhostObject(bool $flag): void
690693
{
691-
if ($flag === false) {
694+
if ($this->nativeLazyObjects) {
695+
throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.');
696+
}
697+
698+
if ($flag) {
692699
if (! class_exists(ProxyManagerConfiguration::class)) {
693700
throw new LogicException('Package "friendsofphp/proxy-manager-lts" is required to disable LazyGhostObject.');
694701
}
695702

696-
trigger_deprecation(
697-
'doctrine/mongodb-odm',
698-
'2.10',
699-
'Using "friendsofphp/proxy-manager-lts" is deprecated. Use "symfony/var-exporter" LazyGhostObjects instead.',
700-
);
703+
trigger_deprecation('doctrine/mongodb-odm', '2.10', 'Using "friendsofphp/proxy-manager-lts" is deprecated. Use "symfony/var-exporter" LazyGhostObjects instead.');
701704
}
702705

703-
$this->useLazyGhostObject = $flag;
706+
if ($flag === true && PHP_VERSION_ID >= 80400) {
707+
trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Using "symfony/var-exporter" lazy ghost objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.');
708+
}
709+
710+
$this->lazyGhostObject = $flag;
704711
}
705712

706713
public function isLazyGhostObjectEnabled(): bool
707714
{
708-
return $this->useLazyGhostObject;
715+
return $this->lazyGhostObject;
716+
}
717+
718+
public function enableNativeLazyObjects(bool $nativeLazyObjects): void
719+
{
720+
if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObjects) {
721+
trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.');
722+
}
723+
724+
if (PHP_VERSION_ID < 80400 && $nativeLazyObjects) {
725+
throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.');
726+
}
727+
728+
$this->nativeLazyObjects = $nativeLazyObjects;
729+
$this->lazyGhostObject = ! $nativeLazyObjects || $this->lazyGhostObject;
730+
}
731+
732+
public function isNativeLazyObjectsEnabled(): bool
733+
{
734+
return $this->nativeLazyObjects;
709735
}
710736

711737
/**

lib/Doctrine/ODM/MongoDB/DocumentManager.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface;
1111
use Doctrine\ODM\MongoDB\Mapping\MappingException;
1212
use Doctrine\ODM\MongoDB\Proxy\Factory\LazyGhostProxyFactory;
13+
use Doctrine\ODM\MongoDB\Proxy\Factory\NativeLazyObjectFactory;
1314
use Doctrine\ODM\MongoDB\Proxy\Factory\ProxyFactory;
1415
use Doctrine\ODM\MongoDB\Proxy\Factory\StaticProxyFactory;
1516
use Doctrine\ODM\MongoDB\Proxy\Resolver\CachingClassNameResolver;
@@ -31,7 +32,6 @@
3132
use MongoDB\Driver\ClientEncryption;
3233
use MongoDB\Driver\ReadPreference;
3334
use MongoDB\GridFS\Bucket;
34-
use ProxyManager\Proxy\GhostObjectInterface;
3535
use RuntimeException;
3636
use Throwable;
3737

@@ -179,11 +179,13 @@ protected function __construct(?Client $client = null, ?Configuration $config =
179179
$this->config->getAutoGenerateHydratorClasses(),
180180
);
181181

182-
$this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory);
183-
$this->schemaManager = new SchemaManager($this, $this->metadataFactory);
184-
$this->proxyFactory = $this->config->isLazyGhostObjectEnabled()
185-
? new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses())
186-
: new StaticProxyFactory($this);
182+
$this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory);
183+
$this->schemaManager = new SchemaManager($this, $this->metadataFactory);
184+
$this->proxyFactory = match (true) {
185+
$this->config->isNativeLazyObjectsEnabled() => new NativeLazyObjectFactory($this->unitOfWork),
186+
$this->config->isLazyGhostObjectEnabled() => new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses()),
187+
default => new StaticProxyFactory($this),
188+
};
187189
$this->repositoryFactory = $this->config->getRepositoryFactory();
188190
}
189191

@@ -607,7 +609,7 @@ public function flush(array $options = []): void
607609
* @param mixed $identifier
608610
* @param class-string<T> $documentName
609611
*
610-
* @return T|(T&GhostObjectInterface<T>)
612+
* @return T
611613
*
612614
* @template T of object
613615
*/
@@ -624,7 +626,7 @@ public function getReference(string $documentName, $identifier): object
624626
return $document;
625627
}
626628

627-
/** @var T&GhostObjectInterface<T> $document */
629+
/** @var T $document */
628630
$document = $this->proxyFactory->getProxy($class, $identifier);
629631
$this->unitOfWork->registerManaged($document, $identifier, []);
630632

lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Doctrine\ODM\MongoDB\Proxy\InternalProxy;
88
use LogicException;
9+
use ProxyManager\Proxy\GhostObjectInterface;
910
use ReflectionProperty;
1011

1112
use function ltrim;
@@ -38,7 +39,10 @@ private function __construct(private ReflectionProperty $reflectionProperty, pri
3839

3940
public function setValue(object $object, mixed $value): void
4041
{
41-
if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) {
42+
if (
43+
! ($object instanceof InternalProxy && ! $object->__isInitialized()) &&
44+
! ($object instanceof GhostObjectInterface && ! $object->isProxyInitialized())
45+
) {
4246
$this->reflectionProperty->setRawValueWithoutLazyInitialization($object, $value);
4347

4448
return;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ODM\MongoDB\Proxy\Factory;
6+
7+
use Doctrine\ODM\MongoDB\DocumentNotFoundException;
8+
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
9+
use Doctrine\ODM\MongoDB\UnitOfWork;
10+
use LogicException;
11+
use ReflectionClass;
12+
13+
use function count;
14+
15+
use const PHP_VERSION_ID;
16+
17+
class NativeLazyObjectFactory implements ProxyFactory
18+
{
19+
public function __construct(
20+
private readonly UnitOfWork $unitOfWork,
21+
) {
22+
if (PHP_VERSION_ID < 80400) {
23+
throw new LogicException('Native lazy objects require PHP 8.4 or higher.');
24+
}
25+
}
26+
27+
public function generateProxyClasses(array $classes): int
28+
{
29+
// Nothing to generate, that's the point of native lazy objects
30+
31+
return count($classes);
32+
}
33+
34+
public function getProxy(ClassMetadata $metadata, $identifier): object
35+
{
36+
$documentPersister = $this->unitOfWork->getDocumentPersister($metadata->name);
37+
38+
$proxy = $metadata->reflClass->newLazyGhost(static function (object $object) use (
39+
$identifier,
40+
$documentPersister,
41+
$metadata,
42+
): void {
43+
$original = $documentPersister->load([$metadata->identifier => $identifier], $object);
44+
if ($original === null) {
45+
throw DocumentNotFoundException::documentNotFound($metadata->name, $identifier);
46+
}
47+
}, ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE);
48+
49+
$metadata->propertyAccessors[$metadata->identifier]->setValue($proxy, $identifier);
50+
51+
return $proxy;
52+
}
53+
}

lib/Doctrine/ODM/MongoDB/UnitOfWork.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2783,12 +2783,14 @@ public function getOrCreateDocument(string $className, array $data, array &$hint
27832783
$document = $this->identityMap[$class->name][$serializedId];
27842784
$oid = spl_object_id($document);
27852785
if ($this->isUninitializedObject($document)) {
2786-
if ($document instanceof InternalProxy) {
2786+
if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) {
2787+
$class->reflClass->markLazyObjectAsInitialized($document);
2788+
} elseif ($document instanceof InternalProxy) {
27872789
$document->__setInitialized(true);
27882790
} elseif ($document instanceof GhostObjectInterface) {
27892791
$document->setProxyInitializer(null);
27902792
} else {
2791-
throw new \RuntimeException(sprintf('Expected uninitialized proxy or ghost object from class "%s"', $document::name));
2793+
throw new \RuntimeException(sprintf('Expected uninitialized proxy or ghost object from class "%s"', $document::class));
27922794
}
27932795

27942796
$overrideLocalValues = true;
@@ -3090,6 +3092,7 @@ public function isUninitializedObject(object $obj): bool
30903092
$obj instanceof InternalProxy => $obj->__isInitialized() === false,
30913093
$obj instanceof GhostObjectInterface => $obj->isProxyInitialized() === false,
30923094
$obj instanceof PersistentCollectionInterface => $obj->isInitialized() === false,
3095+
$this->dm->getConfiguration()->isNativeLazyObjectsEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj),
30933096
default => false
30943097
};
30953098
}

tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ protected static function getConfiguration(): Configuration
104104
$config->setPersistentCollectionNamespace('PersistentCollections');
105105
$config->setDefaultDB(DOCTRINE_MONGODB_DATABASE);
106106
$config->setMetadataDriverImpl(static::createMetadataDriverImpl());
107-
$config->setUseLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']);
107+
$config->setLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']);
108+
$config->enableNativeLazyObjects((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']);
108109

109110
$config->addFilter('testFilter', Filter::class);
110111
$config->addFilter('testFilter2', Filter::class);
@@ -134,6 +135,10 @@ public static function assertArraySubset(array $subset, array $array, bool $chec
134135

135136
public static function isLazyObject(object $document): bool
136137
{
138+
if (PHP_VERSION_ID >= 80400 && (new \ReflectionClass($document))->getLazyInitializer($document)) {
139+
return true;
140+
}
141+
137142
return $document instanceof InternalProxy || $document instanceof LazyLoadingInterface;
138143
}
139144

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,103 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Doctrine\ODM\MongoDB\Tests\Functional;
46

5-
class PropertyHooksTest
7+
use Doctrine\ODM\MongoDB\Mapping\MappingException;
8+
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
9+
use Documents\PropertyHooks\MappingVirtualProperty;
10+
use Documents\PropertyHooks\User;
11+
use PHPUnit\Framework\Attributes\RequiresPhp;
12+
13+
#[RequiresPhp('>= 8.4.0')]
14+
class PropertyHooksTest extends BaseTestCase
615
{
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
20+
if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) {
21+
return;
22+
}
23+
24+
$this->markTestSkipped('Property hooks require native lazy objects to be enabled.');
25+
}
26+
27+
public function testMapPropertyHooks(): void
28+
{
29+
$user = new User();
30+
$user->fullName = 'John Doe';
31+
$user->language = 'EN';
32+
33+
$this->dm->persist($user);
34+
$this->dm->flush();
35+
$this->dm->clear();
36+
37+
$user = $this->dm->find(User::class, $user->id);
38+
39+
self::assertSame('John', $user->first);
40+
self::assertSame('Doe', $user->last);
41+
self::assertSame('John Doe', $user->fullName);
42+
self::assertSame('EN', $user->language, 'The property hook uppercases the language.');
43+
44+
$document = $this->dm->createQueryBuilder()
45+
->find(User::class)
46+
->field('id')->equals($user->id)
47+
->select('language')
48+
->hydrate(false)
49+
->getQuery()
50+
->getSingleResult();
51+
52+
self::assertSame('en', $document['language'], 'Selecting a field without hydration does not go through the property hook, accessing raw data.');
53+
54+
$this->dm->clear();
55+
56+
$user = $this->dm->getRepository(User::class)->findOneBy(['language' => 'EN']);
57+
58+
self::assertNull($user);
59+
60+
$user = $this->dm->getRepository(User::class)->findOneBy(['language' => 'en']);
61+
62+
self::assertNotNull($user);
63+
}
64+
65+
public function testTriggerLazyLoadingWhenAccessingPropertyHooks(): void
66+
{
67+
$user = new User();
68+
$user->fullName = 'Ludwig von Beethoven';
69+
$user->language = 'DE';
70+
71+
$this->dm->persist($user);
72+
$this->dm->flush();
73+
$this->dm->clear();
74+
75+
$user = $this->dm->getReference(User::class, $user->id);
76+
77+
$this->assertTrue($this->dm->getUnitOfWork()->isUninitializedObject($user));
78+
79+
self::assertSame('Ludwig', $user->first);
80+
self::assertSame('von Beethoven', $user->last);
81+
self::assertSame('Ludwig von Beethoven', $user->fullName);
82+
self::assertSame('DE', $user->language, 'The property hook uppercases the language.');
83+
84+
$this->assertFalse($this->dm->getUnitOfWork()->isUninitializedObject($user));
85+
86+
$this->dm->clear();
87+
88+
$user = $this->dm->getReference(User::class, $user->id);
89+
90+
self::assertSame('Ludwig von Beethoven', $user->fullName);
91+
}
92+
93+
public function testMappingVirtualPropertyIsNotSupported(): void
94+
{
95+
// @todo remove if not relevant
96+
self::markTestSkipped('Imported from ORM, but there is no virtual property support in MongoDB ODM.');
97+
98+
$this->expectException(MappingException::class);
99+
$this->expectExceptionMessage('Mapping virtual property "fullName" on entity "Documents\PropertyHooks\MappingVirtualProperty" is not allowed.');
7100

8-
}
101+
$this->dm->getClassMetadata(MappingVirtualProperty::class);
102+
}
103+
}

tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ public function testPrimeReferencesWithDBRefObjects(): void
9494
->field('groups')->prime(true);
9595

9696
foreach ($qb->getQuery() as $user) {
97-
self::assertTrue(self::isLazyObject($user->getAccount()));
9897
self::assertFalse($this->uow->isUninitializedObject($user->getAccount()));
9998

10099
self::assertCount(2, $user->getGroups());

0 commit comments

Comments
 (0)