Skip to content

Commit 2d29bd5

Browse files
aazsamirinnocenzi
andauthored
feat(container): support decorators (#1537)
Co-authored-by: Enzo Innocenzi <[email protected]>
1 parent 743f284 commit 2d29bd5

File tree

15 files changed

+329
-0
lines changed

15 files changed

+329
-0
lines changed

docs/1-essentials/05-container.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,50 @@ class MyCommand
386386

387387
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.
388388

389+
## Decorators
390+
391+
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.
392+
393+
To create a decorator, you need to:
394+
395+
1. Use the {b`#[Tempest\Container\Decorates]`} attribute on your decorator class
396+
2. Implement the same interface as the class you're decorating
397+
3. Accept the decorated object as a constructor parameter
398+
399+
```php app/Cache/CacheRepository.php
400+
use Tempest\Container\Decorates;
401+
402+
#[Decorates(Repository::class)]
403+
final readonly class CacheRepository implements Repository
404+
{
405+
public function __construct(
406+
private Repository $repository,
407+
private Cache $cache,
408+
) {}
409+
410+
public function findById(int $id): ?Book
411+
{
412+
return $this->cache->resolve(
413+
key: "book.{$id}",
414+
callback: fn () => $this->repository->find($id)
415+
);
416+
}
417+
418+
public function save(Book $book): Book
419+
{
420+
$this->cache->delete("book.{$book->id}");
421+
422+
return $this->repository->save($book);
423+
}
424+
}
425+
```
426+
427+
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.
428+
429+
:::info
430+
Decorators are discovered automatically through Tempest's [discovery](../4-internals/02-discovery.md), so you don't need to manually register them.
431+
:::
432+
389433
## Proxy loading
390434

391435
The container supports lazy loading of dependencies using the `#[Proxy]` attribute. Using this attribute on a property (that has `#[Inject]`) or a constructor parameter

packages/container/src/Container.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,6 @@ public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable
3636
* @param ClassReflector<T>|class-string<T>|class-string<U> $initializerClass
3737
*/
3838
public function addInitializer(ClassReflector|string $initializerClass): self;
39+
40+
public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): self;
3941
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_CLASS)]
10+
final readonly class Decorates
11+
{
12+
public function __construct(
13+
public string $decorates,
14+
) {}
15+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container;
6+
7+
use Tempest\Discovery\Discovery;
8+
use Tempest\Discovery\DiscoveryLocation;
9+
use Tempest\Discovery\IsDiscovery;
10+
use Tempest\Reflection\ClassReflector;
11+
12+
/**
13+
* @property GenericContainer $container
14+
*/
15+
final class DecoratorDiscovery implements Discovery
16+
{
17+
use IsDiscovery;
18+
19+
public function __construct(
20+
private readonly Container $container,
21+
) {}
22+
23+
public function discover(DiscoveryLocation $location, ClassReflector $class): void
24+
{
25+
$decorator = $class->getAttribute(Decorates::class);
26+
27+
if ($decorator === null) {
28+
return;
29+
}
30+
31+
$this->discoveryItems->add($location, [$class, $decorator]);
32+
}
33+
34+
public function apply(): void
35+
{
36+
foreach ($this->discoveryItems as [$class, $decorator]) {
37+
/** @var Decorates $decorator */
38+
$this->container->addDecorator($class, $decorator->decorates);
39+
}
40+
}
41+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container\Exceptions;
6+
7+
use Exception;
8+
9+
final class DecoratorDidNotImplementInterface extends Exception implements ContainerException
10+
{
11+
public function __construct(
12+
string $className,
13+
string $decoratorName,
14+
) {
15+
$message = "Cannot resolve {$className} because it is decorated by decorator {$decoratorName}, which does not implement {$className}." . PHP_EOL;
16+
parent::__construct($message);
17+
}
18+
}

packages/container/src/GenericContainer.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ArrayIterator;
88
use Closure;
99
use ReflectionFunction;
10+
use Tempest\Container\Exceptions\DecoratorDidNotImplementInterface;
1011
use Tempest\Container\Exceptions\DependencyCouldNotBeAutowired;
1112
use Tempest\Container\Exceptions\DependencyCouldNotBeInstantiated;
1213
use Tempest\Container\Exceptions\InvokedCallableWasInvalid;
@@ -35,6 +36,9 @@ public function __construct(
3536

3637
/** @var ArrayIterator<array-key, class-string> $dynamicInitializers */
3738
private ArrayIterator $dynamicInitializers = new ArrayIterator(),
39+
40+
/** @var ArrayIterator<array-key, class-string[]> $decorators */
41+
private ArrayIterator $decorators = new ArrayIterator(),
3842
private ?DependencyChain $chain = null,
3943
) {}
4044

@@ -66,6 +70,13 @@ public function setDynamicInitializers(array $dynamicInitializers): self
6670
return $this;
6771
}
6872

73+
public function setDecorators(array $decorators): self
74+
{
75+
$this->decorators = new ArrayIterator($decorators);
76+
77+
return $this;
78+
}
79+
6980
public function getDefinitions(): array
7081
{
7182
return $this->definitions->getArrayCopy();
@@ -99,6 +110,11 @@ public function getDynamicInitializers(): array
99110
return $this->dynamicInitializers->getArrayCopy();
100111
}
101112

113+
public function getDecorators(): array
114+
{
115+
return $this->decorators->getArrayCopy();
116+
}
117+
102118
public function register(string $className, callable $definition): self
103119
{
104120
$this->definitions[$className] = $definition;
@@ -299,7 +315,28 @@ public function removeInitializer(ClassReflector|string $initializerClass): Cont
299315
return $this;
300316
}
301317

318+
public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): Container
319+
{
320+
$decoratorClass = is_string($decoratorClass) ? $decoratorClass : $decoratorClass->getName();
321+
$decoratedClass = is_string($decoratedClass) ? $decoratedClass : $decoratedClass->getName();
322+
323+
$this->decorators[$decoratedClass][] = $decoratorClass;
324+
325+
return $this;
326+
}
327+
302328
private function resolve(string $className, null|string|UnitEnum $tag = null, mixed ...$params): ?object
329+
{
330+
$instance = $this->resolveDependency($className, $tag, ...$params);
331+
332+
if ($this->decorators[$className] ?? null) {
333+
$instance = $this->resolveDecorator($className, $instance, $tag, ...$params);
334+
}
335+
336+
return $instance;
337+
}
338+
339+
private function resolveDependency(string $className, null|string|UnitEnum $tag = null, mixed ...$params): ?object
303340
{
304341
$class = new ClassReflector($className);
305342

@@ -608,4 +645,35 @@ private function resolveTaggedName(string $className, null|string|UnitEnum $tag)
608645
? "{$className}#{$tag}"
609646
: $className;
610647
}
648+
649+
private function resolveDecorator(string $className, mixed $instance, null|string|UnitEnum $tag = null, mixed ...$params): ?object
650+
{
651+
foreach ($this->decorators[$className] ?? [] as $decoratorClass) {
652+
$decoratorClassReflector = new ClassReflector($decoratorClass);
653+
$constructor = $decoratorClassReflector->getConstructor();
654+
$parameters = $constructor?->getParameters();
655+
656+
// we look for parameter holding decorated instance
657+
foreach ($parameters ?? [] as $parameter) {
658+
if ($parameter->getType()->matches($className) === false) {
659+
continue;
660+
}
661+
662+
// we bind the decorated instance to the parameter, so container won't try to resolve it (it would end up as circular dependency)
663+
$params[$parameter->getName()] = $instance;
664+
665+
break;
666+
}
667+
668+
$decorator = $this->resolveDependency($decoratorClass, $tag, ...$params);
669+
670+
if (! $decorator instanceof $className) {
671+
throw new DecoratorDidNotImplementInterface($className, $decoratorClass);
672+
}
673+
674+
$instance = $decorator;
675+
}
676+
677+
return $instance;
678+
}
611679
}

packages/container/tests/ContainerTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PHPUnit\Framework\TestCase;
88
use ReflectionClass;
99
use Tempest\Container\Exceptions\CircularDependencyEncountered;
10+
use Tempest\Container\Exceptions\DecoratorDidNotImplementInterface;
1011
use Tempest\Container\Exceptions\DependencyCouldNotBeAutowired;
1112
use Tempest\Container\Exceptions\DependencyCouldNotBeInstantiated;
1213
use Tempest\Container\Exceptions\InvokedCallableWasInvalid;
@@ -32,6 +33,12 @@
3233
use Tempest\Container\Tests\Fixtures\ContainerObjectDInitializer;
3334
use Tempest\Container\Tests\Fixtures\ContainerObjectE;
3435
use Tempest\Container\Tests\Fixtures\ContainerObjectEInitializer;
36+
use Tempest\Container\Tests\Fixtures\DecoratedClass;
37+
use Tempest\Container\Tests\Fixtures\DecoratedInterface;
38+
use Tempest\Container\Tests\Fixtures\DecoratorClass;
39+
use Tempest\Container\Tests\Fixtures\DecoratorInvalid;
40+
use Tempest\Container\Tests\Fixtures\DecoratorSecondClass;
41+
use Tempest\Container\Tests\Fixtures\DecoratorWithoutConstructor;
3542
use Tempest\Container\Tests\Fixtures\DependencyWithBuiltinDependencies;
3643
use Tempest\Container\Tests\Fixtures\DependencyWithTaggedDependency;
3744
use Tempest\Container\Tests\Fixtures\EnumTag;
@@ -634,4 +641,52 @@ public function test_tag_attribute_with_enum(): void
634641
$this->assertSame('foo', $dependency->foo->name);
635642
$this->assertSame('bar', $dependency->bar->name);
636643
}
644+
645+
public function test_returns_decorated_instance(): void
646+
{
647+
$container = new GenericContainer();
648+
$container->register(DecoratedInterface::class, fn () => new DecoratedClass());
649+
$container->addDecorator(DecoratorClass::class, DecoratedInterface::class);
650+
651+
$instance = $container->get(DecoratedInterface::class);
652+
653+
$this->assertInstanceOf(DecoratorClass::class, $instance);
654+
$this->assertInstanceOf(DecoratedClass::class, $instance->decorated);
655+
}
656+
657+
public function test_returns_multiple_decorated_instance(): void
658+
{
659+
$container = new GenericContainer();
660+
$container->register(DecoratedInterface::class, fn () => new DecoratedClass());
661+
$container->addDecorator(DecoratorClass::class, DecoratedInterface::class);
662+
$container->addDecorator(DecoratorSecondClass::class, DecoratedInterface::class);
663+
664+
$instance = $container->get(DecoratedInterface::class);
665+
666+
$this->assertInstanceOf(DecoratorSecondClass::class, $instance);
667+
$this->assertInstanceOf(DecoratorClass::class, $instance->decorated);
668+
$this->assertInstanceOf(DecoratedClass::class, $instance->decorated->decorated);
669+
}
670+
671+
public function test_throws_on_decorator_not_implementing_interface(): void
672+
{
673+
$container = new GenericContainer();
674+
$container->register(DecoratedInterface::class, fn () => new DecoratedClass());
675+
$container->addDecorator(DecoratorInvalid::class, DecoratedInterface::class);
676+
677+
$this->expectException(DecoratorDidNotImplementInterface::class);
678+
679+
$container->get(DecoratedInterface::class);
680+
}
681+
682+
public function test_returns_decorator_without_constructor(): void
683+
{
684+
$container = new GenericContainer();
685+
$container->register(DecoratedInterface::class, fn () => new DecoratedClass());
686+
$container->addDecorator(DecoratorWithoutConstructor::class, DecoratedInterface::class);
687+
688+
$instance = $container->get(DecoratedInterface::class);
689+
690+
$this->assertInstanceOf(DecoratorWithoutConstructor::class, $instance);
691+
}
637692
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container\Tests\Fixtures;
6+
7+
class DecoratedClass implements DecoratedInterface
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container\Tests\Fixtures;
6+
7+
interface DecoratedInterface
8+
{
9+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Container\Tests\Fixtures;
6+
7+
use Tempest\Container\Decorates;
8+
9+
#[Decorates(DecoratedInterface::class)]
10+
class DecoratorClass implements DecoratedInterface
11+
{
12+
public function __construct(
13+
public DecoratedInterface $decorated,
14+
) {}
15+
}

0 commit comments

Comments
 (0)