diff --git a/composer.json b/composer.json index f454edbca..8b3528a8a 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,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..af0708feb --- /dev/null +++ b/config/in_memory.php @@ -0,0 +1,17 @@ +services() + ->set('.zenstruck_foundry.in_memory.factory_registry', InMemoryFactoryRegistry::class) + ->decorate('.zenstruck_foundry.factory_registry') + ->arg('$decorated', service('.inner')) + ->arg('$inMemoryRepositoryRegistry', service('.zenstruck_foundry.in_memory.repository_registry')); + + $container->services() + ->set('.zenstruck_foundry.in_memory.repository_registry', InMemoryRepositoryRegistry::class); +}; diff --git a/src/Configuration.php b/src/Configuration.php index 0c26525a3..578b0a1ff 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -36,11 +36,13 @@ final class Configuration /** @var \Closure():self|self|null */ private static \Closure|self|null $instance = null; + private bool $inMemory = false; + /** * @param InstantiatorCallable $instantiator */ public function __construct( - public readonly FactoryRegistry $factories, + public readonly FactoryRegistryInterface $factories, public readonly Faker\Generator $faker, callable $instantiator, public readonly StoryRegistry $stories, @@ -90,4 +92,14 @@ public static function shutdown(): void StoryRegistry::reset(); self::$instance = null; } + + public function enableInMemory(): void + { + $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 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 a11f1d80b..527b3c8a2 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 */ @@ -46,7 +46,7 @@ final public static function new(array|callable $attributes = []): static 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/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..1d434bef7 --- /dev/null +++ b/src/InMemory/InMemoryFactoryRegistry.php @@ -0,0 +1,54 @@ + + */ +final class InMemoryFactoryRegistry implements FactoryRegistryInterface +{ + public function __construct( // @phpstan-ignore-line + private readonly FactoryRegistryInterface $decorated, + private readonly InMemoryRepositoryRegistry $inMemoryRepositoryRegistry, + ) { + } + + /** + * @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; + } + + $inMemoryRepository = $this->inMemoryRepositoryRegistry->get($factory::class()); + + if ($factory instanceof PersistentObjectFactory) { + $factory = $factory->withoutPersisting(); + } + + return $factory + ->afterInstantiate( + function (object $object) use ($inMemoryRepository) { + $inMemoryRepository->_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..1e1714066 --- /dev/null +++ b/src/InMemory/functions.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\InMemory; + +use Zenstruck\Foundry\Configuration; + +/** + * Enable "in memory" repositories globally. + */ +function enable_in_memory(): void +{ + Configuration::instance()->enableInMemory(); +} diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 2bd83ffd8..34a835c84 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -275,7 +275,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; } 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/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 c94c45310..f929ab294 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; @@ -161,6 +163,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/InMemory/InMemoryTest.php b/tests/Integration/InMemory/InMemoryTest.php new file mode 100644 index 000000000..39c4d35bc --- /dev/null +++ b/tests/Integration/InMemory/InMemoryTest.php @@ -0,0 +1,120 @@ +addressRepository = self::getContainer()->get(InMemoryStandardAddressRepository::class); // @phpstan-ignore-line + $this->contactRepository = self::getContainer()->get(InMemoryStandardContactRepository::class); // @phpstan-ignore-line + + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + /** + * @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 + * @param PersistentObjectFactory $factory + * @dataProvider provideContactFactory + */ + public function nested_objects_should_be_accessible_from_their_respective_repository(PersistentObjectFactory $factory): void + { + $contact = $factory->create(); + + 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()); + } + + public static function provideContactFactory(): iterable + { + yield [StandardContactFactory::new()]; + yield [ProxyContactFactory::new()]; + } + + /** + * @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::assertNull($category->id); + self::assertSame($this->contactRepository->_all(), $category->getContacts()->toArray()); + } +}