Skip to content

Commit

Permalink
feat: introduce "in-memory" behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
nikophil committed May 1, 2024
1 parent 71bbaa2 commit 57bbc01
Show file tree
Hide file tree
Showing 18 changed files with 490 additions and 15 deletions.
7 changes: 6 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 13 additions & 1 deletion src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
13 changes: 13 additions & 0 deletions src/Exception/CannotCreateFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\Exception;

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);
}
}
4 changes: 2 additions & 2 deletions src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Zenstruck\Foundry;

use Faker;
use Zenstruck\Foundry\Exception\CannotCreateFactory;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand All @@ -33,7 +34,6 @@ public function __construct()
{
}


/**
* @param Attributes $attributes
*/
Expand All @@ -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);
Expand Down
19 changes: 9 additions & 10 deletions src/FactoryRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

namespace Zenstruck\Foundry;

use Zenstruck\Foundry\Exception\CannotCreateFactory;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*
* @internal
*/
final class FactoryRegistry
final class FactoryRegistry implements FactoryRegistryInterface
{
/**
* @param Factory<mixed>[] $factories
Expand All @@ -25,21 +27,18 @@ public function __construct(private iterable $factories)
{
}

/**
* @template T of Factory
*
* @param class-string<T> $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) {
return $factory; // @phpstan-ignore-line
}
}

return null;
try {
return new $class();
} catch (\ArgumentCountError $e) {
throw CannotCreateFactory::argumentCountError($e);
}
}
}
29 changes: 29 additions & 0 deletions src/FactoryRegistryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* 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 <nikophil@gmail.com>
*
* @internal
*/
interface FactoryRegistryInterface
{
/**
* @template T of Factory
*
* @param class-string<T> $class
*
* @return T
*/
public function get(string $class): Factory;
}
19 changes: 19 additions & 0 deletions src/InMemory/AsInMemoryRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\InMemory;

// todo: remove this attribute in favor to interface?
#[\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.");
}
}
}
51 changes: 51 additions & 0 deletions src/InMemory/DependencyInjection/InMemoryCompilerPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\InMemory\DependencyInjection;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Zenstruck\Foundry\InMemory\InMemoryFactoryRegistry;

/**
* @internal
*/
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->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)
;
}
}
42 changes: 42 additions & 0 deletions src/InMemory/GenericInMemoryRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\InMemory;

/**
* @template T of object
* @implements InMemoryRepository<T>
*
* 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<T>
*/
private array $elements = [];

/**
* @param class-string<T> $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;
}
}
}
72 changes: 72 additions & 0 deletions src/InMemory/InMemoryFactoryRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\InMemory;

use Symfony\Component\DependencyInjection\ServiceLocator;
use Zenstruck\Foundry\Configuration;
use Zenstruck\Foundry\Factory;
use Zenstruck\Foundry\FactoryRegistryInterface;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;

/**
* @internal
* @template T of object
*/
final class InMemoryFactoryRegistry implements FactoryRegistryInterface
{
/**
* @var array<class-string<T>, GenericInMemoryRepository<T>>
*/
private array $genericInMemoryRepositories = [];

public function __construct( // @phpstan-ignore-line
private readonly FactoryRegistryInterface $decorated,
/** @var ServiceLocator<InMemoryRepository> */
private readonly ServiceLocator $inMemoryRepositories,
) {
}

/**
* @template TFactory of Factory
*
* @param class-string<TFactory> $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<T> $factory
*
* @return InMemoryRepository<T>
*/
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);
}
}
16 changes: 16 additions & 0 deletions src/InMemory/InMemoryRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Zenstruck\Foundry\InMemory;

/**
* @template T of object
*/
interface InMemoryRepository
{
/**
* @param T $element
*/
public function _save(object $element): void;
}
22 changes: 22 additions & 0 deletions src/InMemory/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the zenstruck/foundry package.
*
* (c) Kevin Bond <kevinbond@gmail.com>
*
* 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();
}
5 changes: 4 additions & 1 deletion src/Persistence/PersistentObjectFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit 57bbc01

Please sign in to comment.