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/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..cca3a8482 --- /dev/null +++ b/src/Exception/CannotCreateFactory.php @@ -0,0 +1,13 @@ + @@ -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..9559e0bb6 --- /dev/null +++ b/src/InMemory/AsInMemoryRepository.php @@ -0,0 +1,19 @@ +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..0b96c5d33 --- /dev/null +++ b/src/InMemory/DependencyInjection/InMemoryCompilerPass.php @@ -0,0 +1,51 @@ +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->register('.zenstruck_foundry.in_memory.factory_registry') + ->setClass(InMemoryFactoryRegistry::class) + ->setDecoratedService('.zenstruck_foundry.factory_registry') + ->setArgument('$decorated', $container->getDefinition('.zenstruck_foundry.factory_registry')) + ->setArgument('$inMemoryRepositories', $inMemoryRepositoriesLocator) + ; + } +} diff --git a/src/InMemory/GenericInMemoryRepository.php b/src/InMemory/GenericInMemoryRepository.php new file mode 100644 index 000000000..629109e83 --- /dev/null +++ b/src/InMemory/GenericInMemoryRepository.php @@ -0,0 +1,42 @@ + + * + * 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; + } + } +} diff --git a/src/InMemory/InMemoryFactoryRegistry.php b/src/InMemory/InMemoryFactoryRegistry.php new file mode 100644 index 000000000..e567a7f4b --- /dev/null +++ b/src/InMemory/InMemoryFactoryRegistry.php @@ -0,0 +1,72 @@ +, GenericInMemoryRepository> + */ + private array $genericInMemoryRepositories = []; + + public function __construct( // @phpstan-ignore-line + private readonly FactoryRegistryInterface $decorated, + /** @var ServiceLocator */ + private readonly ServiceLocator $inMemoryRepositories, + ) { + } + + /** + * @template TFactory of Factory + * + * @param class-string $class + * + * @return TFactory + */ + public function get(string $class): Factory + { + $factory = $this->decorated->get($class); + + if (!$factory instanceof PersistentObjectFactory) { // todo shall we support ObjectFactory as well? + return $factory; + } + + $configuration = Configuration::instance(); + + if (!$configuration->isInMemoryEnabled()) { + return $factory; + } + + return $factory->withoutPersisting() + ->afterInstantiate( + fn(object $object) => $this->findInMemoryRepository($factory)->_save($object) // @phpstan-ignore-line + ); + } + + /** + * @param PersistentObjectFactory $factory + * + * @return InMemoryRepository + */ + private function findInMemoryRepository(PersistentObjectFactory $factory): InMemoryRepository + { + $targetClass = $factory::class(); + if (!$this->inMemoryRepositories->has($targetClass)) { + return $this->genericInMemoryRepositories[$targetClass] ??= new GenericInMemoryRepository($targetClass); + } + + return $this->inMemoryRepositories->get($targetClass); + } +} diff --git a/src/InMemory/InMemoryRepository.php b/src/InMemory/InMemoryRepository.php new file mode 100644 index 000000000..bfef53880 --- /dev/null +++ b/src/InMemory/InMemoryRepository.php @@ -0,0 +1,16 @@ + + * + * 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 aee598a16..c4174199a 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -274,7 +274,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 a0f8921c3..9690ed6c5 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; @@ -211,6 +215,18 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->replaceArgument(1, $config['mongo']) ; } + + // 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 @@ -218,6 +234,9 @@ public function build(ContainerBuilder $container): void parent::build($container); $container->addCompilerPass($this); + + // todo: should we find a way to decouple Foundry from its "plugins"? + $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..7bceb9dbd --- /dev/null +++ b/tests/Fixture/InMemory/InMemoryStandardAddressRepository.php @@ -0,0 +1,36 @@ + + */ +#[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; + } + } + + /** + * @return list + */ + 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..95d0e21dd --- /dev/null +++ b/tests/Fixture/InMemory/InMemoryStandardContactRepository.php @@ -0,0 +1,34 @@ + + */ +#[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; + } + } + + /** + * @return list + */ + public function all(): array + { + return $this->elements; + } +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index afa3ae355..87fba5a8e 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; @@ -145,6 +147,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..e69055849 --- /dev/null +++ b/tests/Integration/InMemory/InMemoryTest.php @@ -0,0 +1,99 @@ +addressRepository = self::getContainer()->get(InMemoryStandardAddressRepository::class); // @phpstan-ignore-line + $this->contactRepository = self::getContainer()->get(InMemoryStandardContactRepository::class); // @phpstan-ignore-line + } + + /** + * @test + */ + public function create_one_does_not_persist_in_database(): void + { + $address = StandardAddressFactory::createOne(); + self::assertInstanceOf(StandardAddress::class, $address); + + // todo! +// StandardAddressFactory::assert()->count(0); + + // 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); + + // todo! +// StandardAddressFactory::assert()->count(0); + + 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()); + } + + /** + * @test + */ + public function can_use_generic_repository(): void + { + $category = StandardCategoryFactory::createOne(); + + // todo! +// StandardCategoryFactory::assert()->count(0); + + self::assertNull($category->id); + } +}