Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for PHP 8.4 Lazy Objects RFC with configuration flag #11853

Open
wants to merge 15 commits into
base: 3.4.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,27 @@ jobs:
- "pdo_sqlite"
deps:
- "highest"
lazy_proxy:
- "0"
include:
- php-version: "8.2"
dbal-version: "4@dev"
extension: "pdo_sqlite"
lazy_proxy: "0"
- php-version: "8.2"
dbal-version: "4@dev"
extension: "sqlite3"
lazy_proxy: "0"
- php-version: "8.1"
dbal-version: "default"
deps: "lowest"
extension: "pdo_sqlite"
lazy_proxy: "0"
- php-version: "8.4"
dbal-version: "default"
deps: "highest"
extension: "pdo_sqlite"
lazy_proxy: "1"

steps:
- name: "Checkout"
Expand Down Expand Up @@ -85,16 +95,18 @@ jobs:
run: "vendor/bin/phpunit -c ci/github/phpunit/${{ matrix.extension }}.xml --coverage-clover=coverage-no-cache.xml"
env:
ENABLE_SECOND_LEVEL_CACHE: 0
ENABLE_LAZY_PROXY: ${{ matrix.lazy_proxy }}

- name: "Run PHPUnit with Second Level Cache"
run: "vendor/bin/phpunit -c ci/github/phpunit/${{ matrix.extension }}.xml --exclude-group performance,non-cacheable,locking_functional --coverage-clover=coverage-cache.xml"
env:
ENABLE_SECOND_LEVEL_CACHE: 1
ENABLE_LAZY_PROXY: ${{ matrix.lazy_proxy }}

- name: "Upload coverage file"
uses: "actions/upload-artifact@v4"
with:
name: "phpunit-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-${{ matrix.deps }}-coverage"
name: "phpunit-${{ matrix.extension }}-${{ matrix.php-version }}-${{ matrix.dbal-version }}-${{ matrix.deps }}-${{ matrix.lazy_proxy }}-coverage"
path: "coverage*.xml"


Expand Down
8 changes: 1 addition & 7 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ parameters:
path: src/EntityManager.php

-
message: '#^Method Doctrine\\ORM\\EntityManager\:\:getReference\(\) should return \(T of object\)\|null but returns Doctrine\\ORM\\Proxy\\InternalProxy\.$#'
message: '#^Method Doctrine\\ORM\\EntityManager\:\:getReference\(\) should return \(T of object\)\|null but returns object\.$#'
identifier: return.type
count: 1
path: src/EntityManager.php
Expand Down Expand Up @@ -2322,12 +2322,6 @@ parameters:
count: 1
path: src/Proxy/ProxyFactory.php

-
message: '#^Method Doctrine\\ORM\\Proxy\\ProxyFactory\:\:getProxy\(\) return type with generic interface Doctrine\\ORM\\Proxy\\InternalProxy does not specify its types\: T$#'
identifier: missingType.generics
count: 1
path: src/Proxy/ProxyFactory.php

-
message: '#^Method Doctrine\\ORM\\Proxy\\ProxyFactory\:\:loadProxyClass\(\) has parameter \$class with generic interface Doctrine\\Persistence\\Mapping\\ClassMetadata but does not specify its types\: T$#'
identifier: missingType.generics
Expand Down
16 changes: 16 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -593,6 +595,20 @@
$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.');

Check warning on line 606 in src/Configuration.php

View check run for this annotation

Codecov / codecov/patch

src/Configuration.php#L606

Added line #L606 was not covered by tests
}

$this->attributes['lazyProxy'] = $lazyProxy;
}

/**
* To be deprecated in 3.1.0
*
Expand Down
8 changes: 7 additions & 1 deletion src/Mapping/PropertyAccessors/RawValuePropertyAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
namespace Doctrine\ORM\Mapping\PropertyAccessors;

use Doctrine\ORM\Proxy\InternalProxy;
use LogicException;
use ReflectionProperty;

use function ltrim;

use const PHP_VERSION_ID;

/**
* This is a PHP 8.4 and up only class and replaces ObjectCastPropertyAccessor.
*
Expand All @@ -28,12 +31,15 @@

private function __construct(private ReflectionProperty $reflectionProperty, private string $key)
{
if (PHP_VERSION_ID < 80400) {
throw new LogicException('This class requires PHP 8.4 or higher.');

Check warning on line 35 in src/Mapping/PropertyAccessors/RawValuePropertyAccessor.php

View check run for this annotation

Codecov / codecov/patch

src/Mapping/PropertyAccessors/RawValuePropertyAccessor.php#L35

Added line #L35 was not covered by tests
}
}

public function setValue(object $object, mixed $value): void
{
if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) {
$this->reflectionProperty->setRawValue($object, $value);
$this->reflectionProperty->setRawValueWithoutLazyInitialization($object, $value);

return;
}
Expand Down
21 changes: 20 additions & 1 deletion src/Proxy/ProxyFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,23 @@
* @param class-string $className
* @param array<mixed> $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->propertyAccessors[$idField]->setValue($proxy, $value);
}

return $proxy;
}

$proxyFactory = $this->proxyFactories[$className] ?? $this->getProxyFactory($className);

return $proxyFactory($identifier);
Expand All @@ -182,6 +197,10 @@
*/
public function generateProxyClasses(array $classes, string|null $proxyDir = null): int
{
if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
return 0;

Check warning on line 201 in src/Proxy/ProxyFactory.php

View check run for this annotation

Codecov / codecov/patch

src/Proxy/ProxyFactory.php#L201

Added line #L201 was not covered by tests
}

$generated = 0;

foreach ($classes as $class) {
Expand Down
17 changes: 16 additions & 1 deletion src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -2378,7 +2378,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])
Expand Down Expand Up @@ -3033,6 +3037,13 @@ public function initializeObject(object $obj): void

if ($obj instanceof PersistentCollection) {
$obj->initialize();

return;
}

if ($this->em->getConfiguration()->isLazyProxyEnabled()) {
$reflection = $this->em->getClassMetadata($obj::class)->getReflectionClass();
$reflection->initializeLazyObject($obj);
}
}

Expand All @@ -3043,6 +3054,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($obj::class)->reflClass->isUninitializedLazyObject($obj);
}

return $obj instanceof InternalProxy && ! $obj->__isInitialized();
}

Expand Down
8 changes: 3 additions & 5 deletions tests/Tests/ORM/Functional/BasicFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\ORMInvalidArgumentException;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\Proxy\InternalProxy;
use Doctrine\ORM\Query;
use Doctrine\ORM\UnitOfWork;
use Doctrine\Tests\IterableTester;
Expand Down Expand Up @@ -557,7 +556,7 @@ public function testSetToOneAssociationWithGetReference(): void
$this->_em->persist($article);
$this->_em->flush();

self::assertFalse($userRef->__isInitialized());
self::assertTrue($this->isUninitializedObject($userRef));

$this->_em->clear();

Expand Down Expand Up @@ -592,7 +591,7 @@ public function testAddToToManyAssociationWithGetReference(): void
$this->_em->persist($user);
$this->_em->flush();

self::assertFalse($groupRef->__isInitialized());
self::assertTrue($this->isUninitializedObject($groupRef));

$this->_em->clear();

Expand Down Expand Up @@ -940,8 +939,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);
}

Expand Down
22 changes: 11 additions & 11 deletions tests/Tests/ORM/Functional/LifecycleCallbackTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function testPreSavePostSaveCallbacksAreInvoked(): void

$query = $this->_em->createQuery('select e from Doctrine\Tests\ORM\Functional\LifecycleCallbackTestEntity e');
$result = $query->getResult();
self::assertTrue($result[0]->postLoadCallbackInvoked);
self::assertTrue($result[0]::$postLoadCallbackInvoked);

$result[0]->value = 'hello again';

Expand Down Expand Up @@ -132,10 +132,10 @@ public function testGetReferenceWithPostLoadEventIsDelayedUntilProxyTrigger(): v
$this->_em->clear();

$reference = $this->_em->getReference(LifecycleCallbackTestEntity::class, $id);
self::assertFalse($reference->postLoadCallbackInvoked);
self::assertArrayNotHasKey('postLoadCallbackInvoked', (array) $reference);

$reference->getValue(); // trigger proxy load
self::assertTrue($reference->postLoadCallbackInvoked);
self::assertTrue($reference::$postLoadCallbackInvoked);
}

#[Group('DDC-958')]
Expand All @@ -150,11 +150,11 @@ public function testPostLoadTriggeredOnRefresh(): void
$this->_em->clear();

$reference = $this->_em->find(LifecycleCallbackTestEntity::class, $id);
self::assertTrue($reference->postLoadCallbackInvoked);
self::assertTrue($reference::$postLoadCallbackInvoked);
$reference->postLoadCallbackInvoked = false;

$this->_em->refresh($reference);
self::assertTrue($reference->postLoadCallbackInvoked, 'postLoad should be invoked when refresh() is called.');
self::assertTrue($reference::$postLoadCallbackInvoked, 'postLoad should be invoked when refresh() is called.');
}

#[Group('DDC-113')]
Expand Down Expand Up @@ -212,7 +212,7 @@ public function testCascadedEntitiesLoadedInPostLoad(): void
->createQuery(sprintf($dql, $e1->getId(), $e2->getId()))
->getResult();

self::assertTrue(current($entities)->postLoadCallbackInvoked);
self::assertTrue(current($entities)::$postLoadCallbackInvoked);
self::assertTrue(current($entities)->postLoadCascaderNotNull);
self::assertTrue(current($entities)->cascader->postLoadCallbackInvoked);
self::assertEquals(current($entities)->cascader->postLoadEntitiesCount, 2);
Expand Down Expand Up @@ -252,7 +252,7 @@ public function testCascadedEntitiesNotLoadedInPostLoadDuringIteration(): void
$iterableResult = iterator_to_array($query->toIterable());

foreach ($iterableResult as $entity) {
self::assertTrue($entity->postLoadCallbackInvoked);
self::assertTrue($entity::$postLoadCallbackInvoked);
self::assertFalse($entity->postLoadCascaderNotNull);

break;
Expand All @@ -276,7 +276,7 @@ public function testCascadedEntitiesNotLoadedInPostLoadDuringIterationWithSimple
$result = iterator_to_array($query->toIterable([], Query::HYDRATE_SIMPLEOBJECT));

foreach ($result as $entity) {
self::assertTrue($entity->postLoadCallbackInvoked);
self::assertTrue($entity::$postLoadCallbackInvoked);
self::assertFalse($entity->postLoadCascaderNotNull);

break;
Expand Down Expand Up @@ -320,7 +320,7 @@ public function testPostLoadIsInvokedOnFetchJoinedEntities(): void

self::assertTrue($fetchedA->postLoadCallbackInvoked);
foreach ($fetchedA->entities as $fetchJoinedEntB) {
self::assertTrue($fetchJoinedEntB->postLoadCallbackInvoked);
self::assertTrue($fetchJoinedEntB::$postLoadCallbackInvoked);
}
}

Expand Down Expand Up @@ -455,7 +455,7 @@ class LifecycleCallbackTestEntity
public $postPersistCallbackInvoked = false;

/** @var bool */
public $postLoadCallbackInvoked = false;
public static $postLoadCallbackInvoked = false;

/** @var bool */
public $postLoadCascaderNotNull = false;
Expand Down Expand Up @@ -502,7 +502,7 @@ public function doStuffOnPostPersist(): void
#[PostLoad]
public function doStuffOnPostLoad(): void
{
$this->postLoadCallbackInvoked = true;
self::$postLoadCallbackInvoked = true;
$this->postLoadCascaderNotNull = isset($this->cascader);
}

Expand Down
4 changes: 4 additions & 0 deletions tests/Tests/ORM/Functional/ProxiesLikeEntitiesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions tests/Tests/ORM/Functional/ReferenceProxyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use Doctrine\Common\Proxy\Proxy as CommonProxy;
use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver;
use Doctrine\ORM\Proxy\InternalProxy;
use Doctrine\Tests\Models\Company\CompanyAuction;
use Doctrine\Tests\Models\ECommerce\ECommerceProduct;
use Doctrine\Tests\Models\ECommerce\ECommerceProduct2;
Expand Down Expand Up @@ -242,13 +241,16 @@ public function testInitializeProxyOnGettingSomethingOtherThanTheIdentifier(): v
#[Group('DDC-1604')]
public function testCommonPersistenceProxy(): void
{
if ($this->_em->getConfiguration()->isLazyProxyEnabled()) {
self::markTestSkipped('Test only works with proxy generation disabled.');
}

$id = $this->createProduct();

$entity = $this->_em->getReference(ECommerceProduct::class, $id);
assert($entity instanceof ECommerceProduct);
$className = DefaultProxyClassNameResolver::getClass($entity);

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

Expand All @@ -257,7 +259,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));
}
}
Loading