From 3807ccfad5497ab8e82fc1fd2b194a80b8cb65d3 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Fri, 12 Jun 2026 23:38:58 +0200 Subject: [PATCH] Introduce Argument Augmenter Unlike the resolver, which completes a call by padding gaps with defaults or null, the augmenter keeps the given arguments authoritative and only adds container services for parameters left unfilled. This suits factories that pass user input straight to a constructor. Types listed as unresolvable are never looked up in the container, which keeps value-like classes (clocks, dates) from being served as frozen services. --- README.md | 41 ++++ composer.json | 5 +- src/Augmenter.php | 30 +++ src/ContainerAugmenter.php | 126 ++++++++++++ tests/fixtures/OptionalServiceConsumer.php | 20 ++ tests/fixtures/functions.php | 24 +++ tests/unit/ContainerAugmenterTest.php | 214 +++++++++++++++++++++ 7 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 src/Augmenter.php create mode 100644 src/ContainerAugmenter.php create mode 100644 tests/fixtures/OptionalServiceConsumer.php create mode 100644 tests/fixtures/functions.php create mode 100644 tests/unit/ContainerAugmenterTest.php diff --git a/README.md b/README.md index e547555..4593e41 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,46 @@ $args = $resolver->resolveNamed( // Named args take precedence, gaps filled from container by name and type ``` +### Augment arguments + +Use the augmenter when the arguments must stay exactly as the caller provided +them (e.g. factories that pass user input straight to a constructor) and the +container should only supply the missing services: + +```php +use Respect\Parameter\ContainerAugmenter; + +final class Notifier +{ + public function __construct( + private string $channel, + private Mailer|null $mailer = null, + ) { + } +} + +$augmenter = new ContainerAugmenter($container); +$args = $augmenter->augment($constructor, ['slack']); +// ['slack', 'mailer' => Mailer] — positional args untouched, gaps named +``` + +Variadic, builtin-typed, and already-filled parameters are never augmented. +Extra arguments (e.g. for variadic parameters) pass through unchanged, and +missing arguments are never padded with defaults or `null`. + +#### Unresolvable types + +Value-like classes should never be served by the container, even when it can +provide them — a container-cached `DateTimeImmutable` is a frozen clock. +List them at construction to exclude them from container lookups: + +```php +$augmenter = new ContainerAugmenter($container, [ + DateTimeImmutable::class, + DateTimeInterface::class, +]); +``` + ### Reflect any callable Convert any callable form into a `ReflectionFunctionAbstract`: @@ -76,6 +116,7 @@ Resolver::acceptsType($reflection, LoggerInterface::class); // true/false |-----------------------------------------|----------|------------------------------------------------------| | `resolve($reflection, $positional)` | instance | Resolve parameters from positional args + container. Returns `array` keyed by parameter name | | `resolveNamed($reflection, $named)` | instance | Resolve from named args (priority) + container. Returns `array` keyed by parameter name | +| `augment($reflection, $arguments)` | instance | Fill unfilled parameters from the container as named args; given arguments stay untouched | | `reflectCallable($callable)` | static | Any callable to `ReflectionFunctionAbstract` | | `acceptsType($reflection, $type)` | static | Check if any parameter accepts a type | diff --git a/composer.json b/composer.json index 158654a..607039d 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,10 @@ "psr-4": { "Respect\\Parameter\\Test\\": "tests/", "Respect\\Parameter\\Test\\Fixtures\\": "tests/fixtures" - } + }, + "files": [ + "tests/fixtures/functions.php" + ] }, "scripts": { "phpcs": "vendor/bin/phpcs", diff --git a/src/Augmenter.php b/src/Augmenter.php new file mode 100644 index 0000000..5f8008c --- /dev/null +++ b/src/Augmenter.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Parameter; + +use ReflectionFunctionAbstract; + +interface Augmenter +{ + /** + * Augment the given arguments with values for the parameters they do not already fill. + * + * The given arguments are authoritative: they are never rebound, reordered, + * or padded with defaults or null. Only parameters left unfilled may gain a + * value, added as named arguments. Variadic and builtin-typed parameters + * are never augmented. + * + * @param array $arguments Positional and/or named arguments + * + * @return array + */ + public function augment(ReflectionFunctionAbstract $reflection, array $arguments): array; +} diff --git a/src/ContainerAugmenter.php b/src/ContainerAugmenter.php new file mode 100644 index 0000000..3fd7e8a --- /dev/null +++ b/src/ContainerAugmenter.php @@ -0,0 +1,126 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Parameter; + +use Psr\Container\ContainerInterface; +use ReflectionFunctionAbstract; +use ReflectionMethod; +use ReflectionNamedType; + +use function array_filter; +use function array_is_list; +use function array_key_exists; +use function array_keys; +use function class_exists; +use function count; +use function in_array; +use function interface_exists; +use function is_int; + +/** + * Augments arguments with services from a PSR-11 container. + * + * Types listed as unresolvable are never looked up in the container, which + * keeps value-like classes (clocks, dates) from being served as services. + */ +final class ContainerAugmenter implements Augmenter +{ + /** @var array> */ + private array $augmentableParametersCache = []; + + /** @param array $unresolvableTypes */ + public function __construct( + private readonly ContainerInterface $container, + private readonly array $unresolvableTypes = [], + ) { + } + + /** + * @param array $arguments Positional and/or named arguments + * + * @return array + */ + public function augment(ReflectionFunctionAbstract $reflection, array $arguments): array + { + if (count($arguments) >= $reflection->getNumberOfParameters()) { + return $arguments; + } + + $augmentableParameters = $this->augmentableParameters($reflection); + if ($augmentableParameters === []) { + return $arguments; + } + + $positionalArgumentsCount = count( + array_is_list($arguments) ? $arguments : array_filter(array_keys($arguments), is_int(...)), + ); + + foreach ($augmentableParameters as [$position, $name, $type]) { + if ($position < $positionalArgumentsCount || array_key_exists($name, $arguments)) { + continue; + } + + if (!$this->container->has($type)) { + continue; + } + + $arguments[$name] = $this->container->get($type); + } + + return $arguments; + } + + /** @return list */ + private function augmentableParameters(ReflectionFunctionAbstract $reflection): array + { + $cacheKey = self::createCacheKey($reflection); + if (isset($this->augmentableParametersCache[$cacheKey])) { + return $this->augmentableParametersCache[$cacheKey]; + } + + $parameters = []; + foreach ($reflection->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($parameter->isVariadic() || !$type instanceof ReflectionNamedType || $type->isBuiltin()) { + continue; + } + + $typeName = $type->getName(); + if (!class_exists($typeName) && !interface_exists($typeName)) { + continue; + } + + if (in_array($typeName, $this->unresolvableTypes, true)) { + continue; + } + + $parameters[] = [$parameter->getPosition(), $parameter->getName(), $typeName]; + } + + return $this->augmentableParametersCache[$cacheKey] = $parameters; + } + + private static function createCacheKey(ReflectionFunctionAbstract $reflection): string + { + if ($reflection instanceof ReflectionMethod) { + return $reflection->class . '::' . $reflection->name; + } + + if (!$reflection->isClosure()) { + return $reflection->name; + } + + $file = $reflection->getFileName() ?: 'internal'; + $line = $reflection->getStartLine() ?: 0; + + return $reflection->getName() . '@' . $file . ':' . $line; + } +} diff --git a/tests/fixtures/OptionalServiceConsumer.php b/tests/fixtures/OptionalServiceConsumer.php new file mode 100644 index 0000000..9703f72 --- /dev/null +++ b/tests/fixtures/OptionalServiceConsumer.php @@ -0,0 +1,20 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Parameter\Test\Fixtures; + +final class OptionalServiceConsumer +{ + public function __construct( + public readonly string $name = 'default', + public readonly SampleService|null $service = null, + ) { + } +} diff --git a/tests/fixtures/functions.php b/tests/fixtures/functions.php new file mode 100644 index 0000000..2b11d7b --- /dev/null +++ b/tests/fixtures/functions.php @@ -0,0 +1,24 @@ + + */ + +declare(strict_types=1); + +// phpcs:disable Squiz.Functions.GlobalFunction.Found + +namespace Respect\Parameter\Test\Fixtures; + +function namedFunctionWithService(string $name, SampleService $service): bool +{ + return true; +} + +// phpcs:ignore SlevomatCodingStandard.PHP.RequireExplicitAssertion.RequiredExplicitAssertion +function functionWithNonExistentType(NonExistentClass123 $x): bool // @phpstan-ignore class.notFound +{ + return true; +} diff --git a/tests/unit/ContainerAugmenterTest.php b/tests/unit/ContainerAugmenterTest.php new file mode 100644 index 0000000..0bd4c8a --- /dev/null +++ b/tests/unit/ContainerAugmenterTest.php @@ -0,0 +1,214 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Parameter\Test\Unit; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionFunction; +use ReflectionMethod; +use Respect\Parameter\ContainerAugmenter; +use Respect\Parameter\Test\Fixtures\ArrayContainer; +use Respect\Parameter\Test\Fixtures\OptionalServiceConsumer; +use Respect\Parameter\Test\Fixtures\SampleService; + +use function Respect\Parameter\Test\Fixtures\namedFunctionWithService; + +#[CoversClass(ContainerAugmenter::class)] +final class ContainerAugmenterTest extends TestCase +{ + #[Test] + public function itShouldAugmentArgumentsWithContainerValuesForUnfilledParameters(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + self::assertSame( + ['some name', 'service' => $service], + $augmenter->augment($this->constructorOf(OptionalServiceConsumer::class), ['some name']), + ); + } + + #[Test] + public function itShouldNotAugmentWhenPositionalArgumentsFillAllParameters(): void + { + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => new SampleService()])); + $arguments = ['some name', new SampleService()]; + + self::assertSame( + $arguments, + $augmenter->augment($this->constructorOf(OptionalServiceConsumer::class), $arguments), + ); + } + + #[Test] + public function itShouldNotAugmentWhenNamedArgumentsFillAugmentableParameters(): void + { + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => new SampleService()])); + $arguments = ['service' => new SampleService()]; + + self::assertSame( + $arguments, + $augmenter->augment($this->constructorOf(OptionalServiceConsumer::class), $arguments), + ); + } + + #[Test] + public function itShouldNotAugmentWhenContainerDoesNotHaveParameterType(): void + { + $augmenter = new ContainerAugmenter(new ArrayContainer()); + + self::assertSame([], $augmenter->augment($this->constructorOf(OptionalServiceConsumer::class), [])); + } + + #[Test] + public function itShouldNotAugmentUnresolvableTypes(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter( + new ArrayContainer([SampleService::class => $service]), + [SampleService::class], + ); + + self::assertSame( + ['some name'], + $augmenter->augment($this->constructorOf(OptionalServiceConsumer::class), ['some name']), + ); + } + + #[Test] + public function itShouldNotAugmentVariadicParameters(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $closure = static fn(string $name, SampleService ...$services): bool => true; + + self::assertSame([], $augmenter->augment(new ReflectionFunction($closure), [])); + } + + #[Test] + public function itShouldNotAugmentBuiltinTypes(): void + { + $augmenter = new ContainerAugmenter(new ArrayContainer(['string' => 'value'])); + + $closure = static fn(string $name, int $count): bool => true; + + self::assertSame([], $augmenter->augment(new ReflectionFunction($closure), [])); + } + + #[Test] + public function itShouldNotAugmentParametersWithNonExistentType(): void + { + $augmenter = new ContainerAugmenter(new ArrayContainer()); + + $function = new ReflectionFunction('Respect\Parameter\Test\Fixtures\functionWithNonExistentType'); + + self::assertSame([], $augmenter->augment($function, [])); + } + + #[Test] + public function itShouldAugmentArgumentsForClosure(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $closure = static fn(string $name, SampleService $service): bool => true; + + self::assertSame( + ['some name', 'service' => $service], + $augmenter->augment(new ReflectionFunction($closure), ['some name']), + ); + } + + #[Test] + public function itShouldKeepNamedArgumentsWhenAugmenting(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $closure = static fn(string $name, SampleService $service): bool => true; + + self::assertSame( + ['name' => 'some name', 'service' => $service], + $augmenter->augment(new ReflectionFunction($closure), ['name' => 'some name']), + ); + } + + #[Test] + public function itShouldAugmentArgumentsForNamedFunction(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $function = new ReflectionFunction(namedFunctionWithService(...)); + + self::assertSame( + ['some name', 'service' => $service], + $augmenter->augment($function, ['some name']), + ); + } + + #[Test] + public function itShouldCreateCacheKeyForNamedFunction(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $function = new ReflectionFunction('Respect\Parameter\Test\Fixtures\namedFunctionWithService'); + + self::assertSame( + ['some name', 'service' => $service], + $augmenter->augment($function, ['some name']), + ); + } + + #[Test] + public function itShouldUseCachedAugmentableParametersOnSubsequentCalls(): void + { + $service = new SampleService(); + $augmenter = new ContainerAugmenter(new ArrayContainer([SampleService::class => $service])); + + $constructor = $this->constructorOf(OptionalServiceConsumer::class); + + $augmenter->augment($constructor, ['some name']); + + self::assertSame( + ['another name', 'service' => $service], + $augmenter->augment($constructor, ['another name']), + ); + } + + #[Test] + public function itShouldNotAugmentDateTimeTypesWhenListedAsUnresolvable(): void + { + $now = new DateTimeImmutable(); + $augmenter = new ContainerAugmenter( + new ArrayContainer([DateTimeImmutable::class => $now]), + [DateTimeImmutable::class], + ); + + $closure = static fn(DateTimeImmutable $date): bool => true; + + self::assertSame([], $augmenter->augment(new ReflectionFunction($closure), [])); + } + + /** @param class-string $class */ + private function constructorOf(string $class): ReflectionMethod + { + $constructor = (new ReflectionClass($class))->getConstructor(); + self::assertNotNull($constructor); + + return $constructor; + } +}