From e6f7ec92dc64ddc91e683b9f7f7ccd6fec67ca06 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Fri, 12 Jun 2026 23:38:58 +0200 Subject: [PATCH 1/3] 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. Co-Authored-By: Claude Fable 5 --- 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; + } +} From 7e9867fc619300e277bb596cd2524f4eb1cab0a1 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Fri, 12 Jun 2026 23:39:39 +0200 Subject: [PATCH 2/3] Extract Reflector From Resolver The static helpers reflectCallable() and acceptsType() do not depend on the container and are useful on their own. Moving them to a dedicated Reflector lets the next step reduce the Resolver to its resolution contract. The Resolver copies remain until that refactoring lands. Co-Authored-By: Claude Fable 5 --- README.md | 14 +++--- src/Reflector.php | 71 +++++++++++++++++++++++++++ tests/unit/ReflectorTest.php | 94 ++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 src/Reflector.php create mode 100644 tests/unit/ReflectorTest.php diff --git a/README.md b/README.md index 4593e41..16405a5 100644 --- a/README.md +++ b/README.md @@ -95,19 +95,19 @@ $augmenter = new ContainerAugmenter($container, [ Convert any callable form into a `ReflectionFunctionAbstract`: ```php -use Respect\Parameter\Resolver; +use Respect\Parameter\Reflector; -Resolver::reflectCallable(fn() => ...); // Closure -Resolver::reflectCallable([$obj, 'method']); // Array callable -Resolver::reflectCallable(new Invocable()); // __invoke object -Resolver::reflectCallable('strlen'); // Function name -Resolver::reflectCallable('DateTime::createFromFormat'); // Static method +Reflector::reflectCallable(fn() => ...); // Closure +Reflector::reflectCallable([$obj, 'method']); // Array callable +Reflector::reflectCallable(new Invocable()); // __invoke object +Reflector::reflectCallable('strlen'); // Function name +Reflector::reflectCallable('DateTime::createFromFormat'); // Static method ``` ### Check accepted types ```php -Resolver::acceptsType($reflection, LoggerInterface::class); // true/false +Reflector::acceptsType($reflection, LoggerInterface::class); // true/false ``` ## API diff --git a/src/Reflector.php b/src/Reflector.php new file mode 100644 index 0000000..90df693 --- /dev/null +++ b/src/Reflector.php @@ -0,0 +1,71 @@ + + * SPDX-FileContributor: Henrique Moody + */ + +declare(strict_types=1); + +namespace Respect\Parameter; + +use Closure; +use ReflectionFunction; +use ReflectionFunctionAbstract; +use ReflectionMethod; +use ReflectionNamedType; + +use function assert; +use function is_a; +use function is_array; +use function is_object; +use function is_string; +use function str_contains; + +final class Reflector +{ + /** Reflect any callable into its ReflectionFunctionAbstract. */ + public static function reflectCallable(callable $callable): ReflectionFunctionAbstract + { + if ($callable instanceof Closure) { + return new ReflectionFunction($callable); + } + + if (is_array($callable)) { + /** @var array{object|class-string, string} $callable */ // phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable + + return new ReflectionMethod(...$callable); + } + + if (is_object($callable)) { + return new ReflectionMethod($callable, '__invoke'); + } + + if (is_string($callable) && str_contains($callable, '::')) { + return ReflectionMethod::createFromMethodName($callable); + } + + assert(is_string($callable)); + + return new ReflectionFunction($callable); + } + + /** @param class-string $type */ + public static function acceptsType(ReflectionFunctionAbstract $reflection, string $type): bool + { + foreach ($reflection->getParameters() as $param) { + $paramType = $param->getType(); + if (!$paramType instanceof ReflectionNamedType || $paramType->isBuiltin()) { + continue; + } + + if (is_a($paramType->getName(), $type, true)) { + return true; + } + } + + return false; + } +} diff --git a/tests/unit/ReflectorTest.php b/tests/unit/ReflectorTest.php new file mode 100644 index 0000000..af1ab74 --- /dev/null +++ b/tests/unit/ReflectorTest.php @@ -0,0 +1,94 @@ + + * SPDX-FileContributor: Henrique Moody + */ + +declare(strict_types=1); + +namespace Respect\Parameter\Test\Unit; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use ReflectionFunction; +use ReflectionMethod; +use Respect\Parameter\Reflector; +use Respect\Parameter\Test\Fixtures\ArrayContainer; +use Respect\Parameter\Test\Fixtures\SampleService; +use Respect\Parameter\Test\Fixtures\ServiceConsumer; + +#[CoversClass(Reflector::class)] +final class ReflectorTest extends TestCase +{ + #[Test] + public function itShouldDetectAcceptedType(): void + { + $constructor = (new ReflectionClass(ServiceConsumer::class))->getConstructor(); + self::assertNotNull($constructor); + + self::assertTrue(Reflector::acceptsType($constructor, SampleService::class)); + self::assertFalse(Reflector::acceptsType($constructor, ArrayContainer::class)); + } + + #[Test] + public function itShouldReflectClosure(): void + { + $fn = static function (string $a): string { + return $a; + }; + + $reflection = Reflector::reflectCallable($fn); + + self::assertInstanceOf(ReflectionFunction::class, $reflection); + self::assertSame('a', $reflection->getParameters()[0]->getName()); + } + + #[Test] + public function itShouldReflectArrayCallable(): void + { + $reflection = Reflector::reflectCallable([new ArrayContainer([]), 'has']); + + self::assertInstanceOf(ReflectionMethod::class, $reflection); + self::assertSame('has', $reflection->getName()); + } + + #[Test] + public function itShouldReflectInvocableObject(): void + { + $invocable = new class () { + public function __invoke(int $x): int + { + return $x; + } + }; + + $reflection = Reflector::reflectCallable($invocable); + + self::assertInstanceOf(ReflectionMethod::class, $reflection); + self::assertSame('__invoke', $reflection->getName()); + self::assertSame('x', $reflection->getParameters()[0]->getName()); + } + + #[Test] + public function itShouldReflectNamedFunction(): void + { + $reflection = Reflector::reflectCallable('strlen'); + + self::assertInstanceOf(ReflectionFunction::class, $reflection); + self::assertSame('strlen', $reflection->getName()); + } + + #[Test] + public function itShouldReflectStaticMethodString(): void + { + $reflection = Reflector::reflectCallable('DateTime::createFromFormat'); + + self::assertInstanceOf(ReflectionMethod::class, $reflection); + self::assertSame('createFromFormat', $reflection->getName()); + } +} From 11d5823443af89e6634bb2f41a016eece3d6b6b9 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Fri, 12 Jun 2026 23:40:10 +0200 Subject: [PATCH 3/3] Turn Resolver Into an Interface Consumers should depend on the resolution contract, not on the PSR-11 implementation, mirroring the Augmenter/ContainerAugmenter split. The concrete class becomes ContainerResolver, and the static helpers that already moved to Reflector are dropped from it. This is a breaking change, so the branch now aliases to 2.0.x-dev. Co-Authored-By: Claude Fable 5 --- README.md | 36 +++- composer.json | 5 + src/ContainerResolver.php | 124 ++++++++++++++ src/Resolver.php | 158 ++---------------- ...lverTest.php => ContainerResolverTest.php} | 85 ++-------- 5 files changed, 177 insertions(+), 231 deletions(-) create mode 100644 src/ContainerResolver.php rename tests/unit/{ResolverTest.php => ContainerResolverTest.php} (52%) diff --git a/README.md b/README.md index 16405a5..826cf46 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ composer require respect/parameter ## Usage +The package offers two contracts with different guarantees: + +- `Resolver` **completes a call**: it returns a full argument list keyed by + parameter name, padding gaps with container services, defaults, or `null`. + Implemented by `ContainerResolver`. +- `Augmenter` **assists a factory**: the given arguments stay authoritative — + never rebound, reordered, or padded — and the container only fills genuine + gaps. Implemented by `ContainerAugmenter`. + +Type-hint the interfaces to keep implementations swappable and testable. + ### Resolve from a container For each parameter the resolver tries, in order: @@ -21,13 +32,13 @@ For each parameter the resolver tries, in order: 5. `null` ```php -use Respect\Parameter\Resolver; +use Respect\Parameter\ContainerResolver; function notify(Mailer $mailer, Logger $logger, string $to, string $subject = 'Hi') { // ... } -$resolver = new Resolver($container); +$resolver = new ContainerResolver($container); $args = $resolver->resolve(new ReflectionFunction('notify'), ['bob@example.com']); // ['mailer' => Mailer, 'logger' => Logger, 'to' => 'bob@example.com', 'subject' => 'Hi'] ``` @@ -112,13 +123,20 @@ Reflector::acceptsType($reflection, LoggerInterface::class); // true/false ## API -| Method | Type | Description | -|-----------------------------------------|----------|------------------------------------------------------| -| `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 | +| Method | Defined on | Description | +|---------------------------------------------|-------------|------------------------------------------------------| +| `resolve($reflection, $positional)` | `Resolver` | Resolve parameters from positional args + container. Returns `array` keyed by parameter name | +| `resolveNamed($reflection, $named)` | `Resolver` | Resolve from named args (priority) + container. Returns `array` keyed by parameter name | +| `augment($reflection, $args)` | `Augmenter` | Fill only unfilled parameters from the container; given args are never rebound, reordered, or padded | +| `Reflector::reflectCallable($callable)` | `Reflector` | Any callable to `ReflectionFunctionAbstract` | +| `Reflector::acceptsType($reflection, $type)`| `Reflector` | Check if any parameter accepts a type | + +## Upgrading from 1.x + +- `Resolver` is now an interface; the concrete class is `ContainerResolver`. +- `Resolver::reflectCallable()` and `Resolver::acceptsType()` moved to `Reflector`. +- The new `Augmenter`/`ContainerAugmenter` fill unfilled parameters without + touching the given arguments. ## License diff --git a/composer.json b/composer.json index 607039d..b36a000 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,11 @@ "@phpunit" ] }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + }, "config": { "sort-packages": true, "allow-plugins": { diff --git a/src/ContainerResolver.php b/src/ContainerResolver.php new file mode 100644 index 0000000..e0ec976 --- /dev/null +++ b/src/ContainerResolver.php @@ -0,0 +1,124 @@ + + * SPDX-FileContributor: Henrique Moody + */ + +declare(strict_types=1); + +namespace Respect\Parameter; + +use Psr\Container\ContainerInterface; +use ReflectionFunctionAbstract; +use ReflectionNamedType; +use ReflectionParameter; + +use function array_key_exists; +use function count; + +/** + * Resolves function/constructor parameters from a PSR-11 container. + * + * For each parameter, tries by type (non-builtin) against the container. + * Falls through to positional arguments, then defaults. + */ +final readonly class ContainerResolver implements Resolver +{ + public function __construct(private ContainerInterface $container) + { + } + + /** + * @param array $arguments User-provided positional arguments + * + * @return array|array Resolved arguments keyed by parameter name + */ + public function resolve(ReflectionFunctionAbstract $reflection, array $arguments): array + { + $params = $reflection->getParameters(); + if ($params === []) { + return $arguments; + } + + $resolvedArgs = []; + $argIndex = 0; + $argCount = count($arguments); + + foreach ($params as $param) { + $paramName = $param->getName(); + $typeName = self::typeName($param); + + if ($typeName !== null && isset($arguments[$argIndex]) && $arguments[$argIndex] instanceof $typeName) { + $resolvedArgs[$paramName] = $arguments[$argIndex++]; + + continue; + } + + if ($typeName !== null && $this->container->has($typeName)) { + $resolvedArgs[$paramName] = $this->container->get($typeName); + + continue; + } + + if ($argIndex < $argCount) { + $resolvedArgs[$paramName] = $arguments[$argIndex++]; + } elseif ($param->isDefaultValueAvailable()) { + $resolvedArgs[$paramName] = $param->getDefaultValue(); + } else { + $resolvedArgs[$paramName] = null; + } + } + + return $resolvedArgs; + } + + /** + * @param array $namedArgs + * + * @return array Resolved arguments keyed by parameter name + */ + public function resolveNamed(ReflectionFunctionAbstract $reflection, array $namedArgs): array + { + $params = $reflection->getParameters(); + if ($params === []) { + return []; + } + + $resolvedArgs = []; + + foreach ($params as $param) { + $paramName = $param->getName(); + + if (array_key_exists($paramName, $namedArgs)) { + $resolvedArgs[$paramName] = $namedArgs[$paramName]; + + continue; + } + + $typeName = self::typeName($param); + + if ($typeName !== null && $this->container->has($typeName)) { + $resolvedArgs[$paramName] = $this->container->get($typeName); + + continue; + } + + $resolvedArgs[$paramName] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; + } + + return $resolvedArgs; + } + + /** @return class-string|null */ + private static function typeName(ReflectionParameter $param): string|null + { + $type = $param->getType(); + + /** @phpstan-ignore return.type */ + return $type instanceof ReflectionNamedType && !$type->isBuiltin() ? $type->getName() : null; + /* Ignore Reason: !isBuiltin() guarantees class-string */ + } +} diff --git a/src/Resolver.php b/src/Resolver.php index f115be1..1a5e1e9 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -4,174 +4,38 @@ * SPDX-License-Identifier: ISC * SPDX-FileCopyrightText: (c) Respect Project Contributors * SPDX-FileContributor: Alexandre Gomes Gaigalas + * SPDX-FileContributor: Henrique Moody */ declare(strict_types=1); namespace Respect\Parameter; -use Closure; -use Psr\Container\ContainerInterface; -use ReflectionFunction; use ReflectionFunctionAbstract; -use ReflectionMethod; -use ReflectionNamedType; -use ReflectionParameter; -use function array_key_exists; -use function assert; -use function count; -use function is_a; -use function is_array; -use function is_object; -use function is_string; -use function str_contains; - -/** - * Resolves function/constructor parameters from a PSR-11 container. - * - * For each parameter, tries by type (non-builtin) against the container. - * Falls through to positional arguments, then defaults. - */ -final readonly class Resolver +interface Resolver { - public function __construct(private ContainerInterface $container) - { - } - /** * Resolve parameters for a function/constructor from positional arguments. * + * For each parameter, tries in order: positional argument of matching type, + * container match by type, next positional argument, default value, null. + * * @param array $arguments User-provided positional arguments * * @return array|array Resolved arguments keyed by parameter name */ - public function resolve(ReflectionFunctionAbstract $reflection, array $arguments): array - { - $params = $reflection->getParameters(); - if ($params === []) { - return $arguments; - } - - $resolvedArgs = []; - $argIndex = 0; - $argCount = count($arguments); - - foreach ($params as $param) { - $paramName = $param->getName(); - $typeName = self::typeName($param); - - if ($typeName !== null && isset($arguments[$argIndex]) && $arguments[$argIndex] instanceof $typeName) { - $resolvedArgs[$paramName] = $arguments[$argIndex++]; - - continue; - } - - if ($typeName !== null && $this->container->has($typeName)) { - $resolvedArgs[$paramName] = $this->container->get($typeName); - - continue; - } - - if ($argIndex < $argCount) { - $resolvedArgs[$paramName] = $arguments[$argIndex++]; - } elseif ($param->isDefaultValueAvailable()) { - $resolvedArgs[$paramName] = $param->getDefaultValue(); - } else { - $resolvedArgs[$paramName] = null; - } - } - - return $resolvedArgs; - } + public function resolve(ReflectionFunctionAbstract $reflection, array $arguments): array; /** - * Resolve parameters from explicit named args + container. - * Named args take precedence over container values. + * Resolve parameters from explicit named arguments. + * + * Named arguments take precedence, gaps are filled from the container by + * type, then by default value, then null. * * @param array $namedArgs * * @return array Resolved arguments keyed by parameter name */ - public function resolveNamed(ReflectionFunctionAbstract $reflection, array $namedArgs): array - { - $params = $reflection->getParameters(); - if ($params === []) { - return []; - } - - $resolvedArgs = []; - - foreach ($params as $param) { - $paramName = $param->getName(); - - if (array_key_exists($paramName, $namedArgs)) { - $resolvedArgs[$paramName] = $namedArgs[$paramName]; - - continue; - } - - $typeName = self::typeName($param); - - if ($typeName !== null && $this->container->has($typeName)) { - $resolvedArgs[$paramName] = $this->container->get($typeName); - - continue; - } - - $resolvedArgs[$paramName] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; - } - - return $resolvedArgs; - } - - /** Reflect any callable into its ReflectionFunctionAbstract. */ - public static function reflectCallable(callable $callable): ReflectionFunctionAbstract - { - if ($callable instanceof Closure) { - return new ReflectionFunction($callable); - } - - if (is_array($callable)) { - /** @var array{object|class-string, string} $callable */ // phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable - - return new ReflectionMethod(...$callable); - } - - if (is_object($callable)) { - return new ReflectionMethod($callable, '__invoke'); - } - - if (is_string($callable) && str_contains($callable, '::')) { - return ReflectionMethod::createFromMethodName($callable); - } - - assert(is_string($callable)); - - return new ReflectionFunction($callable); - } - - /** @param class-string $type */ - public static function acceptsType(ReflectionFunctionAbstract $reflection, string $type): bool - { - foreach ($reflection->getParameters() as $param) { - $typeName = self::typeName($param); - - if ($typeName !== null && is_a($typeName, $type, true)) { - return true; - } - } - - return false; - } - - /** @return class-string|null */ - private static function typeName(ReflectionParameter $param): string|null - { - $type = $param->getType(); - - /** @phpstan-ignore return.type */ - return $type instanceof ReflectionNamedType && !$type->isBuiltin() ? $type->getName() : null; - /* Ignore Reason: !isBuiltin() guarantees class-string */ - } + public function resolveNamed(ReflectionFunctionAbstract $reflection, array $namedArgs): array; } diff --git a/tests/unit/ResolverTest.php b/tests/unit/ContainerResolverTest.php similarity index 52% rename from tests/unit/ResolverTest.php rename to tests/unit/ContainerResolverTest.php index c3e8dd6..fad87b4 100644 --- a/tests/unit/ResolverTest.php +++ b/tests/unit/ContainerResolverTest.php @@ -4,6 +4,7 @@ * SPDX-License-Identifier: ISC * SPDX-FileCopyrightText: (c) Respect Project Contributors * SPDX-FileContributor: Alexandre Gomes Gaigalas + * SPDX-FileContributor: Henrique Moody */ declare(strict_types=1); @@ -16,19 +17,19 @@ use ReflectionClass; use ReflectionFunction; use ReflectionMethod; -use Respect\Parameter\Resolver; +use Respect\Parameter\ContainerResolver; use Respect\Parameter\Test\Fixtures\ArrayContainer; use Respect\Parameter\Test\Fixtures\SampleService; use Respect\Parameter\Test\Fixtures\ServiceConsumer; -#[CoversClass(Resolver::class)] -final class ResolverTest extends TestCase +#[CoversClass(ContainerResolver::class)] +final class ContainerResolverTest extends TestCase { #[Test] public function itShouldResolveByType(): void { $service = new SampleService(); - $resolver = new Resolver(new ArrayContainer([SampleService::class => $service])); + $resolver = new ContainerResolver(new ArrayContainer([SampleService::class => $service])); $args = $resolver->resolve($this->constructorOf(ServiceConsumer::class), ['hello']); @@ -42,7 +43,7 @@ public function itShouldAllowUserOverride(): void { $default = new SampleService(); $explicit = new SampleService(); - $resolver = new Resolver(new ArrayContainer([SampleService::class => $default])); + $resolver = new ContainerResolver(new ArrayContainer([SampleService::class => $default])); $args = $resolver->resolve($this->constructorOf(ServiceConsumer::class), [$explicit, 'hello']); @@ -53,7 +54,7 @@ public function itShouldAllowUserOverride(): void #[Test] public function itShouldFallThroughToPositionalArgs(): void { - $resolver = new Resolver(new ArrayContainer()); + $resolver = new ContainerResolver(new ArrayContainer()); $args = $resolver->resolve($this->constructorOf(ServiceConsumer::class), ['positional']); @@ -63,7 +64,7 @@ public function itShouldFallThroughToPositionalArgs(): void #[Test] public function itShouldPassThroughWhenNoParams(): void { - $resolver = new Resolver(new ArrayContainer()); + $resolver = new ContainerResolver(new ArrayContainer()); $fn = new ReflectionFunction(static function (): void { }); @@ -72,77 +73,11 @@ public function itShouldPassThroughWhenNoParams(): void self::assertSame(['a', 'b'], $args); } - #[Test] - public function itShouldDetectAcceptedType(): void - { - $constructor = $this->constructorOf(ServiceConsumer::class); - - self::assertTrue(Resolver::acceptsType($constructor, SampleService::class)); - self::assertFalse(Resolver::acceptsType($constructor, ArrayContainer::class)); - } - - #[Test] - public function itShouldReflectClosure(): void - { - $fn = static function (string $a): string { - return $a; - }; - - $reflection = Resolver::reflectCallable($fn); - - self::assertInstanceOf(ReflectionFunction::class, $reflection); - self::assertSame('a', $reflection->getParameters()[0]->getName()); - } - - #[Test] - public function itShouldReflectArrayCallable(): void - { - $reflection = Resolver::reflectCallable([new ArrayContainer([]), 'has']); - - self::assertInstanceOf(ReflectionMethod::class, $reflection); - self::assertSame('has', $reflection->getName()); - } - - #[Test] - public function itShouldReflectInvocableObject(): void - { - $invocable = new class () { - public function __invoke(int $x): int - { - return $x; - } - }; - - $reflection = Resolver::reflectCallable($invocable); - - self::assertInstanceOf(ReflectionMethod::class, $reflection); - self::assertSame('__invoke', $reflection->getName()); - self::assertSame('x', $reflection->getParameters()[0]->getName()); - } - - #[Test] - public function itShouldReflectNamedFunction(): void - { - $reflection = Resolver::reflectCallable('strlen'); - - self::assertInstanceOf(ReflectionFunction::class, $reflection); - self::assertSame('strlen', $reflection->getName()); - } - - #[Test] - public function itShouldReflectStaticMethodString(): void - { - $reflection = Resolver::reflectCallable('DateTime::createFromFormat'); - - self::assertInstanceOf(ReflectionMethod::class, $reflection); - self::assertSame('createFromFormat', $reflection->getName()); - } - #[Test] public function itShouldResolveNamedArgsWithPrecedenceOverContainer(): void { $service = new SampleService(); - $resolver = new Resolver(new ArrayContainer([SampleService::class => $service])); + $resolver = new ContainerResolver(new ArrayContainer([SampleService::class => $service])); $args = $resolver->resolveNamed( $this->constructorOf(ServiceConsumer::class), @@ -158,7 +93,7 @@ public function itShouldResolveNamedArgsWithPrecedenceOverContainer(): void public function itShouldResolveNamedArgsWithEmptyNamedArray(): void { $service = new SampleService(); - $resolver = new Resolver(new ArrayContainer([SampleService::class => $service])); + $resolver = new ContainerResolver(new ArrayContainer([SampleService::class => $service])); $args = $resolver->resolveNamed( $this->constructorOf(ServiceConsumer::class),