Skip to content

Commit

Permalink
feature: allow to create objects in dataProvider thanks to lazy proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
nikophil committed Aug 7, 2024
1 parent 8bf8c4c commit 630c311
Show file tree
Hide file tree
Showing 21 changed files with 485 additions and 155 deletions.
4 changes: 2 additions & 2 deletions bin/console
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<?php

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Zenstruck\Foundry\Tests\Fixtures\Kernel;
use Zenstruck\Foundry\Tests\Fixture\TestKernel;

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

$application = new Application(new Kernel('test', true));
$application = new Application(new TestKernel('test', true));
$application->run();
1 change: 1 addition & 0 deletions phpunit-10.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
</source>
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
<bootstrap class="Zenstruck\Foundry\PHPUnit\Extension" />
</extensions>
</phpunit>
19 changes: 13 additions & 6 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ final class Configuration
*/
public $instantiator;

/** @var \Closure():self|self|null */
private static \Closure|self|null $instance = null;
private bool $bootedForDataProvider = false;

private static self|null $instance = null;

/**
* @param InstantiatorCallable $instantiator
Expand Down Expand Up @@ -66,23 +67,29 @@ public function assertPersistanceEnabled(): void
}
}

public function inADataProvider(): bool
{
return $this->bootedForDataProvider;
}

public static function instance(): self
{
if (!self::$instance) {
throw new FoundryNotBooted('Foundry is not yet booted. Ensure ZenstruckFoundryBundle is enabled. If in a test, ensure your TestCase has the Factories trait.');
throw new FoundryNotBooted();
}

return \is_callable(self::$instance) ? (self::$instance)() : self::$instance;
return self::$instance;
}

public static function isBooted(): bool
{
return null !== self::$instance;
}

public static function boot(\Closure|self $configuration): void
public static function boot(\Closure|self $configuration, bool $bootForDataProvider = false): void
{
self::$instance = $configuration;
self::$instance = \is_callable($configuration) ? ($configuration)() : $configuration;
self::$instance->bootedForDataProvider = $bootForDataProvider;
}

public static function shutdown(): void
Expand Down
4 changes: 4 additions & 0 deletions src/Exception/FoundryNotBooted.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@
*/
final class FoundryNotBooted extends \LogicException
{
public function __construct()
{
parent::__construct('Foundry is not yet booted. Ensure ZenstruckFoundryBundle is enabled. If in a test, ensure your TestCase has the Factories trait.');
}
}
43 changes: 43 additions & 0 deletions src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\PHPUnit;

use App\Infrastructure\Common\Symfony\Kernel;
use PHPUnit\Event;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\KernelInterface;
use Zenstruck\Foundry\Configuration;
use Zenstruck\Foundry\Test\UnitTestConfig;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
final class BootFoundryOnDataProviderMethodCalled implements Event\Test\DataProviderMethodCalledSubscriber
{
public function __construct(
private KernelInterface $kernel
)
{
}

public function notify(Event\Test\DataProviderMethodCalled $event): void
{
if (is_a($event->testMethod()->className(), KernelTestCase::class, allow_string: true)) {
static $kernelIsBooted = false;

if (!$kernelIsBooted) {
$this->kernel->boot();
$kernelIsBooted = true;
}

Configuration::boot(
fn() => $this->kernel->getContainer()->get('.zenstruck_foundry.configuration'),
bootForDataProvider: true
);
} else {
Configuration::boot(UnitTestConfig::build(), bootForDataProvider: true);
}
}
}
68 changes: 68 additions & 0 deletions src/PHPUnit/Extension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\PHPUnit;

use PHPUnit\Metadata\Version\ConstraintRequirement;
use PHPUnit\Runner;
use PHPUnit\TextUI;
use Symfony\Component\HttpKernel\KernelInterface;
use Zenstruck\Foundry\Configuration;

final class Extension implements Runner\Extension\Extension
{
const MIN_PHPUNIT_VERSION = '11.4';

public function bootstrap(
TextUI\Configuration\Configuration $configuration,
Runner\Extension\Facade $facade,
Runner\Extension\ParameterCollection $parameters
): void {
if (!ConstraintRequirement::from(self::MIN_PHPUNIT_VERSION)->isSatisfiedBy(Runner\Version::id())) {
throw new \LogicException(
sprintf(
'Your PHPUnit version (%s) is not compatible with the minimum version (%s) needed to use this extension.',
Runner\Version::id(),
self::MIN_PHPUNIT_VERSION
)
);
}

// shutdown Foundry if for some reason it has been booted before
if (Configuration::isBooted()) {
Configuration::shutdown();
}

$kernel = $this->createKernel();

$facade->registerSubscribers(
new BootFoundryOnDataProviderMethodCalled($kernel),
new ShutdownKernelOnTestSuiteLoaded($kernel)
);
}

/**
* This logic was shamelessly stolen from Symfony's KernelTestCase
*/
private function createKernel(): KernelInterface
{
if (!isset($_SERVER['KERNEL_CLASS']) && !isset($_ENV['KERNEL_CLASS'])) {
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.');
}

if (!class_exists($class = $_ENV['KERNEL_CLASS'] ?? $_SERVER['KERNEL_CLASS'])) {
throw new \RuntimeException(
sprintf(
'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.',
$class
)
);
}

$env = $options['environment'] ?? $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test';
$debug = $options['debug'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true;

return new $class($env, $debug);
}
}
26 changes: 26 additions & 0 deletions src/PHPUnit/ShutdownKernelOnTestSuiteLoaded.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\PHPUnit;

use PHPUnit\Event;
use Symfony\Component\HttpKernel\KernelInterface;
use Zenstruck\Foundry\Configuration;

/**
* @author Nicolas PHILIPPE <[email protected]>
*/
final class ShutdownKernelOnTestSuiteLoaded implements Event\TestSuite\LoadedSubscriber
{
public function __construct(
private KernelInterface $kernel
) {
}

public function notify(Event\TestSuite\Loaded $event): void
{
$this->kernel->shutdown();
Configuration::shutdown();
}
}
6 changes: 5 additions & 1 deletion src/Persistence/IsProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

namespace Zenstruck\Foundry\Persistence;

use Doctrine\ODM\MongoDB\DocumentManager;
use Symfony\Component\VarExporter\LazyProxyTrait;
use Zenstruck\Assert;
use Zenstruck\Foundry\Configuration;
Expand Down Expand Up @@ -143,6 +142,11 @@ private function isPersisted(): bool
}
}

public function _initializeLazyObject(): void
{
$this->initializeLazyObject();
}

private function _autoRefresh(): void
{
if (!$this->_getAutoRefresh()) {
Expand Down
16 changes: 13 additions & 3 deletions src/Persistence/PersistentObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,18 +204,28 @@ final public static function truncate(): void
}

/**
* @final
* @return T
*/
final public function create(callable|array $attributes = []): object
public function create(callable|array $attributes = []): object
{
$object = parent::create($attributes);

$configuration = Configuration::instance();

if ($configuration->inADataProvider() && !$this instanceof PersistentProxyObjectFactory) {
throw new \LogicException(
sprintf(
'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',
PersistentProxyObjectFactory::class
)
);
}

if (!$this->isPersisting()) {
return $this->proxy($object);
}

$configuration = Configuration::instance();

if (!$configuration->isPersistenceAvailable()) {
throw new \LogicException('Persistence cannot be used in unit tests.');
}
Expand Down
14 changes: 14 additions & 0 deletions src/Persistence/PersistentProxyObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Doctrine\Persistence\ObjectRepository;
use Zenstruck\Foundry\Configuration;
use Zenstruck\Foundry\Exception\FoundryNotBooted;
use Zenstruck\Foundry\Factory;
use Zenstruck\Foundry\Object\Instantiator;
use Zenstruck\Foundry\FactoryCollection; // keep me!
Expand Down Expand Up @@ -43,6 +44,19 @@
*/
abstract class PersistentProxyObjectFactory extends PersistentObjectFactory
{
/**
* @return T&Proxy<T>
*/
final public function create(callable|array $attributes = []): object
{
$configuration = Configuration::instance();
if ($configuration->inADataProvider()) {
return ProxyGenerator::wrapFactory($this, $attributes);
}

return parent::create($attributes);
}

/**
* @return class-string<T>
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Persistence/Proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,9 @@ public function _assertNotPersisted(string $message = '{entity} is persisted but
* @return ProxyRepositoryDecorator<T,ObjectRepository<T>>
*/
public function _repository(): ProxyRepositoryDecorator;

/**
* @internal
*/
public function _initializeLazyObject(): void;
}
32 changes: 30 additions & 2 deletions src/Persistence/ProxyGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
use Symfony\Component\VarExporter\LazyObjectInterface;
use Symfony\Component\VarExporter\LazyProxyTrait;
use Symfony\Component\VarExporter\ProxyHelper;
use Zenstruck\Foundry\Factory;

/**
* @author Kevin Bond <[email protected]>
*
* @internal
*
* @phpstan-import-type Attributes from Factory
*/
final class ProxyGenerator
{
Expand All @@ -43,6 +46,19 @@ public static function wrap(object $object): Proxy
return self::generateClassFor($object)::createLazyProxy(static fn() => $object); // @phpstan-ignore-line
}

/**
* @template T of object
*
* @param PersistentProxyObjectFactory<T> $factory
* @phpstan-param Attributes $attributes
*
* @return T&Proxy<T>
*/
public static function wrapFactory(PersistentProxyObjectFactory $factory, callable|array $attributes): Proxy
{
return self::generateClassFor($factory)::createLazyProxy(static fn() => $factory->create($attributes)); // @phpstan-ignore-line
}

/**
* @template T
*
Expand Down Expand Up @@ -76,8 +92,8 @@ public static function unwrap(mixed $what): mixed
*/
private static function generateClassFor(object $object): string
{
/** @var class-string $class */
$class = $object instanceof DoctrineProxy ? \get_parent_class($object) : $object::class;
$class = self::extractClassName($object);

$proxyClass = self::proxyClassNameFor($class);

/** @var class-string<LazyObjectInterface&Proxy<T>&T> $proxyClass */
Expand Down Expand Up @@ -108,4 +124,16 @@ public static function proxyClassNameFor(string $class): string
{
return \str_replace('\\', '', $class).'Proxy';
}

/**
* @return class-string
*/
private static function extractClassName(object $object): string
{
if ($object instanceof PersistentProxyObjectFactory) {
return $object::class();
}

return $object instanceof DoctrineProxy ? \get_parent_class($object) : $object::class; // @phpstan-ignore return.type
}
}
12 changes: 12 additions & 0 deletions src/Persistence/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,15 @@ function enable_persisting(): void
{
Configuration::instance()->persistence()->enablePersisting();
}

/**
* @internal
*/
function initialize_proxy_object(mixed $what): void
{
match(true) {
$what instanceof Proxy => $what->_initializeLazyObject(),
is_array($what) => array_map(initialize_proxy_object(...), $what),
default => true // do nothing
};
}
Loading

0 comments on commit 630c311

Please sign in to comment.