diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md index 180d3c617..f87734ea8 100644 --- a/docs/1-essentials/05-container.md +++ b/docs/1-essentials/05-container.md @@ -386,6 +386,50 @@ class MyCommand For these edge cases, it's nicer to make the trait self-contained without having to rely on constructor injection. That's why injected properties are supported. +## Decorators + +The container supports the [decorator pattern](https://refactoring.guru/design-patterns/decorator), which allows you to wrap objects and add new behavior to them at runtime without changing their structure. This is particularly useful for adding cross-cutting concerns like logging, caching, validation, or authentication. + +To create a decorator, you need to: + +1. Use the {b`#[Tempest\Container\Decorates]`} attribute on your decorator class +2. Implement the same interface as the class you're decorating +3. Accept the decorated object as a constructor parameter + +```php app/Cache/CacheRepository.php +use Tempest\Container\Decorates; + +#[Decorates(Repository::class)] +final readonly class CacheRepository implements Repository +{ + public function __construct( + private Repository $repository, + private Cache $cache, + ) {} + + public function findById(int $id): ?Book + { + return $this->cache->resolve( + key: "book.{$id}", + callback: fn () => $this->repository->find($id) + ); + } + + public function save(Book $book): Book + { + $this->cache->delete("book.{$book->id}"); + + return $this->repository->save($book); + } +} +``` + +When you request the `Repository` from the container, Tempest will automatically wrap the original implementation with your decorator. The decorated object (the original `Repository`) is injected into the decorator's constructor. + +:::info +Decorators are discovered automatically through Tempest's [discovery](../4-internals/02-discovery.md), so you don't need to manually register them. +::: + ## Proxy loading The container supports lazy loading of dependencies using the `#[Proxy]` attribute. Using this attribute on a property (that has `#[Inject]`) or a constructor parameter diff --git a/packages/container/src/Container.php b/packages/container/src/Container.php index dc970491b..05908104f 100644 --- a/packages/container/src/Container.php +++ b/packages/container/src/Container.php @@ -36,4 +36,6 @@ public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable * @param ClassReflector|class-string|class-string $initializerClass */ public function addInitializer(ClassReflector|string $initializerClass): self; + + public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): self; } diff --git a/packages/container/src/Decorates.php b/packages/container/src/Decorates.php new file mode 100644 index 000000000..a5e6d8b03 --- /dev/null +++ b/packages/container/src/Decorates.php @@ -0,0 +1,15 @@ +getAttribute(Decorates::class); + + if ($decorator === null) { + return; + } + + $this->discoveryItems->add($location, [$class, $decorator]); + } + + public function apply(): void + { + foreach ($this->discoveryItems as [$class, $decorator]) { + /** @var Decorates $decorator */ + $this->container->addDecorator($class, $decorator->decorates); + } + } +} diff --git a/packages/container/src/Exceptions/DecoratorDidNotImplementInterface.php b/packages/container/src/Exceptions/DecoratorDidNotImplementInterface.php new file mode 100644 index 000000000..9b0c51945 --- /dev/null +++ b/packages/container/src/Exceptions/DecoratorDidNotImplementInterface.php @@ -0,0 +1,18 @@ + $dynamicInitializers */ private ArrayIterator $dynamicInitializers = new ArrayIterator(), + + /** @var ArrayIterator $decorators */ + private ArrayIterator $decorators = new ArrayIterator(), private ?DependencyChain $chain = null, ) {} @@ -66,6 +70,13 @@ public function setDynamicInitializers(array $dynamicInitializers): self return $this; } + public function setDecorators(array $decorators): self + { + $this->decorators = new ArrayIterator($decorators); + + return $this; + } + public function getDefinitions(): array { return $this->definitions->getArrayCopy(); @@ -99,6 +110,11 @@ public function getDynamicInitializers(): array return $this->dynamicInitializers->getArrayCopy(); } + public function getDecorators(): array + { + return $this->decorators->getArrayCopy(); + } + public function register(string $className, callable $definition): self { $this->definitions[$className] = $definition; @@ -299,7 +315,28 @@ public function removeInitializer(ClassReflector|string $initializerClass): Cont return $this; } + public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): Container + { + $decoratorClass = is_string($decoratorClass) ? $decoratorClass : $decoratorClass->getName(); + $decoratedClass = is_string($decoratedClass) ? $decoratedClass : $decoratedClass->getName(); + + $this->decorators[$decoratedClass][] = $decoratorClass; + + return $this; + } + private function resolve(string $className, null|string|UnitEnum $tag = null, mixed ...$params): ?object + { + $instance = $this->resolveDependency($className, $tag, ...$params); + + if ($this->decorators[$className] ?? null) { + $instance = $this->resolveDecorator($className, $instance, $tag, ...$params); + } + + return $instance; + } + + private function resolveDependency(string $className, null|string|UnitEnum $tag = null, mixed ...$params): ?object { $class = new ClassReflector($className); @@ -608,4 +645,35 @@ private function resolveTaggedName(string $className, null|string|UnitEnum $tag) ? "{$className}#{$tag}" : $className; } + + private function resolveDecorator(string $className, mixed $instance, null|string|UnitEnum $tag = null, mixed ...$params): ?object + { + foreach ($this->decorators[$className] ?? [] as $decoratorClass) { + $decoratorClassReflector = new ClassReflector($decoratorClass); + $constructor = $decoratorClassReflector->getConstructor(); + $parameters = $constructor?->getParameters(); + + // we look for parameter holding decorated instance + foreach ($parameters ?? [] as $parameter) { + if ($parameter->getType()->matches($className) === false) { + continue; + } + + // we bind the decorated instance to the parameter, so container won't try to resolve it (it would end up as circular dependency) + $params[$parameter->getName()] = $instance; + + break; + } + + $decorator = $this->resolveDependency($decoratorClass, $tag, ...$params); + + if (! $decorator instanceof $className) { + throw new DecoratorDidNotImplementInterface($className, $decoratorClass); + } + + $instance = $decorator; + } + + return $instance; + } } diff --git a/packages/container/tests/ContainerTest.php b/packages/container/tests/ContainerTest.php index ed1b1b308..9f8552a8d 100644 --- a/packages/container/tests/ContainerTest.php +++ b/packages/container/tests/ContainerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use ReflectionClass; use Tempest\Container\Exceptions\CircularDependencyEncountered; +use Tempest\Container\Exceptions\DecoratorDidNotImplementInterface; use Tempest\Container\Exceptions\DependencyCouldNotBeAutowired; use Tempest\Container\Exceptions\DependencyCouldNotBeInstantiated; use Tempest\Container\Exceptions\InvokedCallableWasInvalid; @@ -32,6 +33,12 @@ use Tempest\Container\Tests\Fixtures\ContainerObjectDInitializer; use Tempest\Container\Tests\Fixtures\ContainerObjectE; use Tempest\Container\Tests\Fixtures\ContainerObjectEInitializer; +use Tempest\Container\Tests\Fixtures\DecoratedClass; +use Tempest\Container\Tests\Fixtures\DecoratedInterface; +use Tempest\Container\Tests\Fixtures\DecoratorClass; +use Tempest\Container\Tests\Fixtures\DecoratorInvalid; +use Tempest\Container\Tests\Fixtures\DecoratorSecondClass; +use Tempest\Container\Tests\Fixtures\DecoratorWithoutConstructor; use Tempest\Container\Tests\Fixtures\DependencyWithBuiltinDependencies; use Tempest\Container\Tests\Fixtures\DependencyWithTaggedDependency; use Tempest\Container\Tests\Fixtures\EnumTag; @@ -634,4 +641,52 @@ public function test_tag_attribute_with_enum(): void $this->assertSame('foo', $dependency->foo->name); $this->assertSame('bar', $dependency->bar->name); } + + public function test_returns_decorated_instance(): void + { + $container = new GenericContainer(); + $container->register(DecoratedInterface::class, fn () => new DecoratedClass()); + $container->addDecorator(DecoratorClass::class, DecoratedInterface::class); + + $instance = $container->get(DecoratedInterface::class); + + $this->assertInstanceOf(DecoratorClass::class, $instance); + $this->assertInstanceOf(DecoratedClass::class, $instance->decorated); + } + + public function test_returns_multiple_decorated_instance(): void + { + $container = new GenericContainer(); + $container->register(DecoratedInterface::class, fn () => new DecoratedClass()); + $container->addDecorator(DecoratorClass::class, DecoratedInterface::class); + $container->addDecorator(DecoratorSecondClass::class, DecoratedInterface::class); + + $instance = $container->get(DecoratedInterface::class); + + $this->assertInstanceOf(DecoratorSecondClass::class, $instance); + $this->assertInstanceOf(DecoratorClass::class, $instance->decorated); + $this->assertInstanceOf(DecoratedClass::class, $instance->decorated->decorated); + } + + public function test_throws_on_decorator_not_implementing_interface(): void + { + $container = new GenericContainer(); + $container->register(DecoratedInterface::class, fn () => new DecoratedClass()); + $container->addDecorator(DecoratorInvalid::class, DecoratedInterface::class); + + $this->expectException(DecoratorDidNotImplementInterface::class); + + $container->get(DecoratedInterface::class); + } + + public function test_returns_decorator_without_constructor(): void + { + $container = new GenericContainer(); + $container->register(DecoratedInterface::class, fn () => new DecoratedClass()); + $container->addDecorator(DecoratorWithoutConstructor::class, DecoratedInterface::class); + + $instance = $container->get(DecoratedInterface::class); + + $this->assertInstanceOf(DecoratorWithoutConstructor::class, $instance); + } } diff --git a/packages/container/tests/Fixtures/DecoratedClass.php b/packages/container/tests/Fixtures/DecoratedClass.php new file mode 100644 index 000000000..4664908c6 --- /dev/null +++ b/packages/container/tests/Fixtures/DecoratedClass.php @@ -0,0 +1,9 @@ +isInterface()) { + return $input instanceof $this->definition; + } + if ($this->isIterable()) { return is_iterable($input); } diff --git a/tests/Integration/Container/Commands/ContainerShowCommandTest.php b/tests/Integration/Container/Commands/ContainerShowCommandTest.php index bef2344d9..a62d413ba 100644 --- a/tests/Integration/Container/Commands/ContainerShowCommandTest.php +++ b/tests/Integration/Container/Commands/ContainerShowCommandTest.php @@ -77,6 +77,13 @@ public function addInitializer(mixed $initializerClass): self return $this; } + + public function addDecorator(mixed $decoratorClass, mixed $decoratedClass): self + { + $this->container->addDecorator($decoratorClass, $decoratedClass); + + return $this; + } }, );