diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f994b621..eaebc38c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - {php: 8.3, symfony: '*', database: sqlite, without-dama: 1} - {php: 8.3, symfony: '*', database: sqlite, without-dama: 1, deps: lowest} - {php: 8.3, symfony: '*', database: mysql, deps: lowest} - - {php: 8.3, symfony: '*', database: mysql, use-migrate: 1} + - {php: 8.3, symfony: '*', database: pgsql, use-migrate: 1} - {php: 8.3, symfony: '*', database: mysql|mongo, phpunit: 10} - {php: 8.3, symfony: '*', database: mysql|mongo, phpunit: 11} - {php: 8.3, symfony: '*', database: mysql|mongo, use-phpunit-extension: 1, phpunit: 11} diff --git a/composer.json b/composer.json index aec2e99e..3fd26e1c 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,11 @@ "Zenstruck\\Foundry\\": "src/", "Zenstruck\\Foundry\\Psalm\\": "utils/psalm" }, - "files": ["src/functions.php", "src/Persistence/functions.php", "src/phpunit_helper.php"] + "files": [ + "src/functions.php", + "src/Persistence/functions.php", + "src/phpunit_helper.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/in_memory.php b/config/in_memory.php new file mode 100644 index 00000000..2ecdf0aa --- /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 cd3fd6fd..23903077 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'), ]) ->public() ; diff --git a/src/Configuration.php b/src/Configuration.php index 551d9fef..e1e1eada 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -15,6 +15,9 @@ use Zenstruck\Foundry\Exception\FoundryNotBooted; use Zenstruck\Foundry\Exception\PersistenceDisabled; use Zenstruck\Foundry\Exception\PersistenceNotAvailable; +use Zenstruck\Foundry\InMemory\CannotEnableInMemory; +use Zenstruck\Foundry\InMemory\InMemoryRepositoryRegistry; +use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\Persistence\PersistenceManager; /** @@ -41,15 +44,18 @@ final class Configuration /** @var \Closure():self|self|null */ private static \Closure|self|null $instance = null; + private bool $inMemory = false; + /** * @phpstan-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; } @@ -109,4 +115,21 @@ public static function shutdown(): void StoryRegistry::reset(); self::$instance = null; } + + /** + * @throws CannotEnableInMemory + */ + public function enableInMemory(): void + { + if (null === $this->inMemoryRepositoryRegistry) { + throw CannotEnableInMemory::noInMemoryRepositoryRegistry(); + } + + $this->inMemory = true; + } + + public function isInMemoryEnabled(): bool + { + return $this->inMemory; + } } diff --git a/src/Exception/CannotCreateFactory.php b/src/Exception/CannotCreateFactory.php new file mode 100644 index 00000000..9461e6c0 --- /dev/null +++ b/src/Exception/CannotCreateFactory.php @@ -0,0 +1,17 @@ + + * @internal + */ +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 37d1c84b..aa1abf10 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 @@ -47,7 +48,7 @@ final public static function new(array|callable $attributes = []): static try { $factory ??= new static(); // @phpstan-ignore new.static } 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 2a554e2e..6c118824 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 00000000..f2e5a202 --- /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 00000000..54aeb22d --- /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 00000000..7c951a25 --- /dev/null +++ b/src/InMemory/AsInMemoryTest.php @@ -0,0 +1,27 @@ + + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] +final class AsInMemoryTest +{ + /** + * @param class-string $class + * @internal + */ + public static function shouldEnableInMemory(string $class, string $method): bool + { + $classReflection = new \ReflectionClass($class); + + if ($classReflection->getAttributes(static::class)) { + return true; + } + + return (bool)$classReflection->getMethod($method)->getAttributes(static::class); + } +} diff --git a/src/InMemory/CannotEnableInMemory.php b/src/InMemory/CannotEnableInMemory.php new file mode 100644 index 00000000..0c1d40ce --- /dev/null +++ b/src/InMemory/CannotEnableInMemory.php @@ -0,0 +1,18 @@ + + */ +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 00000000..23d1b386 --- /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 00000000..0e731ccd --- /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 00000000..4e2977ca --- /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 00000000..4d80ecd4 --- /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/PHPUnit/BootFoundryOnDataProviderMethodCalled.php b/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php index 2eccf412..c913ad68 100644 --- a/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php +++ b/src/PHPUnit/BootFoundryOnDataProviderMethodCalled.php @@ -14,6 +14,8 @@ namespace Zenstruck\Foundry\PHPUnit; use PHPUnit\Event; +use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\InMemory\AsInMemoryTest; /** * @internal @@ -26,5 +28,11 @@ public function notify(Event\Test\DataProviderMethodCalled $event): void if (\method_exists($event->testMethod()->className(), '_bootForDataProvider')) { \call_user_func([$event->testMethod()->className(), '_bootForDataProvider']); } + + $testMethod = $event->testMethod(); + + if (AsInMemoryTest::shouldEnableInMemory($testMethod->className(), $testMethod->methodName())) { + Configuration::instance()->enableInMemory(); + } } } diff --git a/src/PHPUnit/EnableInMemoryBeforeTest.php b/src/PHPUnit/EnableInMemoryBeforeTest.php new file mode 100644 index 00000000..4f90bbf5 --- /dev/null +++ b/src/PHPUnit/EnableInMemoryBeforeTest.php @@ -0,0 +1,35 @@ +test(); + + if (!$test instanceof Event\Code\TestMethod) { + return; + } + + $testClass = $test->className(); + + if (!AsInMemoryTest::shouldEnableInMemory($testClass, $test->methodName())) { + return; + } + + if (!\is_subclass_of($testClass, KernelTestCase::class)) { + throw CannotEnableInMemory::testIsNotAKernelTestCase("{$test->className()}::{$test->methodName()}"); + } + + Configuration::instance()->enableInMemory(); + } +} diff --git a/src/PHPUnit/FoundryExtension.php b/src/PHPUnit/FoundryExtension.php index 738a1a16..174d1bb7 100644 --- a/src/PHPUnit/FoundryExtension.php +++ b/src/PHPUnit/FoundryExtension.php @@ -42,6 +42,7 @@ public function bootstrap( $facade->registerSubscribers( new BootFoundryOnDataProviderMethodCalled(), + new EnableInMemoryBeforeTest(), new ShutdownFoundryOnDataProviderMethodFinished(), ); } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 4198addd..e74715c9 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -256,7 +256,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)); } @@ -264,7 +266,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; } @@ -325,7 +330,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 1f31d3ab..664e70cb 100644 --- a/src/Test/Factories.php +++ b/src/Test/Factories.php @@ -16,6 +16,8 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Configuration; +use Zenstruck\Foundry\InMemory\AsInMemoryTest; + use function Zenstruck\Foundry\Persistence\initialize_proxy_object; /** diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 21dc8445..d75e1d8c 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\Mongo\MongoResetter; use Zenstruck\Foundry\Object\Instantiator; use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; @@ -276,6 +280,20 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $container->setAlias(MongoResetter::class, '.zenstruck_foundry.persistence.schema_resetter.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 @@ -283,6 +301,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 9d51237f..00000000 --- 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 00000000..758d807e --- /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 00000000..d9566f46 --- /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 63e61850..9b52969b 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -26,6 +26,8 @@ use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; 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 00000000..111b9576 --- /dev/null +++ b/tests/Integration/DataProvider/DataProviderWithInMemoryTest.php @@ -0,0 +1,99 @@ + + * + * 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\RequiresPhpunitExtension; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\InMemory\AsInMemoryTest; +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\PHPUnit\FoundryExtension; +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')] +#[RequiresPhpunitExtension(FoundryExtension::class)] +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 + { + 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 + { + yield [ProxyContactFactory::createOne()]; + } +} diff --git a/tests/Integration/InMemory/InMemoryTest.php b/tests/Integration/InMemory/InMemoryTest.php new file mode 100644 index 00000000..192e7406 --- /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 03193ea6..fb5a7573 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -42,13 +42,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(); }