Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/1-essentials/05-container.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/container/src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ public function invoke(ClassReflector|MethodReflector|FunctionReflector|callable
* @param ClassReflector<T>|class-string<T>|class-string<U> $initializerClass
*/
public function addInitializer(ClassReflector|string $initializerClass): self;

public function addDecorator(ClassReflector|string $decoratorClass, ClassReflector|string $decoratedClass): self;
}
15 changes: 15 additions & 0 deletions packages/container/src/Decorates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Container;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Decorates
{
public function __construct(
public string $decorates,
) {}
}
41 changes: 41 additions & 0 deletions packages/container/src/DecoratorDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Tempest\Container;

use Tempest\Discovery\Discovery;
use Tempest\Discovery\DiscoveryLocation;
use Tempest\Discovery\IsDiscovery;
use Tempest\Reflection\ClassReflector;

/**
* @property GenericContainer $container
*/
final class DecoratorDiscovery implements Discovery
{
use IsDiscovery;

public function __construct(
private readonly Container $container,
) {}

public function discover(DiscoveryLocation $location, ClassReflector $class): void
{
$decorator = $class->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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Exceptions;

use Exception;

final class DecoratorDidNotImplementInterface extends Exception implements ContainerException
{
public function __construct(
string $className,
string $decoratorName,
) {
$message = "Cannot resolve {$className} because it is decorated by decorator {$decoratorName}, which does not implement {$className}." . PHP_EOL;
parent::__construct($message);
}
}
68 changes: 68 additions & 0 deletions packages/container/src/GenericContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use ArrayIterator;
use Closure;
use ReflectionFunction;
use Tempest\Container\Exceptions\DecoratorDidNotImplementInterface;
use Tempest\Container\Exceptions\DependencyCouldNotBeAutowired;
use Tempest\Container\Exceptions\DependencyCouldNotBeInstantiated;
use Tempest\Container\Exceptions\InvokedCallableWasInvalid;
Expand Down Expand Up @@ -35,6 +36,9 @@ public function __construct(

/** @var ArrayIterator<array-key, class-string> $dynamicInitializers */
private ArrayIterator $dynamicInitializers = new ArrayIterator(),

/** @var ArrayIterator<array-key, class-string[]> $decorators */
private ArrayIterator $decorators = new ArrayIterator(),
private ?DependencyChain $chain = null,
) {}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
}
55 changes: 55 additions & 0 deletions packages/container/tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
9 changes: 9 additions & 0 deletions packages/container/tests/Fixtures/DecoratedClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

class DecoratedClass implements DecoratedInterface
{
}
9 changes: 9 additions & 0 deletions packages/container/tests/Fixtures/DecoratedInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

interface DecoratedInterface
{
}
15 changes: 15 additions & 0 deletions packages/container/tests/Fixtures/DecoratorClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

use Tempest\Container\Decorates;

#[Decorates(DecoratedInterface::class)]
class DecoratorClass implements DecoratedInterface
{
public function __construct(
public DecoratedInterface $decorated,
) {}
}
15 changes: 15 additions & 0 deletions packages/container/tests/Fixtures/DecoratorInvalid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

use Tempest\Container\Decorates;

#[Decorates(DecoratedInterface::class)]
class DecoratorInvalid
{
public function __construct(
public DecoratedInterface $decorated,
) {}
}
15 changes: 15 additions & 0 deletions packages/container/tests/Fixtures/DecoratorSecondClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

use Tempest\Container\Decorates;

#[Decorates(DecoratedInterface::class)]
class DecoratorSecondClass implements DecoratedInterface
{
public function __construct(
public DecoratedInterface $decorated,
) {}
}
12 changes: 12 additions & 0 deletions packages/container/tests/Fixtures/DecoratorWithoutConstructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Tempest\Container\Tests\Fixtures;

use Tempest\Container\Decorates;

#[Decorates(DecoratedInterface::class)]
class DecoratorWithoutConstructor implements DecoratedInterface
{
}
Loading