Skip to content

Commit a5291f8

Browse files
committed
feature: allow to create objects in dataProvider thanks to lazy proxy
1 parent 8bf8c4c commit a5291f8

21 files changed

+497
-155
lines changed

bin/console

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
<?php
33

44
use Symfony\Bundle\FrameworkBundle\Console\Application;
5-
use Zenstruck\Foundry\Tests\Fixtures\Kernel;
5+
use Zenstruck\Foundry\Tests\Fixture\TestKernel;
66

77
require_once __DIR__ . '/../tests/bootstrap.php';
88

9-
$application = new Application(new Kernel('test', true));
9+
$application = new Application(new TestKernel('test', true));
1010
$application->run();

phpunit-10.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,6 @@
1919
</source>
2020
<extensions>
2121
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
22+
<bootstrap class="Zenstruck\Foundry\PHPUnit\Extension" />
2223
</extensions>
2324
</phpunit>

src/Configuration.php

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ final class Configuration
3333
*/
3434
public $instantiator;
3535

36-
/** @var \Closure():self|self|null */
37-
private static \Closure|self|null $instance = null;
36+
private bool $bootedForDataProvider = false;
37+
38+
private static self|null $instance = null;
3839

3940
/**
4041
* @param InstantiatorCallable $instantiator
@@ -66,23 +67,29 @@ public function assertPersistanceEnabled(): void
6667
}
6768
}
6869

70+
public function inADataProvider(): bool
71+
{
72+
return $this->bootedForDataProvider;
73+
}
74+
6975
public static function instance(): self
7076
{
7177
if (!self::$instance) {
72-
throw new FoundryNotBooted('Foundry is not yet booted. Ensure ZenstruckFoundryBundle is enabled. If in a test, ensure your TestCase has the Factories trait.');
78+
throw new FoundryNotBooted();
7379
}
7480

75-
return \is_callable(self::$instance) ? (self::$instance)() : self::$instance;
81+
return self::$instance;
7682
}
7783

7884
public static function isBooted(): bool
7985
{
8086
return null !== self::$instance;
8187
}
8288

83-
public static function boot(\Closure|self $configuration): void
89+
public static function boot(\Closure|self $configuration, bool $bootForDataProvider = false): void
8490
{
85-
self::$instance = $configuration;
91+
self::$instance = \is_callable($configuration) ? ($configuration)() : $configuration;
92+
self::$instance->bootedForDataProvider = $bootForDataProvider;
8693
}
8794

8895
public static function shutdown(): void

src/Exception/FoundryNotBooted.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@
1616
*/
1717
final class FoundryNotBooted extends \LogicException
1818
{
19+
public function __construct()
20+
{
21+
parent::__construct('Foundry is not yet booted. Ensure ZenstruckFoundryBundle is enabled. If in a test, ensure your TestCase has the Factories trait.');
22+
}
1923
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zenstruck\Foundry\PHPUnit;
6+
7+
use App\Infrastructure\Common\Symfony\Kernel;
8+
use PHPUnit\Event;
9+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
10+
use Symfony\Component\HttpKernel\KernelInterface;
11+
use Zenstruck\Foundry\Configuration;
12+
use Zenstruck\Foundry\Test\UnitTestConfig;
13+
14+
/**
15+
* @author Nicolas PHILIPPE <[email protected]>
16+
*/
17+
final class BootFoundryOnDataProviderMethodCalled implements Event\Test\DataProviderMethodCalledSubscriber
18+
{
19+
public function __construct(
20+
private KernelInterface $kernel
21+
)
22+
{
23+
}
24+
25+
public function notify(Event\Test\DataProviderMethodCalled $event): void
26+
{
27+
if (is_a($event->testMethod()->className(), KernelTestCase::class, allow_string: true)) {
28+
static $kernelIsBooted = false;
29+
30+
if (!$kernelIsBooted) {
31+
$this->kernel->boot();
32+
$kernelIsBooted = true;
33+
}
34+
35+
Configuration::boot(
36+
fn() => $this->kernel->getContainer()->get('.zenstruck_foundry.configuration'),
37+
bootForDataProvider: true
38+
);
39+
} else {
40+
Configuration::boot(UnitTestConfig::build(), bootForDataProvider: true);
41+
}
42+
}
43+
}

src/PHPUnit/Extension.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zenstruck\Foundry\PHPUnit;
6+
7+
use PHPUnit\Metadata\Version\ConstraintRequirement;
8+
use PHPUnit\Runner;
9+
use PHPUnit\TextUI;
10+
use Symfony\Component\HttpKernel\KernelInterface;
11+
use Zenstruck\Foundry\Configuration;
12+
13+
final class Extension implements Runner\Extension\Extension
14+
{
15+
const MIN_PHPUNIT_VERSION = '11.4';
16+
17+
public function bootstrap(
18+
TextUI\Configuration\Configuration $configuration,
19+
Runner\Extension\Facade $facade,
20+
Runner\Extension\ParameterCollection $parameters
21+
): void {
22+
if (!ConstraintRequirement::from(self::MIN_PHPUNIT_VERSION)->isSatisfiedBy(Runner\Version::id())) {
23+
throw new \LogicException(
24+
sprintf(
25+
'Your PHPUnit version (%s) is not compatible with the minimum version (%s) needed to use this extension.',
26+
Runner\Version::id(),
27+
self::MIN_PHPUNIT_VERSION
28+
)
29+
);
30+
}
31+
32+
// shutdown Foundry if for some reason it has been booted before
33+
if (Configuration::isBooted()) {
34+
Configuration::shutdown();
35+
}
36+
37+
$kernel = $this->createKernel();
38+
39+
$facade->registerSubscribers(
40+
new BootFoundryOnDataProviderMethodCalled($kernel),
41+
new ShutdownKernelOnTestSuiteLoaded($kernel)
42+
);
43+
}
44+
45+
/**
46+
* This logic was shamelessly stolen from Symfony's KernelTestCase
47+
*/
48+
private function createKernel(): KernelInterface
49+
{
50+
if (!isset($_SERVER['KERNEL_CLASS']) && !isset($_ENV['KERNEL_CLASS'])) {
51+
throw new \LogicException('You must set the KERNEL_CLASS environment variable to the fully-qualified class name of your Kernel in phpunit.xml / phpunit.xml.dist.');
52+
}
53+
54+
if (!class_exists($class = $_ENV['KERNEL_CLASS'] ?? $_SERVER['KERNEL_CLASS'])) {
55+
throw new \RuntimeException(
56+
sprintf(
57+
'Class "%s" doesn\'t exist or cannot be autoloaded. Check that the KERNEL_CLASS value in phpunit.xml matches the fully-qualified class name of your Kernel.',
58+
$class
59+
)
60+
);
61+
}
62+
63+
$env = $options['environment'] ?? $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test';
64+
$debug = $options['debug'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true;
65+
66+
return new $class($env, $debug);
67+
}
68+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zenstruck\Foundry\PHPUnit;
6+
7+
use PHPUnit\Event;
8+
use Symfony\Component\HttpKernel\KernelInterface;
9+
use Zenstruck\Foundry\Configuration;
10+
11+
/**
12+
* @author Nicolas PHILIPPE <[email protected]>
13+
*/
14+
final class ShutdownKernelOnTestSuiteLoaded implements Event\TestSuite\LoadedSubscriber
15+
{
16+
public function __construct(
17+
private KernelInterface $kernel
18+
) {
19+
}
20+
21+
public function notify(Event\TestSuite\Loaded $event): void
22+
{
23+
$this->kernel->shutdown();
24+
Configuration::shutdown();
25+
}
26+
}

src/Persistence/IsProxy.php

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

1212
namespace Zenstruck\Foundry\Persistence;
1313

14-
use Doctrine\ODM\MongoDB\DocumentManager;
1514
use Symfony\Component\VarExporter\LazyProxyTrait;
1615
use Zenstruck\Assert;
1716
use Zenstruck\Foundry\Configuration;
@@ -143,6 +142,11 @@ private function isPersisted(): bool
143142
}
144143
}
145144

145+
public function _initializeLazyObject(): void
146+
{
147+
$this->initializeLazyObject();
148+
}
149+
146150
private function _autoRefresh(): void
147151
{
148152
if (!$this->_getAutoRefresh()) {

src/Persistence/PersistentObjectFactory.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,28 @@ final public static function truncate(): void
204204
}
205205

206206
/**
207+
* @final
207208
* @return T
208209
*/
209-
final public function create(callable|array $attributes = []): object
210+
public function create(callable|array $attributes = []): object
210211
{
211212
$object = parent::create($attributes);
212213

214+
$configuration = Configuration::instance();
215+
216+
if ($configuration->inADataProvider() && !$this instanceof PersistentProxyObjectFactory) {
217+
throw new \LogicException(
218+
sprintf(
219+
'Cannot create object in a data provider for non-proxy factories. Transform your factory into a "%s", or call "create()" method in the test. See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#phpunit-data-providers',
220+
PersistentProxyObjectFactory::class
221+
)
222+
);
223+
}
224+
213225
if (!$this->isPersisting()) {
214226
return $this->proxy($object);
215227
}
216228

217-
$configuration = Configuration::instance();
218-
219229
if (!$configuration->isPersistenceAvailable()) {
220230
throw new \LogicException('Persistence cannot be used in unit tests.');
221231
}

src/Persistence/PersistentProxyObjectFactory.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Doctrine\Persistence\ObjectRepository;
1515
use Zenstruck\Foundry\Configuration;
16+
use Zenstruck\Foundry\Exception\FoundryNotBooted;
1617
use Zenstruck\Foundry\Factory;
1718
use Zenstruck\Foundry\Object\Instantiator;
1819
use Zenstruck\Foundry\FactoryCollection; // keep me!
@@ -43,6 +44,19 @@
4344
*/
4445
abstract class PersistentProxyObjectFactory extends PersistentObjectFactory
4546
{
47+
/**
48+
* @return T&Proxy<T>
49+
*/
50+
final public function create(callable|array $attributes = []): object
51+
{
52+
$configuration = Configuration::instance();
53+
if ($configuration->inADataProvider()) {
54+
return ProxyGenerator::wrapFactory($this, $attributes);
55+
}
56+
57+
return parent::create($attributes);
58+
}
59+
4660
/**
4761
* @return class-string<T>
4862
*/

src/Persistence/Proxy.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,9 @@ public function _assertNotPersisted(string $message = '{entity} is persisted but
5353
* @return ProxyRepositoryDecorator<T,ObjectRepository<T>>
5454
*/
5555
public function _repository(): ProxyRepositoryDecorator;
56+
57+
/**
58+
* @internal
59+
*/
60+
public function _initializeLazyObject(): void;
5661
}

src/Persistence/ProxyGenerator.php

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
use Symfony\Component\VarExporter\LazyObjectInterface;
1616
use Symfony\Component\VarExporter\LazyProxyTrait;
1717
use Symfony\Component\VarExporter\ProxyHelper;
18+
use Zenstruck\Foundry\Factory;
1819

1920
/**
2021
* @author Kevin Bond <[email protected]>
2122
*
2223
* @internal
24+
*
25+
* @phpstan-import-type Attributes from Factory
2326
*/
2427
final class ProxyGenerator
2528
{
@@ -43,6 +46,19 @@ public static function wrap(object $object): Proxy
4346
return self::generateClassFor($object)::createLazyProxy(static fn() => $object); // @phpstan-ignore-line
4447
}
4548

49+
/**
50+
* @template T of object
51+
*
52+
* @param PersistentProxyObjectFactory<T> $factory
53+
* @phpstan-param Attributes $attributes
54+
*
55+
* @return T&Proxy<T>
56+
*/
57+
public static function wrapFactory(PersistentProxyObjectFactory $factory, callable|array $attributes): Proxy
58+
{
59+
return self::generateClassFor($factory)::createLazyProxy(static fn() => $factory->create($attributes)); // @phpstan-ignore-line
60+
}
61+
4662
/**
4763
* @template T
4864
*
@@ -76,8 +92,8 @@ public static function unwrap(mixed $what): mixed
7692
*/
7793
private static function generateClassFor(object $object): string
7894
{
79-
/** @var class-string $class */
80-
$class = $object instanceof DoctrineProxy ? \get_parent_class($object) : $object::class;
95+
$class = self::extractClassName($object);
96+
8197
$proxyClass = self::proxyClassNameFor($class);
8298

8399
/** @var class-string<LazyObjectInterface&Proxy<T>&T> $proxyClass */
@@ -108,4 +124,16 @@ public static function proxyClassNameFor(string $class): string
108124
{
109125
return \str_replace('\\', '', $class).'Proxy';
110126
}
127+
128+
/**
129+
* @return class-string
130+
*/
131+
private static function extractClassName(object $object): string
132+
{
133+
if ($object instanceof PersistentProxyObjectFactory) {
134+
return $object::class();
135+
}
136+
137+
return $object instanceof DoctrineProxy ? \get_parent_class($object) : $object::class; // @phpstan-ignore return.type
138+
}
111139
}

src/Persistence/functions.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,15 @@ function enable_persisting(): void
171171
{
172172
Configuration::instance()->persistence()->enablePersisting();
173173
}
174+
175+
/**
176+
* @internal
177+
*/
178+
function initialize_proxy_object(mixed $what): void
179+
{
180+
match(true) {
181+
$what instanceof Proxy => $what->_initializeLazyObject(),
182+
is_array($what) => array_map(initialize_proxy_object(...), $what),
183+
default => true // do nothing
184+
};
185+
}

0 commit comments

Comments
 (0)