diff --git a/README.md b/README.md index a1c44fb..b304680 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ foreach (Attributes::findTargetProperties(Column::class) as $target) { var_dump($target->attribute, $target->class, $target->name); } +// Find the target method-parameters of the UserInput attribute. +foreach (Attributes::findTargetMethodParameters(UserInput::class) as $target) { + var_dump($target->attribute, $target->class, $target->method, $target->name); +} + // Filter target methods using a predicate. // You can also filter target classes and properties. $predicate = fn($attribute) => is_a($attribute, Route::class, true); diff --git a/src/Attributes.php b/src/Attributes.php index e4d6547..dfecf1d 100644 --- a/src/Attributes.php +++ b/src/Attributes.php @@ -61,6 +61,18 @@ public static function findTargetProperties(string $attribute): array return self::getCollection()->findTargetProperties($attribute); } + /** + * @template T of object + * + * @param class-string $attribute + * + * @return TargetMethodParameter[] + */ + public static function findTargetMethodParameters(string $attribute): array + { + return self::getCollection()->findTargetMethodParameters($attribute); + } + /** * @param callable(class-string $attribute, class-string $class):bool $predicate * @@ -91,6 +103,16 @@ public static function filterTargetProperties(callable $predicate): array return self::getCollection()->filterTargetProperties($predicate); } + /** + * @param callable(class-string $attribute, class-string $class, string $property, string $method):bool $predicate + * + * @return array> + */ + public static function filterTargetMethodParameters(callable $predicate): array + { + return self::getCollection()->filterTargetMethodParameters($predicate); + } + /** * @param class-string $class * diff --git a/src/ClassAttributeCollector.php b/src/ClassAttributeCollector.php index dee4e13..3a8db59 100644 --- a/src/ClassAttributeCollector.php +++ b/src/ClassAttributeCollector.php @@ -25,6 +25,7 @@ public function __construct( * array, * array, * array, + * array>, * } * * @throws ReflectionException @@ -34,7 +35,7 @@ public function collectAttributes(string $class): array $classReflection = new ReflectionClass($class); if (self::isAttribute($classReflection)) { - return [ [], [], [] ]; + return [ [], [], [], [] ]; } $classAttributes = []; @@ -54,23 +55,15 @@ public function collectAttributes(string $class): array } $methodAttributes = []; + $methodParameterAttributes = []; foreach ($classReflection->getMethods() as $methodReflection) { - foreach ($methodReflection->getAttributes() as $attribute) { - if (self::isAttributeIgnored($attribute)) { - continue; - } - - $method = $methodReflection->name; - - $this->io->debug("Found attribute {$attribute->getName()} on $class::$method"); - - $methodAttributes[] = new TransientTargetMethod( - $attribute->getName(), - $attribute->getArguments(), - $method, - ); - } + $this->collectMethodAndParameterAttributes( + $class, + $methodReflection, + $methodAttributes, + $methodParameterAttributes, + ); } $propertyAttributes = []; @@ -94,7 +87,7 @@ public function collectAttributes(string $class): array } } - return [ $classAttributes, $methodAttributes, $propertyAttributes ]; + return [ $classAttributes, $methodAttributes, $propertyAttributes, $methodParameterAttributes ]; } /** @@ -124,4 +117,34 @@ private static function isAttributeIgnored(ReflectionAttribute $attribute): bool return isset($ignored[$attribute->getName()]); // @phpstan-ignore offsetAccess.nonOffsetAccessible } + + /** + * @param array $methodAttributes + * @param array> $methodParameterAttributes + * @return void + */ + private function collectMethodAndParameterAttributes(string $class, \ReflectionMethod $methodReflection, array &$methodAttributes, array &$methodParameterAttributes): void + { + $parameterAttributeCollector = new ParameterAttributeCollector($this->io); + foreach ($methodReflection->getAttributes() as $attribute) { + if (self::isAttributeIgnored($attribute)) { + continue; + } + + $method = $methodReflection->name; + + $this->io->debug("Found attribute {$attribute->getName()} on $class::$method"); + + $methodAttributes[] = new TransientTargetMethod( + $attribute->getName(), + $attribute->getArguments(), + $method, + ); + } + + $parameterAttributes = $parameterAttributeCollector->collectAttributes($methodReflection); + if ($parameterAttributes !== []) { + $methodParameterAttributes[] = $parameterAttributes; + } + } } diff --git a/src/Collection.php b/src/Collection.php index d95240e..29778b1 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -22,11 +22,15 @@ final class Collection * @param array> $targetProperties * Where _key_ is an attribute class and _value_ an array of arrays * where 0 are the attribute arguments, 1 is a target class, and 2 is the target property. + * @param array> $targetMethodParameters + * Where _key_ is an attribute class and _value_ an array of arrays + * where 0 are the attribute arguments, 1 is a target class, 2 is the target method, and 3 is the target parameter. */ public function __construct( private array $targetClasses, private array $targetMethods, private array $targetProperties, + private array $targetMethodParameters, ) { } @@ -109,6 +113,46 @@ private static function createMethodAttribute( } } + /** + * @template T of object + * + * @param class-string $attribute + * + * @return array> + */ + public function findTargetMethodParameters(string $attribute): array + { + return array_map( + fn(array $t) => self::createMethodParameterAttribute($attribute, ...$t), + $this->targetMethodParameters[$attribute] ?? [], + ); + } + + /** + * @template T of object + * + * @param class-string $attribute + * @param array $arguments + * @param class-string $class + * @param non-empty-string $method + * @param non-empty-string $parameter + * + * @return TargetMethodParameter + */ + private static function createMethodParameterAttribute(string $attribute, array $arguments, string $class, string $method, string $parameter): object + { + try { + $a = new $attribute(...$arguments); + return new TargetMethodParameter($a, $class, $method, $parameter); + } catch (Throwable $e) { + throw new RuntimeException( + "An error occurred while instantiating attribute $attribute on method $class::$method($parameter)", + 0, + $e, + ); + } + } + /** * @template T of object * @@ -196,6 +240,32 @@ public function filterTargetMethods(callable $predicate): array return $ar; } + /** + * @param callable(class-string $attribute, class-string $class, non-empty-string $method, non-empty-string $parameter):bool $predicate + * + * @return array> + */ + public function filterTargetMethodParameters(callable $predicate): array + { + $ar = []; + + foreach ($this->targetMethodParameters as $attribute => $references) { + foreach ($references as [$arguments, $class, $method, $parameter]) { + if ($predicate($attribute, $class, $method, $parameter)) { + $ar[] = self::createMethodParameterAttribute( + $attribute, + $arguments, + $class, + $method, + $parameter + ); + } + } + } + + return $ar; + } + /** * @param callable(class-string $attribute, class-string $class, non-empty-string $property):bool $predicate * diff --git a/src/MemoizeAttributeCollector.php b/src/MemoizeAttributeCollector.php index 25f3157..3d5982e 100644 --- a/src/MemoizeAttributeCollector.php +++ b/src/MemoizeAttributeCollector.php @@ -23,12 +23,14 @@ class MemoizeAttributeCollector * array, * array, * array, + * array>, * }> * Where _key_ is a class and _value is an array where: * - `0` is a timestamp * - `1` is an array of class attributes * - `2` is an array of method attributes * - `3` is an array of property attributes + * - `4` is an array of arrays. _key_ is a method name and _value_ parameter attributes */ private array $state; @@ -59,7 +61,8 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, - ] = $this->state[$class] ?? [ 0, [], [], [] ]; + $methodParameterAttributes, + ] = $this->state[$class] ?? [ 0, [], [], [], [] ]; $mtime = filemtime($filepath); @@ -76,6 +79,7 @@ public function collectAttributes(array $classMap): TransientCollection $classAttributes, $methodAttributes, $propertyAttributes, + $methodParameterAttributes, ] = $classAttributeCollector->collectAttributes($class); } catch (Throwable $e) { $this->io->error( @@ -83,7 +87,7 @@ public function collectAttributes(array $classMap): TransientCollection ); } - $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes ]; + $this->state[$class] = [ time(), $classAttributes, $methodAttributes, $propertyAttributes, $methodParameterAttributes ]; } if (count($classAttributes)) { @@ -92,6 +96,9 @@ public function collectAttributes(array $classMap): TransientCollection if (count($methodAttributes)) { $collector->addMethodAttributes($class, $methodAttributes); } + if (count($methodParameterAttributes)) { + $collector->addMethodParameterAttributes($class, $methodParameterAttributes); + } if (count($propertyAttributes)) { $collector->addTargetProperties($class, $propertyAttributes); } diff --git a/src/ParameterAttributeCollector.php b/src/ParameterAttributeCollector.php new file mode 100644 index 0000000..62ab593 --- /dev/null +++ b/src/ParameterAttributeCollector.php @@ -0,0 +1,56 @@ + + */ + public function collectAttributes(\ReflectionFunctionAbstract $reflectionFunctionAbstract): array + { + $funcParameterAttributes = []; + foreach ($reflectionFunctionAbstract->getParameters() as $parameter) { + $attributes = $parameter->getAttributes(); + $functionName = $reflectionFunctionAbstract->name; + $parameterName = $parameter->name; + assert($functionName !== ''); + assert($parameterName !== ''); + + $paramLabel = ''; + if ($reflectionFunctionAbstract instanceof \ReflectionMethod) { + $paramLabel = $reflectionFunctionAbstract->class . '::' . $functionName . '(' . $parameterName . ')'; + } elseif ($reflectionFunctionAbstract instanceof \ReflectionFunction) { + $paramLabel = $functionName . '(' . $parameterName . ')'; + } + + foreach ($attributes as $attribute) { + $this->io->debug("Found attribute {$attribute->getName()} on $paramLabel"); + + $funcParameterAttributes[] = new TransientTargetMethodParameter( + $attribute->getName(), + $attribute->getArguments(), + $functionName, + $parameterName + ); + } + } + + return $funcParameterAttributes; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 20a8441..7bebda8 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -27,7 +27,7 @@ final class Plugin implements PluginInterface, EventSubscriberInterface { public const CACHE_DIR = '.composer-attribute-collector'; public const VERSION_MAJOR = 2; - public const VERSION_MINOR = 0; + public const VERSION_MINOR = 1; /** * @uses onPostAutoloadDump diff --git a/src/TargetMethodParameter.php b/src/TargetMethodParameter.php new file mode 100644 index 0000000..bcdc8de --- /dev/null +++ b/src/TargetMethodParameter.php @@ -0,0 +1,28 @@ +>> + */ + public array $methodParameters = []; + /** * @var array> * Where _key_ is a target class. @@ -45,6 +50,16 @@ public function addMethodAttributes(string $class, iterable $targets): void $this->methods[$class] = $targets; } + /** + * @param class-string $class + * @param iterable> $targets + * The target class. + */ + public function addMethodParameterAttributes(string $class, iterable $targets): void + { + $this->methodParameters[$class] = $targets; + } + /** * @param class-string $class * @param iterable $targets diff --git a/src/TransientCollectionRenderer.php b/src/TransientCollectionRenderer.php index 60f8018..4c5411d 100644 --- a/src/TransientCollectionRenderer.php +++ b/src/TransientCollectionRenderer.php @@ -2,6 +2,7 @@ namespace olvlvl\ComposerAttributeCollector; +use function is_iterable; use function var_export; /** @@ -16,6 +17,7 @@ public static function render(TransientCollection $collector): string $targetClassesCode = self::targetsToCode($collector->classes); $targetMethodsCode = self::targetsToCode($collector->methods); $targetPropertiesCode = self::targetsToCode($collector->properties); + $targetMethodParametersCode = self::targetsToCode($collector->methodParameters); return <<> $targetByClass + * @param iterable>> $targetByClass * * @return string */ @@ -45,7 +48,7 @@ private static function targetsToCode(iterable $targetByClass): string /** * //phpcs:disable Generic.Files.LineLength.TooLong - * @param iterable> $targetByClass + * @param iterable>> $targetByClass * * @return array, class-string, 2?:non-empty-string }>> */ @@ -54,14 +57,29 @@ private static function targetsToArray(iterable $targetByClass): array $by = []; foreach ($targetByClass as $class => $targets) { - foreach ($targets as $t) { - $a = [ $t->arguments, $class ]; - - if ($t instanceof TransientTargetMethod || $t instanceof TransientTargetProperty) { - $a[] = $t->name; + foreach ($targets as $target) { + if (!is_iterable($target)) { + $target = [$target]; } - $by[$t->attribute][] = $a; + foreach ($target as $t) { + // args in order how the Target* classes expects them in __construct() + $args = [ $t->arguments, $class ]; + + if ( + $t instanceof TransientTargetMethod + || $t instanceof TransientTargetProperty + || $t instanceof TransientTargetMethodParameter + ) { + $args[] = $t->name; + } + + if ($t instanceof TransientTargetMethodParameter) { + $args[] = $t->method; + } + + $by[$t->attribute][] = $args; + } } } diff --git a/src/TransientTargetMethodParameter.php b/src/TransientTargetMethodParameter.php new file mode 100644 index 0000000..9b393d0 --- /dev/null +++ b/src/TransientTargetMethodParameter.php @@ -0,0 +1,24 @@ + $arguments The attribute arguments. + * @param non-empty-string $method The target method. + * @param non-empty-string $name The target parameter. + */ + public function __construct( + public string $attribute, + public array $arguments, + public string $method, + public string $name + ) { + } +} diff --git a/tests/Acme/PSR4/Presentation/ArticleController.php b/tests/Acme/PSR4/Presentation/ArticleController.php index 07ccb3a..537bb1f 100644 --- a/tests/Acme/PSR4/Presentation/ArticleController.php +++ b/tests/Acme/PSR4/Presentation/ArticleController.php @@ -11,6 +11,8 @@ use Acme\Attribute\Resource; use Acme\Attribute\Route; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; #[Resource("articles")] final class ArticleController @@ -24,4 +26,15 @@ public function list(): void public function show(int $id): void { } + + #[Route("/articles/method/", 'GET', 'articles:method')] + public function aMethod( + #[ParameterA("my parameter label")] + $myParameter, + #[ParameterB("my 2nd parameter label", "some more data")] + $anotherParameter, + #[ParameterA("my yet another parameter label")] + $yetAnotherParameter + ) { + } } diff --git a/tests/Acme81/Attribute/ParameterA.php b/tests/Acme81/Attribute/ParameterA.php new file mode 100644 index 0000000..66cc034 --- /dev/null +++ b/tests/Acme81/Attribute/ParameterA.php @@ -0,0 +1,16 @@ +label = $label; + } +} diff --git a/tests/Acme81/Attribute/ParameterB.php b/tests/Acme81/Attribute/ParameterB.php new file mode 100644 index 0000000..5ac8087 --- /dev/null +++ b/tests/Acme81/Attribute/ParameterB.php @@ -0,0 +1,19 @@ +label = $label; + $this->moreData = $moreData; + } +} diff --git a/tests/Acme81/PSR4/AFunction.php b/tests/Acme81/PSR4/AFunction.php new file mode 100644 index 0000000..c475fc1 --- /dev/null +++ b/tests/Acme81/PSR4/AFunction.php @@ -0,0 +1,11 @@ + 'articles:show', 'pattern' => "/articles/{id}", 'method' => 'GET' ], 'show', ), + new TransientTargetMethod( + 'Acme\Attribute\Route', + [ "/articles/method/", 'GET', 'articles:method' ], + 'aMethod', + ), ], [], + [ + [ + new TransientTargetMethodParameter( + 'Acme81\Attribute\ParameterA', + ["my parameter label"], + 'aMethod', + 'myParameter' + ), + new TransientTargetMethodParameter( + 'Acme81\Attribute\ParameterB', + ["my 2nd parameter label", "some more data"], + 'aMethod', + 'anotherParameter' + ), + new TransientTargetMethodParameter( + 'Acme81\Attribute\ParameterA', + ["my yet another parameter label"], + 'aMethod', + 'yetAnotherParameter' + ), + ] + ], ] ], @@ -109,6 +140,7 @@ public static function provideCollectAttributes(): array new TransientTargetMethod('Acme\Attribute\Subscribe', [], 'onEventA'), ], [], + [], ] ], @@ -128,6 +160,7 @@ public static function provideCollectAttributes(): array new TransientTargetProperty('Acme\Attribute\ActiveRecord\Text', [], 'body'), new TransientTargetProperty('Acme\Attribute\ActiveRecord\Boolean', [], 'active'), ], + [], ] ], diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php index 4dfabbb..5c3a1ea 100644 --- a/tests/CollectionTest.php +++ b/tests/CollectionTest.php @@ -18,10 +18,13 @@ use Acme\PSR4\DeleteMenu; use Acme\PSR4\Presentation\ArticleController; use Closure; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\Collection; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethod; +use olvlvl\ComposerAttributeCollector\TargetMethodParameter; use olvlvl\ComposerAttributeCollector\TargetProperty; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -52,6 +55,8 @@ public function testInstantiationErrorIsDecorated(string $expectedMessage, Closu Serial::class => [ [ [ 'Primary' => true ], Article::class, 'id' ], ] + ], + targetMethodParameters: [ ] ); @@ -104,6 +109,8 @@ public function testFilterTargetClasses(): void targetMethods: [ ], targetProperties: [ + ], + targetMethodParameters: [ ] ); @@ -134,6 +141,8 @@ public function testFilterTargetMethods(): void ], ], targetProperties: [ + ], + targetMethodParameters: [ ] ); @@ -146,6 +155,36 @@ public function testFilterTargetMethods(): void ], $actual); } + public function testFilterTargetMethodParameters(): void + { + $collection = new Collection( + targetClasses: [ + ], + targetMethods: [ + ], + targetProperties: [ + ], + targetMethodParameters: [ + ParameterA::class => [ + [ [ 'a' ], ArticleController::class, 'myMethod', 'myParamA', ], + [ [ 'a2' ], ArticleController::class, 'myMethod', 'myParamA2' ], + [ [ 'a3' ], ArticleController::class, 'myFoo', 'fooParam' ], + ], + ParameterB::class => [ + [ [ 'b', 'more data'], ArticleController::class, 'myMethod', 'myParamB' ], + ], + ] + ); + + $actual = $collection->filterTargetMethodParameters(fn($a) => is_a($a, ParameterA::class, true)); + + $this->assertEquals([ + new TargetMethodParameter(new ParameterA('a'), ArticleController::class, 'myMethod', 'myParamA'), + new TargetMethodParameter(new ParameterA('a2'), ArticleController::class, 'myMethod', 'myParamA2'), + new TargetMethodParameter(new ParameterA('a3'), ArticleController::class, 'myFoo', 'fooParam'), + ], $actual); + } + public function testFilterTargetProperties(): void { $collection = new Collection( @@ -175,6 +214,8 @@ public function testFilterTargetProperties(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + targetMethodParameters: [ ] ); @@ -220,6 +261,8 @@ public function testForClass(): void Text::class => [ [ [ ], Article::class, 'body' ], ] + ], + targetMethodParameters: [ ] ); diff --git a/tests/PluginTest.php b/tests/PluginTest.php index 9d3f885..150fc3c 100644 --- a/tests/PluginTest.php +++ b/tests/PluginTest.php @@ -23,13 +23,17 @@ use Acme\Attribute\Route; use Acme\Attribute\Subscribe; use Acme\PSR4\Presentation\ArticleController; +use Acme81\Attribute\ParameterA; +use Acme81\Attribute\ParameterB; use Composer\IO\NullIO; use olvlvl\ComposerAttributeCollector\Attributes; use olvlvl\ComposerAttributeCollector\Config; use olvlvl\ComposerAttributeCollector\Plugin; use olvlvl\ComposerAttributeCollector\TargetClass; use olvlvl\ComposerAttributeCollector\TargetMethod; +use olvlvl\ComposerAttributeCollector\TargetMethodParameter; use olvlvl\ComposerAttributeCollector\TargetProperty; +use PhpParser\Node\Param; use PHPUnit\Framework\TestCase; use ReflectionException; @@ -158,6 +162,10 @@ public static function provideTargetMethods(): array [ Route::class, [ + [ + new Route("/articles/method/", 'GET', 'articles:method'), + 'Acme\PSR4\Presentation\ArticleController::aMethod' + ], [ new Route("/articles", 'GET', 'articles:list'), 'Acme\PSR4\Presentation\ArticleController::list' @@ -188,6 +196,52 @@ public static function provideTargetMethods(): array ]; } + /** + * @dataProvider provideTargetMethodParameters + * + * @param class-string $attribute + * @param array $expected + */ + public function testTargetMethodParameters(string $attribute, array $expected): void + { + $actual = Attributes::findTargetMethodParameters($attribute); + + $this->assertEquals($expected, $this->collectMethodParameters($actual)); + } + + /** + * @return array }> + */ + public static function provideTargetMethodParameters(): array + { + return [ + + [ + ParameterA::class, + [ + [ + new ParameterA('my parameter label'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(myParameter)' + ], + [ + new ParameterA('my yet another parameter label'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(yetAnotherParameter)' + ], + ] + ], + [ + ParameterB::class, + [ + [ + new ParameterB('my 2nd parameter label', 'some more data'), + 'Acme\PSR4\Presentation\ArticleController::aMethod(anotherParameter)' + ], + ] + ], + + ]; + } + /** * @dataProvider provideTargetProperties * @@ -256,6 +310,7 @@ public function testFilterTargetMethods(): void ); $this->assertEquals([ + [ new Route("/articles/method/", 'GET', 'articles:method'), 'Acme\PSR4\Presentation\ArticleController::aMethod' ], [ new Route("/articles", 'GET', 'articles:list'), 'Acme\PSR4\Presentation\ArticleController::list' ], [ new Route("/articles/{id}", 'GET', 'articles:show'), 'Acme\PSR4\Presentation\ArticleController::show' ], [ new Get(), 'Acme\Presentation\FileController::list' ], @@ -295,6 +350,18 @@ public function testFilterTargetMethods81(): void $this->assertEquals($expected, $actual); } + public function testFilterTargetMethodParameters(): void + { + $actual = Attributes::filterTargetMethodParameters( + Attributes::predicateForAttributeInstanceOf(ParameterA::class) + ); + + $this->assertEquals([ + [ new ParameterA("my parameter label"), 'Acme\PSR4\Presentation\ArticleController::aMethod(myParameter)' ], + [ new ParameterA('my yet another parameter label'), 'Acme\PSR4\Presentation\ArticleController::aMethod(yetAnotherParameter)' ], + ], $this->collectMethodParameters($actual)); + } + public function testFilterTargetProperties(): void { $actual = Attributes::filterTargetProperties( @@ -320,8 +387,9 @@ public function testForClass(): void ], $forClass->classAttributes); $this->assertEquals([ - 'list' => [ new Route("/articles", id: 'articles:list') ], - 'show' => [ new Route("/articles/{id}", id: 'articles:show') ], + 'list' => [ new Route("/articles", 'GET', 'articles:list') ], + 'show' => [ new Route("/articles/{id}", 'GET', 'articles:show') ], + 'aMethod' => [ new Route("/articles/method/", 'GET', 'articles:method') ], ], $forClass->methodsAttributes); } @@ -365,6 +433,26 @@ private function collectMethods(array $targets): array return $methods; } + /** + * @template T of object + * + * @param TargetMethodParameter[] $targets + * + * @return array + */ + private function collectMethodParameters(array $targets): array + { + $parameters = []; + + foreach ($targets as $target) { + $parameters[] = [ $target->attribute, "$target->class::$target->method($target->name)" ]; + } + + usort($parameters, fn($a, $b) => $a[1] <=> $b[1]); + + return $parameters; + } + /** * @template T of object *