Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support "in-memory" repositories #533

Open
nikophil opened this issue Dec 1, 2023 · 2 comments
Open

support "in-memory" repositories #533

nikophil opened this issue Dec 1, 2023 · 2 comments
Labels
enhancement New feature or request

Comments

@nikophil
Copy link
Member

nikophil commented Dec 1, 2023

Some words about the feature:

with a DDD approach, all repositories would be interfaces, which opens the door to have multiple implementations: usually a Doctrine one and a "in-memory" one.

In one of my project, we use two different kernels in test: the common one, and a InMemoryKernel one, which injects in-memory repositories instead of doctrine ones.

Most of the factories have the following method, which will store the new object in a property of the in-memory repository:

// SomeObjectFactory.php

    public static function inMemory(InMemorySomeObjectRepository $inMemorySomeObjectRepository): self
    {
        return self::new()->withoutPersisting()
            ->afterInstantiate(
                static fn (SomeObject $object) => $inMemorySomeObjectRepository->save($object)
            )
        ;
    }

Beside of this, I'm testing all my "doctrine" and "in-memory" repositories with the exact same class, using an abstract test class and two implementations which only have a setUp() method. This way I can be sure both implementations behave the same way and I know I can safely replace my doctrine repositories by the in-memory ones. Resulting in super fast kernel tests!

Another very cool benefit is that we do not have to mock repositories anymore! Once the in-memory factories are initialized, we just need to call $factory->create() and the object is directly available in the tested code.

My current implementation works well, but is very perfectible, mainly because in some cases it is a little bit hard to work with relationships. I really think this is a very nice feature, which should be in Foundry!

About the implementation I have in mind:

First, I'd like this behavior to be globally enabled or disabled with some kind of marker. Not sure which kind of marker: maybe in a first implementation, a simple function like enable_in_memory() will suffice. But IMO the best way would be an attribute on the test method or test class.

We must introduce an interface for the in memory repositories:

/**
 * @template T of object
 */
interface InMemoryRepository
{
    /**
     * @param T $object
     */
    public function _save(object $object): void; 
    // the underscore prefix is needed in order to not conflict 
    // with a potential `save()` method in `SomeObjectRepositoryInterface`
}

Then, when the option is enabled, we need a way to create in-memory factories. We'd need to hook in the factory creation process, in order to expose only in-memory factories, when the feature is enabled.

This will need a little bit of refactoring because currently, when a factory is not a service, we create it with a static call: Factory::new()

My suggestion would be to change how FactoryRegistry behaves and to create the factory inside of it, if we don't find it in the container.

// Zenstruck\Foundry\FactoryRegistry
-    public function get(string $class): ?Factory
+    public function get(string $class): Factory
     {
         foreach ($this->factories as $factory) {
             if ($class === $factory::class) {
                return $factory;
             }
         }
 
-        return null;
+        return new $class(); // todo: handle `ArgumentCountError`
     }

(it would become a.... FactoryFactory 😱 but this name is really awful, I think we can keep the current name)

Then, we could extract an interface from the FactoryRegistry and decorate it with InMemoryFactoryRegistry:

// Zenstruck\Foundry\InMemory\InMemoryFactoryRegistry
public function get(string $class): Factory
{
    if (/** in memory is not enabled */) {
        return $this->decorated->get($class);			
    }

    return $this->decorated
        ->get($class)
        ->withoutPersisting()
        ->afterInstantiate(
            static fn (object $object) => $this->findInMemoryRepository($class)->_save($object)
        )
    ;
}

We also need a way to guess the in-memory repository from the factory's class name. One of the solutions would be to introduce an new attribute #[AsInMemoryRepository(class: Object::class)].

And voilà! 🎉 this is all we need as a first step.
Next step would be to to provide a in-memory version of RepositoryDecorator because, I'd really like to be able to use things like ::findOrCreate() within the in-memory tests. And then RepositoryAssertions should also be needed! But let's keep simple for a first iteration 😅

As a bonus, I think we can isolate all the code into a Zenstruck\Foundry\InMemory namespace, which will eventually have its own repo in the future.

Do you have any thoughts about this?

@nikophil nikophil changed the title [Foundry 2] support "in memory" repositories support "in memory" repositories Dec 1, 2023
@nikophil nikophil added the enhancement New feature or request label Dec 1, 2023
@nikophil nikophil changed the title support "in memory" repositories support "in-memory" repositories Dec 3, 2023
@kbond
Copy link
Member

kbond commented Dec 13, 2023

Sure, this all makes sense to me. Once we create the 2.x branch and all the legacy stuff from 1.x is removed, let's experiment! We can at least ensure it will be possible to add to 2.x w/o a BC break if the feature is not quite ready.

@nikophil
Copy link
Member Author

cool :)

Actually all the code is almost ready 😄 (just need to fix phpstan...)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Development

No branches or pull requests

2 participants