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; + } +}