From 56a501be2bb64114ed584be9156df8231900026d Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sun, 18 Aug 2024 19:21:45 +0200 Subject: [PATCH] feat: introduce "in-memory" behavior --- composer.json | 7 +- config/in_memory.php | 16 +++ config/services.php | 1 + src/Configuration.php | 25 +++- src/Exception/CannotCreateFactory.php | 16 +++ src/Factory.php | 4 +- src/FactoryRegistry.php | 19 ++- src/FactoryRegistryInterface.php | 29 +++++ src/InMemory/AsInMemoryRepository.php | 21 ++++ src/InMemory/AsInMemoryTest.php | 13 ++ .../InMemoryCompilerPass.php | 49 ++++++++ src/InMemory/GenericInMemoryRepository.php | 48 +++++++ src/InMemory/InMemoryFactoryRegistry.php | 51 ++++++++ src/InMemory/InMemoryRepository.php | 23 ++++ src/InMemory/InMemoryRepositoryRegistry.php | 40 ++++++ src/InMemory/functions.php | 28 +++++ src/Persistence/PersistentObjectFactory.php | 11 +- src/Test/Factories.php | 16 +++ ...leInMemoryOnDataProviderMethodFinished.php | 30 +++++ ...ableInMemoryOnDataProviderMethodCalled.php | 36 ++++++ src/Test/PHPUnit/Extension.php | 7 ++ .../ShutdownKernelOnTestSuiteLoaded.php | 1 + src/ZenstruckFoundryBundle.php | 19 +++ .../Version20240611065130.php | 44 ------- .../InMemoryStandardAddressRepository.php | 33 +++++ .../InMemoryStandardContactRepository.php | 31 +++++ tests/Fixture/TestKernel.php | 4 + .../DataProviderWithInMemoryTest.php | 106 ++++++++++++++++ tests/Integration/InMemory/InMemoryTest.php | 119 ++++++++++++++++++ tests/bootstrap.php | 7 -- 30 files changed, 785 insertions(+), 69 deletions(-) create mode 100644 config/in_memory.php create mode 100644 src/Exception/CannotCreateFactory.php create mode 100644 src/FactoryRegistryInterface.php create mode 100644 src/InMemory/AsInMemoryRepository.php create mode 100644 src/InMemory/AsInMemoryTest.php create mode 100644 src/InMemory/DependencyInjection/InMemoryCompilerPass.php create mode 100644 src/InMemory/GenericInMemoryRepository.php create mode 100644 src/InMemory/InMemoryFactoryRegistry.php create mode 100644 src/InMemory/InMemoryRepository.php create mode 100644 src/InMemory/InMemoryRepositoryRegistry.php create mode 100644 src/InMemory/functions.php create mode 100644 src/Test/PHPUnit/DisableInMemoryOnDataProviderMethodFinished.php create mode 100644 src/Test/PHPUnit/EnableInMemoryOnDataProviderMethodCalled.php delete mode 100644 tests/Fixture/CustomMigrations/Version20240611065130.php create mode 100644 tests/Fixture/InMemory/InMemoryStandardAddressRepository.php create mode 100644 tests/Fixture/InMemory/InMemoryStandardContactRepository.php create mode 100644 tests/Integration/DataProvider/DataProviderWithInMemoryTest.php create mode 100644 tests/Integration/InMemory/InMemoryTest.php diff --git a/composer.json b/composer.json index 0e619e8b1..bb75433b7 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,12 @@ }, "autoload": { "psr-4": { "Zenstruck\\Foundry\\": "src/" }, - "files": ["src/functions.php", "src/Persistence/functions.php", "src/phpunit_helper.php"] + "files": [ + "src/functions.php", + "src/Persistence/functions.php", + "src/phpunit_helper.php", + "src/InMemory/functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/in_memory.php b/config/in_memory.php new file mode 100644 index 000000000..2ecdf0aac --- /dev/null +++ b/config/in_memory.php @@ -0,0 +1,16 @@ +services() + ->set('.zenstruck_foundry.in_memory.factory_registry', InMemoryFactoryRegistry::class) + ->decorate('.zenstruck_foundry.factory_registry') + ->arg('$decorated', service('.inner')); + + $container->services() + ->set('.zenstruck_foundry.in_memory.repository_registry', InMemoryRepositoryRegistry::class); +}; diff --git a/config/services.php b/config/services.php index cd3fd6fd9..5b80fad53 100644 --- a/config/services.php +++ b/config/services.php @@ -32,6 +32,7 @@ service('.zenstruck_foundry.instantiator'), service('.zenstruck_foundry.story_registry'), service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), + service('.zenstruck_foundry.in_memory.repository_registry')->nullOnInvalid(), ]) ->public() ; diff --git a/src/Configuration.php b/src/Configuration.php index 629eeb27b..bf86145af 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -15,6 +15,7 @@ use Zenstruck\Foundry\Exception\FoundryNotBooted; use Zenstruck\Foundry\Exception\PersistenceDisabled; use Zenstruck\Foundry\Exception\PersistenceNotAvailable; +use Zenstruck\Foundry\InMemory\InMemoryRepositoryRegistry; use Zenstruck\Foundry\Persistence\PersistenceManager; /** @@ -40,15 +41,18 @@ final class Configuration private static ?self $instance = null; + private bool $inMemory = false; + /** * @param InstantiatorCallable $instantiator */ - public function __construct( - public readonly FactoryRegistry $factories, + public function __construct( // @phpstan-ignore missingType.generics + public readonly FactoryRegistryInterface $factories, public readonly Faker\Generator $faker, callable $instantiator, public readonly StoryRegistry $stories, private readonly ?PersistenceManager $persistence = null, + public readonly ?InMemoryRepositoryRegistry $inMemoryRepositoryRegistry = null, ) { $this->instantiator = $instantiator; } @@ -93,12 +97,14 @@ public static function boot(\Closure|self $configuration): void { self::$instance = \is_callable($configuration) ? ($configuration)() : $configuration; self::$instance->bootedForDataProvider = false; + self::$instance->inMemory = false; } public static function bootForDataProvider(\Closure|self $configuration): void { self::$instance = \is_callable($configuration) ? ($configuration)() : $configuration; self::$instance->bootedForDataProvider = true; + self::$instance->inMemory = false; } public static function shutdown(): void @@ -106,4 +112,19 @@ public static function shutdown(): void StoryRegistry::reset(); self::$instance = null; } + + public function enableInMemory(): void + { + $this->inMemory = true; + } + + public function disableInMemory(): void + { + $this->inMemory = false; + } + + public function isInMemoryEnabled(): bool + { + return $this->inMemory; + } } diff --git a/src/Exception/CannotCreateFactory.php b/src/Exception/CannotCreateFactory.php new file mode 100644 index 000000000..37ec14b2b --- /dev/null +++ b/src/Exception/CannotCreateFactory.php @@ -0,0 +1,16 @@ + + */ +final class CannotCreateFactory extends \LogicException +{ + public static function argumentCountError(\ArgumentCountError $e): static + { + return new self('Factories with dependencies (services) cannot be created before foundry is booted.', previous: $e); + } +} diff --git a/src/Factory.php b/src/Factory.php index 465f5e94e..cfad8559f 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -12,6 +12,7 @@ namespace Zenstruck\Foundry; use Faker; +use Zenstruck\Foundry\Exception\CannotCreateFactory; /** * @author Kevin Bond @@ -33,7 +34,6 @@ public function __construct() { } - /** * @param Attributes $attributes * @@ -48,7 +48,7 @@ final public static function new(array|callable $attributes = []): static // @ph try { $factory ??= new static(); // @phpstan-ignore-line } catch (\ArgumentCountError $e) { - throw new \LogicException('Factories with dependencies (services) cannot be created before foundry is booted.', previous: $e); + throw CannotCreateFactory::argumentCountError($e); } return $factory->initialize()->with($attributes); diff --git a/src/FactoryRegistry.php b/src/FactoryRegistry.php index 05e96d53c..fc4ee6289 100644 --- a/src/FactoryRegistry.php +++ b/src/FactoryRegistry.php @@ -11,12 +11,14 @@ namespace Zenstruck\Foundry; +use Zenstruck\Foundry\Exception\CannotCreateFactory; + /** * @author Kevin Bond * * @internal */ -final class FactoryRegistry +final class FactoryRegistry implements FactoryRegistryInterface { /** * @param Factory[] $factories @@ -25,14 +27,7 @@ public function __construct(private iterable $factories) { } - /** - * @template T of Factory - * - * @param class-string $class - * - * @return T|null - */ - public function get(string $class): ?Factory + public function get(string $class): Factory { foreach ($this->factories as $factory) { if ($class === $factory::class) { @@ -40,6 +35,10 @@ public function get(string $class): ?Factory } } - return null; + try { + return new $class(); + } catch (\ArgumentCountError $e) { + throw CannotCreateFactory::argumentCountError($e); + } } } diff --git a/src/FactoryRegistryInterface.php b/src/FactoryRegistryInterface.php new file mode 100644 index 000000000..f2e5a2026 --- /dev/null +++ b/src/FactoryRegistryInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry; + +/** + * @author Nicolas PHILIPPE + * + * @internal + */ +interface FactoryRegistryInterface +{ + /** + * @template T of Factory + * + * @param class-string $class + * + * @return T + */ + public function get(string $class): Factory; +} diff --git a/src/InMemory/AsInMemoryRepository.php b/src/InMemory/AsInMemoryRepository.php new file mode 100644 index 000000000..31ed11cb0 --- /dev/null +++ b/src/InMemory/AsInMemoryRepository.php @@ -0,0 +1,21 @@ + + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class AsInMemoryRepository +{ + public function __construct( + public readonly string $class + ) + { + if (!class_exists($this->class)) { + throw new \InvalidArgumentException("Wrong definition for \"AsInMemoryRepository\" attribute: class \"{$this->class}\" does not exist."); + } + } +} diff --git a/src/InMemory/AsInMemoryTest.php b/src/InMemory/AsInMemoryTest.php new file mode 100644 index 000000000..dc7c601a1 --- /dev/null +++ b/src/InMemory/AsInMemoryTest.php @@ -0,0 +1,13 @@ + + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +final class AsInMemoryTest +{ +} diff --git a/src/InMemory/DependencyInjection/InMemoryCompilerPass.php b/src/InMemory/DependencyInjection/InMemoryCompilerPass.php new file mode 100644 index 000000000..41553e59f --- /dev/null +++ b/src/InMemory/DependencyInjection/InMemoryCompilerPass.php @@ -0,0 +1,49 @@ + + */ +final class InMemoryCompilerPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + // create a service locator with all "in memory" repositories, indexed by target class + $inMemoryRepositoriesServices = $container->findTaggedServiceIds('foundry.in_memory.repository'); + $inMemoryRepositoriesLocator = ServiceLocatorTagPass::register( + $container, + array_combine( + array_map( + static function (array $tags) { + if (\count($tags) !== 1) { + throw new \LogicException('Cannot have multiple tags "foundry.in_memory.repository" on a service!'); + } + + return $tags[0]['class'] ?? throw new \LogicException('Invalid tag definition of "foundry.in_memory.repository".'); + }, + array_values($inMemoryRepositoriesServices) + ), + array_map( + static fn(string $inMemoryRepositoryId) => new Reference($inMemoryRepositoryId), + array_keys($inMemoryRepositoriesServices) + ), + ) + ); + + // todo: should we check we only have a 1 repository per class? + + $container->findDefinition('.zenstruck_foundry.in_memory.repository_registry') + ->setArgument('$inMemoryRepositories', $inMemoryRepositoriesLocator) + ; + } +} diff --git a/src/InMemory/GenericInMemoryRepository.php b/src/InMemory/GenericInMemoryRepository.php new file mode 100644 index 000000000..23d1b3864 --- /dev/null +++ b/src/InMemory/GenericInMemoryRepository.php @@ -0,0 +1,48 @@ + + * @author Nicolas PHILIPPE + * + * This class will be used when a specific "in-memory" repository does not exist for a given class. + */ +final class GenericInMemoryRepository implements InMemoryRepository +{ + /** + * @var list + */ + private array $elements = []; + + /** + * @param class-string $class + */ + public function __construct( + private readonly string $class + ) + { + } + + /** + * @param T $element + */ + public function _save(object $element): void + { + if (!$element instanceof $this->class) { + throw new \InvalidArgumentException(sprintf('Given object of class "%s" is not an instance of expected "%s"', $element::class, $this->class)); + } + + if (!in_array($element, $this->elements, true)) { + $this->elements[] = $element; + } + } + + public function _all(): array + { + return $this->elements; + } +} diff --git a/src/InMemory/InMemoryFactoryRegistry.php b/src/InMemory/InMemoryFactoryRegistry.php new file mode 100644 index 000000000..0e731ccd3 --- /dev/null +++ b/src/InMemory/InMemoryFactoryRegistry.php @@ -0,0 +1,51 @@ + + */ +final class InMemoryFactoryRegistry implements FactoryRegistryInterface +{ + public function __construct( + private readonly FactoryRegistryInterface $decorated, + ) { + } + + /** + * @template TFactory of Factory + * + * @param class-string $class + * + * @return TFactory + */ + public function get(string $class): Factory + { + $factory = $this->decorated->get($class); + + if (!$factory instanceof ObjectFactory || !Configuration::instance()->isInMemoryEnabled()) { + return $factory; + } + + if ($factory instanceof PersistentObjectFactory) { + $factory = $factory->withoutPersisting(); + } + + return $factory + ->afterInstantiate( + function (object $object) use ($factory) { + Configuration::instance()->inMemoryRepositoryRegistry?->get($factory::class())->_save($object); + } + ); + } +} diff --git a/src/InMemory/InMemoryRepository.php b/src/InMemory/InMemoryRepository.php new file mode 100644 index 000000000..4e2977caa --- /dev/null +++ b/src/InMemory/InMemoryRepository.php @@ -0,0 +1,23 @@ + + * + * @template T of object + */ +interface InMemoryRepository +{ + /** + * @param T $element + */ + public function _save(object $element): void; + + /** + * @return list + */ + public function _all(): array; +} diff --git a/src/InMemory/InMemoryRepositoryRegistry.php b/src/InMemory/InMemoryRepositoryRegistry.php new file mode 100644 index 000000000..4d80ecd4f --- /dev/null +++ b/src/InMemory/InMemoryRepositoryRegistry.php @@ -0,0 +1,40 @@ + + */ +final class InMemoryRepositoryRegistry +{ + /** + * @var array, GenericInMemoryRepository> + */ + private array $genericInMemoryRepositories = []; + + public function __construct( + /** @var ServiceLocator> */ + private readonly ServiceLocator $inMemoryRepositories, + ) { + } + + /** + * @param class-string $class + * + * @return InMemoryRepository + */ + public function get(string $class): InMemoryRepository + { + if (!$this->inMemoryRepositories->has($class)) { + return $this->genericInMemoryRepositories[$class] ??= new GenericInMemoryRepository($class); + } + + return $this->inMemoryRepositories->get($class); + } +} diff --git a/src/InMemory/functions.php b/src/InMemory/functions.php new file mode 100644 index 000000000..619db99e9 --- /dev/null +++ b/src/InMemory/functions.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\InMemory; + +/** + * @param class-string $class + * + * @internal + */ +function should_enable_in_memory(string $class, string $method): bool +{ + $classReflection = new \ReflectionClass($class); + + if ($classReflection->getAttributes(AsInMemoryTest::class)) { + return true; + } + + return (bool)$classReflection->getMethod($method)->getAttributes(AsInMemoryTest::class); +} diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 7fb4d8787..963c80c0e 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -273,7 +273,9 @@ final public function afterPersist(callable $callback): static protected function normalizeParameter(string $field, mixed $value): mixed { - if (!Configuration::instance()->isPersistenceAvailable()) { + $configuration = Configuration::instance(); + + if (!$configuration->isPersistenceAvailable()) { return unproxy(parent::normalizeParameter($field, $value)); } @@ -281,7 +283,10 @@ protected function normalizeParameter(string $field, mixed $value): mixed $value->persist = $this->persist; // todo - breaks immutability } - if ($value instanceof self && Configuration::instance()->persistence()->relationshipMetadata(static::class(), $value::class(), $field)?->isCascadePersist) { + if ($value instanceof self + && !Configuration::instance()->isInMemoryEnabled() + && Configuration::instance()->persistence()->relationshipMetadata(static::class(), $value::class(), $field)?->isCascadePersist + ) { $value->persist = false; } @@ -342,7 +347,7 @@ final protected function isPersisting(): bool { $config = Configuration::instance(); - if ($config->isPersistenceAvailable() && !$config->persistence()->isEnabled()) { + if ($config->isInMemoryEnabled() || $config->isPersistenceAvailable() && !$config->persistence()->isEnabled()) { return false; } diff --git a/src/Test/Factories.php b/src/Test/Factories.php index 8bc6c1e1a..0ecab7f4a 100644 --- a/src/Test/Factories.php +++ b/src/Test/Factories.php @@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Configuration; +use function Zenstruck\Foundry\InMemory\should_enable_in_memory; use function Zenstruck\Foundry\Persistence\initialize_proxy_object; /** @@ -31,6 +32,7 @@ trait Factories public function _beforeHook(): void { $this->_bootFoundry(); + $this->_enableInMemory(); $this->_loadDataProvidedProxies(); } @@ -67,6 +69,20 @@ private function _bootFoundry(): void }); } + /** + * @internal + */ + private function _enableInMemory(): void + { + $method = \method_exists($this, 'name') ? $this->name() : $this->getName(false); // @phpstan-ignore method.notFound + + if (!\is_subclass_of(static::class, KernelTestCase::class) || !should_enable_in_memory(static::class, $method)) { // @phpstan-ignore-line + return; + } + + Configuration::instance()->enableInMemory(); + } + /** * If a persistent object has been created in a data provider, we need to initialize the proxy object, * which will trigger the object to be persisted. diff --git a/src/Test/PHPUnit/DisableInMemoryOnDataProviderMethodFinished.php b/src/Test/PHPUnit/DisableInMemoryOnDataProviderMethodFinished.php new file mode 100644 index 000000000..9837889c3 --- /dev/null +++ b/src/Test/PHPUnit/DisableInMemoryOnDataProviderMethodFinished.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\PHPUnit; + +use PHPUnit\Event; +use Zenstruck\Foundry\Configuration; +use function Zenstruck\Foundry\InMemory\should_enable_in_memory; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class DisableInMemoryOnDataProviderMethodFinished implements Event\Test\DataProviderMethodFinishedSubscriber +{ + public function notify(Event\Test\DataProviderMethodFinished $event): void + { + Configuration::instance()->disableInMemory(); + } +} diff --git a/src/Test/PHPUnit/EnableInMemoryOnDataProviderMethodCalled.php b/src/Test/PHPUnit/EnableInMemoryOnDataProviderMethodCalled.php new file mode 100644 index 000000000..b0f177559 --- /dev/null +++ b/src/Test/PHPUnit/EnableInMemoryOnDataProviderMethodCalled.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Test\PHPUnit; + +use PHPUnit\Event; +use Zenstruck\Foundry\Configuration; +use function Zenstruck\Foundry\InMemory\should_enable_in_memory; + +/** + * @internal + * @author Nicolas PHILIPPE + */ +final class EnableInMemoryOnDataProviderMethodCalled implements Event\Test\DataProviderMethodCalledSubscriber +{ + public function notify(Event\Test\DataProviderMethodCalled $event): void + { + $testMethod = $event->testMethod(); + + if (!should_enable_in_memory($testMethod->className(), $testMethod->methodName())) { + return; + } + + Configuration::instance()->enableInMemory(); + } +} diff --git a/src/Test/PHPUnit/Extension.php b/src/Test/PHPUnit/Extension.php index c90d9916c..467ed87a4 100644 --- a/src/Test/PHPUnit/Extension.php +++ b/src/Test/PHPUnit/Extension.php @@ -45,7 +45,14 @@ public function bootstrap( $kernel = $this->createKernel(); $facade->registerSubscribers( + // DataProviderMethodCalled new BootFoundryOnDataProviderMethodCalled($kernel), + new EnableInMemoryOnDataProviderMethodCalled(), + + // DataProviderMethodCalledFinished + new DisableInMemoryOnDataProviderMethodFinished(), + + // TestSuiteLoaded new ShutdownKernelOnTestSuiteLoaded($kernel), ); } diff --git a/src/Test/PHPUnit/ShutdownKernelOnTestSuiteLoaded.php b/src/Test/PHPUnit/ShutdownKernelOnTestSuiteLoaded.php index 2c60cdde8..28f816f68 100644 --- a/src/Test/PHPUnit/ShutdownKernelOnTestSuiteLoaded.php +++ b/src/Test/PHPUnit/ShutdownKernelOnTestSuiteLoaded.php @@ -31,6 +31,7 @@ public function __construct( public function notify(Event\TestSuite\Loaded $event): void { $this->kernel->shutdown(); + Configuration::instance()->disableInMemory(); Configuration::shutdown(); } } diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 7d909ff61..4a627573a 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -12,11 +12,15 @@ namespace Zenstruck\Foundry; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Zenstruck\Foundry\InMemory\AsInMemoryRepository; +use Zenstruck\Foundry\InMemory\DependencyInjection\InMemoryCompilerPass; +use Zenstruck\Foundry\InMemory\InMemoryRepository; use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; @@ -224,6 +228,20 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->replaceArgument(1, $config['mongo']) ; } + + $configurator->import('../config/in_memory.php'); + + // tag with "foundry.in_memory.repository" all classes using attribute "AsInMemoryRepository" + $container->registerAttributeForAutoconfiguration( + AsInMemoryRepository::class, + static function (ChildDefinition $definition, AsInMemoryRepository $attribute, \ReflectionClass $reflector) { // @phpstan-ignore-line + if (!is_a($reflector->name, InMemoryRepository::class, true)) { + throw new \LogicException(sprintf("Service \"%s\" with attribute \"AsInMemoryRepository\" must implement \"%s\".", $reflector->name, InMemoryRepository::class)); + } + + $definition->addTag('foundry.in_memory.repository', ['class' => $attribute->class]); + } + ); } public function build(ContainerBuilder $container): void @@ -231,6 +249,7 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass($this); + $container->addCompilerPass(new InMemoryCompilerPass()); } public function process(ContainerBuilder $container): void diff --git a/tests/Fixture/CustomMigrations/Version20240611065130.php b/tests/Fixture/CustomMigrations/Version20240611065130.php deleted file mode 100644 index 9d51237ff..000000000 --- a/tests/Fixture/CustomMigrations/Version20240611065130.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -// to "Migrations" directory on boot (cf. bootstrap.php) - -namespace Zenstruck\Foundry\Tests\Fixture\Migrations; - -use Doctrine\DBAL\Schema\Schema; -use Doctrine\Migrations\AbstractMigration; -use Zenstruck\Foundry\Tests\Fixture\EdgeCases\Migrate\ORM\EntityInAnotherSchema\Article; - -/** - * Create custom "cms" schema ({@see Article}) to ensure "migrate" mode is still working with multiple schemas. - * Note: the doctrine:migrations:diff command doesn't seem able to add this custom "CREATE SCHEMA" automatically. - * - * @see https://github.com/zenstruck/foundry/issues/618 - */ -final class Version20240611065130 extends AbstractMigration -{ - public function getDescription(): string - { - return 'Create custom "cms" schema.'; - } - - public function up(Schema $schema): void - { - $this->addSql('CREATE SCHEMA cms'); - } - - public function down(Schema $schema): void - { - $this->addSql('DROP SCHEMA cms'); - } -} diff --git a/tests/Fixture/InMemory/InMemoryStandardAddressRepository.php b/tests/Fixture/InMemory/InMemoryStandardAddressRepository.php new file mode 100644 index 000000000..758d807ea --- /dev/null +++ b/tests/Fixture/InMemory/InMemoryStandardAddressRepository.php @@ -0,0 +1,33 @@ + + */ +#[AsInMemoryRepository(class: StandardAddress::class)] +final class InMemoryStandardAddressRepository implements InMemoryRepository +{ + /** + * @var list + */ + private array $elements = []; + + public function _save(object $element): void + { + if (!in_array($element, $this->elements, true)) { + $this->elements[] = $element; + } + } + + public function _all(): array + { + return $this->elements; + } +} diff --git a/tests/Fixture/InMemory/InMemoryStandardContactRepository.php b/tests/Fixture/InMemory/InMemoryStandardContactRepository.php new file mode 100644 index 000000000..d9566f462 --- /dev/null +++ b/tests/Fixture/InMemory/InMemoryStandardContactRepository.php @@ -0,0 +1,31 @@ + + */ +#[AsInMemoryRepository(class: StandardContact::class)] +final class InMemoryStandardContactRepository implements InMemoryRepository +{ + /** @var list */ + private array $elements = []; + + public function _save(object $element): void + { + if (!in_array($element, $this->elements, true)) { + $this->elements[] = $element; + } + } + + public function _all(): array + { + return $this->elements; + } +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 049c13c7d..4fdb796cf 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -26,6 +26,8 @@ use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory; use Zenstruck\Foundry\Tests\Fixture\Factories\Object1Factory; +use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryStandardAddressRepository; +use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryStandardContactRepository; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalInvokableService; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalStory; use Zenstruck\Foundry\ZenstruckFoundryBundle; @@ -162,6 +164,8 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(GlobalInvokableService::class); $c->register(ArrayFactory::class)->setAutowired(true)->setAutoconfigured(true); $c->register(Object1Factory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(InMemoryStandardAddressRepository::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(InMemoryStandardContactRepository::class)->setAutowired(true)->setAutoconfigured(true); } protected function configureRoutes(RoutingConfigurator $routes): void diff --git a/tests/Integration/DataProvider/DataProviderWithInMemoryTest.php b/tests/Integration/DataProvider/DataProviderWithInMemoryTest.php new file mode 100644 index 000000000..900ba9b80 --- /dev/null +++ b/tests/Integration/DataProvider/DataProviderWithInMemoryTest.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\DataProvider; + +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpunit; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\InMemory\AsInMemoryTest; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact\StandardContact; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ProxyContactFactory; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\StandardContactFactory; +use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryStandardContactRepository; + +use Zenstruck\Foundry\Tests\Integration\RequiresORM; +use function Zenstruck\Foundry\Persistence\unproxy; + +/** + * @author Nicolas PHILIPPE + * @requires PHPUnit 11.4 + */ +#[RequiresPhpunit('11.4')] +final class DataProviderWithInMemoryTest extends KernelTestCase +{ + use Factories; + use RequiresORM; // needed to use the entity manager + use ResetDatabase; + + private InMemoryStandardContactRepository $contactRepository; + + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + $this->contactRepository = self::getContainer()->get(InMemoryStandardContactRepository::class); // @phpstan-ignore assign.propertyType + + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); // @phpstan-ignore assign.propertyType + } + + /** + * @param PersistentObjectFactory $factory + */ + #[Test] + #[DataProvider('provideContactFactory')] + #[AsInMemoryTest] + public function it_can_create_in_memory_factory_in_data_provider(PersistentObjectFactory $factory): void + { + if ('1' !== ($_ENV['USE_FOUNDRY_PHPUNIT_EXTENSION'] ?? null)) { + self::markTestSkipped('Needs Foundry PHPUnit extension.'); + } + + $contact = $factory->create(); + + self::assertSame([unproxy($contact)], $this->contactRepository->_all()); + + self::assertSame(0, $this->entityManager->getRepository(StandardContact::class)->count()); + } + + public static function provideContactFactory(): iterable + { + yield [StandardContactFactory::new()]; + yield [ProxyContactFactory::new()]; + } + + #[Test] + #[DataProvider('provideContact')] + #[AsInMemoryTest] + public function it_can_create_in_memory_objects_in_data_provider(?StandardContact $contact = null): void + { + if (\FOUNDRY_SKIP_DATA_PROVIDER === $this->dataName()) { + $this->markTestSkipped(); + } + + self::assertInstanceOf(StandardContact::class, $contact); + + self::assertSame([unproxy($contact)], $this->contactRepository->_all()); + + self::assertSame(0, $this->entityManager->getRepository(StandardContact::class)->count()); + } + + public static function provideContact(): iterable + { + if ('1' !== ($_ENV['USE_FOUNDRY_PHPUNIT_EXTENSION'] ?? null)) { + yield \FOUNDRY_SKIP_DATA_PROVIDER => [null]; + + return; + } + + yield [ProxyContactFactory::createOne()]; + } +} diff --git a/tests/Integration/InMemory/InMemoryTest.php b/tests/Integration/InMemory/InMemoryTest.php new file mode 100644 index 000000000..192e74065 --- /dev/null +++ b/tests/Integration/InMemory/InMemoryTest.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\InMemory; + +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\InMemory\AsInMemoryTest; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\Entity\Address\StandardAddress; +use Zenstruck\Foundry\Tests\Fixture\Entity\Category\StandardCategory; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact\StandardContact; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Address\StandardAddressFactory; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\StandardCategoryFactory; +use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\StandardContactFactory; +use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryStandardAddressRepository; +use Zenstruck\Foundry\Tests\Fixture\InMemory\InMemoryStandardContactRepository; +use Zenstruck\Foundry\Tests\Integration\RequiresORM; + +#[AsInMemoryTest] +final class InMemoryTest extends KernelTestCase +{ + use Factories; + use RequiresORM; // needed to use the entity manager + use ResetDatabase; + + private InMemoryStandardAddressRepository $addressRepository; + private InMemoryStandardContactRepository $contactRepository; + + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + $this->addressRepository = self::getContainer()->get(InMemoryStandardAddressRepository::class); // @phpstan-ignore assign.propertyType + $this->contactRepository = self::getContainer()->get(InMemoryStandardContactRepository::class); // @phpstan-ignore assign.propertyType + + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); // @phpstan-ignore assign.propertyType + } + + /** + * @test + */ + public function create_one_does_not_persist_in_database(): void + { + $address = StandardAddressFactory::createOne(); + self::assertInstanceOf(StandardAddress::class, $address); + + self::assertSame(0, $this->entityManager->getRepository(StandardAddress::class)->count([])); + + // id is autogenerated from the db, then it should be null + self::assertNull($address->id); + } + + /** + * @test + */ + public function create_many_does_not_persist_in_database(): void + { + $addresses = StandardAddressFactory::createMany(2); + self::assertContainsOnlyInstancesOf(StandardAddress::class, $addresses); + + self::assertSame(0, $this->entityManager->getRepository(StandardAddress::class)->count([])); + + foreach ($addresses as $address) { + // id is autogenerated from the db, then it should be null + self::assertNull($address->id); + } + } + + /** + * @test + */ + public function object_should_be_accessible_from_in_memory_repository(): void + { + $address = StandardAddressFactory::createOne(); + + self::assertSame([$address], $this->addressRepository->_all()); + } + + /** + * @test + */ + public function nested_objects_should_be_accessible_from_their_respective_repository(): void + { + $contact = StandardContactFactory::createOne(); + + self::assertSame([$contact], $this->contactRepository->_all()); + self::assertSame([$contact->getAddress()], $this->addressRepository->_all()); + + self::assertSame(0, $this->entityManager->getRepository(StandardAddress::class)->count([])); + self::assertSame(0, $this->entityManager->getRepository(StandardContact::class)->count([])); + } + + /** + * @test + */ + public function can_use_generic_repository(): void + { + $category = StandardCategoryFactory::createOne([ + 'contacts' => StandardContactFactory::new()->many(2), + ]); + + self::assertSame(0, $this->entityManager->getRepository(StandardCategory::class)->count([])); + + self::assertSame($this->contactRepository->_all(), $category->getContacts()->toArray()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d2a607084..5641e349a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -43,13 +43,6 @@ $application->run(new StringInput('doctrine:migrations:diff'), new NullOutput()); $application->run(new StringInput('doctrine:database:drop --force'), new NullOutput()); - // restore custom migrations - // this must be after "doctrine:migrations:diff" otherwise - // Doctrine is not able to run its diff command - foreach ((new Finder())->files()->in(__DIR__.'/Fixture/CustomMigrations') as $customMigrationFile) { - $fs->copy($customMigrationFile->getRealPath(), __DIR__.'/Fixture/Migrations/'.$customMigrationFile->getFilename()); - } - $kernel->shutdown(); }