Skip to content
Draft
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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -76,6 +116,7 @@ Resolver::acceptsType($reflection, LoggerInterface::class); // true/false
|-----------------------------------------|----------|------------------------------------------------------|
| `resolve($reflection, $positional)` | instance | Resolve parameters from positional args + container. Returns `array<string, mixed>` keyed by parameter name |
| `resolveNamed($reflection, $named)` | instance | Resolve from named args (priority) + container. Returns `array<string, mixed>` 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 |

Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions src/Augmenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

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<int|string, mixed> $arguments Positional and/or named arguments
*
* @return array<int|string, mixed>
*/
public function augment(ReflectionFunctionAbstract $reflection, array $arguments): array;
}
126 changes: 126 additions & 0 deletions src/ContainerAugmenter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

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<string, list<array{int, string, class-string}>> */
private array $augmentableParametersCache = [];

/** @param array<class-string> $unresolvableTypes */
public function __construct(
private readonly ContainerInterface $container,
private readonly array $unresolvableTypes = [],
) {
}

/**
* @param array<int|string, mixed> $arguments Positional and/or named arguments
*
* @return array<int|string, mixed>
*/
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<array{int, string, class-string}> */
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you run benchmarks? Claude often recommends caching reflection, but in PHP 8.5, he's wrong. Reflection is fast now, and caching it often decreases performance in some cases.

There are legitimate cases though (such as the attribute/template thing we did on Validation which involves multiple reflection calls that coalesce in a single cache).

{
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;
}
}
20 changes: 20 additions & 0 deletions tests/fixtures/OptionalServiceConsumer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

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,
) {
}
}
24 changes: 24 additions & 0 deletions tests/fixtures/functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/

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