Skip to content

Commit 76df287

Browse files
committed
feat: create proxy system with PHP 8.4 lazy proxies
1 parent b0a4076 commit 76df287

File tree

10 files changed

+197
-4
lines changed

10 files changed

+197
-4
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ jobs:
329329
- name: Setup PHP
330330
uses: shivammathur/setup-php@v2
331331
with:
332-
php-version: 8.3
332+
php-version: 8.4
333333
coverage: none
334334

335335
- name: Install dependencies

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,18 @@
3232
"doctrine/common": "^3.2.2",
3333
"doctrine/doctrine-bundle": "^2.10",
3434
"doctrine/doctrine-migrations-bundle": "^2.2|^3.0",
35-
"doctrine/persistence": "^2.0|^3.0|^4.0",
36-
"doctrine/mongodb-odm-bundle": "^4.6|^5.0",
3735
"doctrine/mongodb-odm": "^2.4",
36+
"doctrine/mongodb-odm-bundle": "^4.6|^5.0",
3837
"doctrine/orm": "^2.16|^3.0",
38+
"doctrine/persistence": "^2.0|^3.0|^4.0",
3939
"phpunit/phpunit": "^9.5.0 || ^10.0 || ^11.0 || ^12.0",
40+
"symfony/browser-kit": "^7.2",
4041
"symfony/console": "^6.4|^7.0",
4142
"symfony/dotenv": "^6.4|^7.0",
4243
"symfony/framework-bundle": "^6.4|^7.0",
4344
"symfony/maker-bundle": "^1.55",
4445
"symfony/phpunit-bridge": "^6.4|^7.0",
46+
"symfony/routing": "^7.2",
4547
"symfony/runtime": "^6.4|^7.0",
4648
"symfony/translation-contracts": "^3.4",
4749
"symfony/uid": "^6.4|^7.0",

config/persistence.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
44

5+
use Symfony\Component\HttpKernel\Event\TerminateEvent;
56
use Zenstruck\Foundry\Persistence\PersistenceManager;
7+
use Zenstruck\Foundry\Persistence\Proxy\KernelTerminateListener;
68
use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager;
79

810
return static function (ContainerConfigurator $container): void {
@@ -18,4 +20,10 @@
1820
tagged_iterator('.foundry.persistence.schema_resetter'),
1921
])
2022
;
23+
24+
if (PHP_VERSION_ID >= 80400) {
25+
$container->services()->set('.foundry.proxy.kernel_terminate_listener', KernelTerminateListener::class)
26+
->tag('kernel.event_listener', ['event' => TerminateEvent::class, 'method' => '__invoke'])
27+
;
28+
}
2129
};

src/Persistence/PersistentObjectFactory.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship;
2828
use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship;
2929

30+
use Zenstruck\Foundry\Persistence\Proxy\CreatedObjectsTracker;
31+
3032
use function Zenstruck\Foundry\force;
3133
use function Zenstruck\Foundry\get;
3234
use function Zenstruck\Foundry\set;
@@ -454,6 +456,10 @@ final protected function initializeInternal(): static
454456
return parent::initializeInternal()
455457
->afterInstantiate(
456458
static function(object $object, array $parameters, PersistentObjectFactory $factoryUsed): void {
459+
if (PHP_VERSION_ID >= 80400) {
460+
CreatedObjectsTracker::add($object);
461+
}
462+
457463
if (!$factoryUsed->isPersisting()) {
458464
return;
459465
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry\Persistence\Proxy;
4+
5+
use WeakReference;
6+
7+
use function Zenstruck\Foundry\Persistence\refresh;
8+
9+
final class CreatedObjectsTracker
10+
{
11+
/** @var list<\WeakReference<object>> */
12+
private static $buffer = [];
13+
14+
public static function add(object $object): void
15+
{
16+
self::$buffer[] = \WeakReference::create($object);
17+
}
18+
19+
public static function proxifyObjects(): void
20+
{
21+
self::$buffer = array_values(
22+
array_map(
23+
static function (WeakReference $weakRef) {
24+
$object = $weakRef->get() ?? throw new \LogicException('Object cannot be null.');
25+
$clone = clone $object;
26+
(new \ReflectionClass($object))->resetAsLazyProxy($object, fn() => refresh($clone));
27+
28+
return \WeakReference::create($object);
29+
},
30+
array_filter(self::$buffer, static fn (WeakReference $weakRef) => $weakRef->get() !== null),
31+
)
32+
);
33+
}
34+
35+
public static function reset(): void
36+
{
37+
self::$buffer = [];
38+
}
39+
40+
public static function countObjects(): int
41+
{
42+
return \count(self::$buffer);
43+
}
44+
45+
public static function countObjectsWithValidRef(): int
46+
{
47+
return \count(
48+
array_filter(self::$buffer, static fn (WeakReference $weakRef) => $weakRef->get() !== null)
49+
);
50+
}
51+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry\Persistence\Proxy;
4+
5+
use Symfony\Component\HttpKernel\Event\TerminateEvent;
6+
7+
final class KernelTerminateListener
8+
{
9+
public function __invoke(TerminateEvent $event): void
10+
{
11+
CreatedObjectsTracker::proxifyObjects();
12+
}
13+
}

src/Test/Factories.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
1717
use Zenstruck\Foundry\Configuration;
1818

19+
use Zenstruck\Foundry\Persistence\Proxy\CreatedObjectsTracker;
20+
1921
use function Zenstruck\Foundry\Persistence\initialize_proxy_object;
2022

2123
/**
@@ -42,6 +44,7 @@ public function _beforeHook(): void
4244
public static function _shutdownFoundry(): void
4345
{
4446
Configuration::shutdown();
47+
CreatedObjectsTracker::reset();
4548
}
4649

4750
/**

tests/Fixture/TestKernel.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111

1212
namespace Zenstruck\Foundry\Tests\Fixture;
1313

14+
use Doctrine\ORM\EntityManagerInterface;
1415
use Symfony\Bundle\MakerBundle\MakerBundle;
1516
use Symfony\Component\Config\Loader\LoaderInterface;
1617
use Symfony\Component\DependencyInjection\ContainerBuilder;
18+
use Symfony\Component\HttpFoundation\Response;
19+
use Symfony\Component\Routing\Attribute\Route;
1720
use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode;
21+
use Zenstruck\Foundry\Tests\Fixture\Entity\Address;
22+
use Zenstruck\Foundry\Tests\Fixture\Entity\GenericEntity;
1823
use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory;
1924
use Zenstruck\Foundry\Tests\Fixture\Factories\Object1Factory;
2025
use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryAddressRepository;
@@ -56,4 +61,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
5661
$c->register(InMemoryAddressRepository::class)->setAutowired(true)->setAutoconfigured(true);
5762
$c->register(InMemoryContactRepository::class)->setAutowired(true)->setAutoconfigured(true);
5863
}
64+
65+
#[Route('/update/{id}', name: 'test')]
66+
public function __invoke(EntityManagerInterface $entityManager, int $id): Response
67+
{
68+
$genericEntity = $entityManager->find(GenericEntity::class, $id);
69+
$genericEntity?->setProp1('foo');
70+
$entityManager->flush();
71+
72+
return new Response();
73+
}
5974
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry\Tests\Integration\ORM;
4+
5+
use PHPUnit\Framework\Attributes\Depends;
6+
use PHPUnit\Framework\Attributes\RequiresPhp;
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
9+
use Zenstruck\Foundry\Persistence\Proxy\CreatedObjectsTracker;
10+
use Zenstruck\Foundry\Test\Factories;
11+
use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory;
12+
use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\GenericEntityFactory;
13+
14+
final class ProxyPHP84Test extends WebTestCase
15+
{
16+
use Factories;
17+
18+
/**
19+
* @test
20+
* @requires PHP >= 8.4
21+
*/
22+
#[Test]
23+
#[RequiresPhp('>= 8.4')]
24+
public function it_can_refresh_objects_with_php84_proxies(): void
25+
{
26+
$object = GenericEntityFactory::createOne();
27+
self::ensureKernelShutdown();
28+
29+
self::assertSame('default1', $object->getProp1());
30+
self::assertFalse((new \ReflectionClass($object))->isUninitializedLazyObject($object));
31+
32+
$client = self::createClient();
33+
$client->request('GET', "/update/{$object->id}");
34+
35+
self::assertTrue((new \ReflectionClass($object))->isUninitializedLazyObject($object));
36+
self::assertSame('foo', $object->getProp1());
37+
self::assertFalse((new \ReflectionClass($object))->isUninitializedLazyObject($object));
38+
}
39+
40+
/**
41+
* @test
42+
* @requires PHP >= 8.4
43+
* @depends it_can_refresh_objects_with_php84_proxies
44+
*/
45+
#[Test]
46+
#[RequiresPhp('>= 8.4')]
47+
#[Depends('it_can_refresh_objects_with_php84_proxies')]
48+
public function it_can_refresh_objects_with_php84_tracker_is_empty_after_test(): void
49+
{
50+
self::assertSame(0, CreatedObjectsTracker::countObjects());
51+
}
52+
53+
/**
54+
* @test
55+
* @requires PHP >= 8.4
56+
*/
57+
#[Test]
58+
#[RequiresPhp('>= 8.4')]
59+
public function tracker_only_keep_reference_for_objects_in_current_scope(): void
60+
{
61+
[$genericEntity] = GenericEntityFactory::new()->many(2)->create();
62+
ContactFactory::new()->many(2)->create();
63+
64+
// 8 = 2 GenericEntity + 2 Contact + 2 Address + 2 Category
65+
self::assertSame(8, CreatedObjectsTracker::countObjects());
66+
self::assertSame(8, CreatedObjectsTracker::countObjectsWithValidRef());
67+
68+
self::ensureKernelShutdown();
69+
70+
self::assertSame(8, CreatedObjectsTracker::countObjects());
71+
// kernel shutdown cleared the EM, then one of the generic entities was removed from tracker
72+
// all other entities are kept, because they have circular references
73+
self::assertSame(7, CreatedObjectsTracker::countObjectsWithValidRef());
74+
75+
gc_collect_cycles();
76+
77+
self::assertSame(8, CreatedObjectsTracker::countObjects());
78+
79+
// after gc collect, all entities created by ContactFactory are removed from tracker
80+
self::assertSame(1, CreatedObjectsTracker::countObjectsWithValidRef());
81+
82+
CreatedObjectsTracker::proxifyObjects();
83+
84+
// a call to proxifyObjects() will update the references in the tracker, to only keep the valid ones
85+
self::assertSame(1, CreatedObjectsTracker::countObjects());
86+
self::assertSame(1, CreatedObjectsTracker::countObjectsWithValidRef());
87+
88+
unset($genericEntity);
89+
CreatedObjectsTracker::proxifyObjects();
90+
91+
// unsetting the generic entity will remove it from the tracker as well
92+
self::assertSame(0, CreatedObjectsTracker::countObjects());
93+
}
94+
}

tests/Integration/Persistence/GenericFactoryTestCase.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHPUnit\Framework\Attributes\Depends;
1515
use PHPUnit\Framework\Attributes\Test;
1616
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
17+
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
1718
use Zenstruck\Foundry\Configuration;
1819
use Zenstruck\Foundry\Exception\PersistenceDisabled;
1920
use Zenstruck\Foundry\Object\Instantiator;
@@ -37,7 +38,7 @@
3738
/**
3839
* @author Kevin Bond <[email protected]>
3940
*/
40-
abstract class GenericFactoryTestCase extends KernelTestCase
41+
abstract class GenericFactoryTestCase extends WebTestCase
4142
{
4243
use Factories, ResetDatabase;
4344

0 commit comments

Comments
 (0)