From af2e3453ddd63f48bc1b3c1e1d7ab5a58329f4ad Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Sun, 31 Dec 2023 14:20:19 -0600 Subject: [PATCH 1/7] Add a function/closure analyzer. --- src/FromReflectionFunction.php | 21 ++++ src/FuncAnalyzer.php | 65 +++++++++++ src/FunctionAnalyzer.php | 21 ++++ src/ReflectionDefinitionBuilder.php | 6 +- tests/Attributes/Functions/HasParameters.php | 32 ++++++ .../Functions/IncludesReflection.php | 16 +++ .../Attributes/Functions/ParameterAttrib.php | 9 ++ tests/Attributes/Functions/RequiredArg.php | 9 ++ tests/Attributes/Functions/SubChild.php | 9 ++ tests/Attributes/Functions/SubParent.php | 23 ++++ tests/FunctionAnalyzerTest.php | 105 ++++++++++++++++++ 11 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/FromReflectionFunction.php create mode 100644 src/FuncAnalyzer.php create mode 100644 src/FunctionAnalyzer.php create mode 100644 tests/Attributes/Functions/HasParameters.php create mode 100644 tests/Attributes/Functions/IncludesReflection.php create mode 100644 tests/Attributes/Functions/ParameterAttrib.php create mode 100644 tests/Attributes/Functions/RequiredArg.php create mode 100644 tests/Attributes/Functions/SubChild.php create mode 100644 tests/Attributes/Functions/SubParent.php create mode 100644 tests/FunctionAnalyzerTest.php diff --git a/src/FromReflectionFunction.php b/src/FromReflectionFunction.php new file mode 100644 index 0000000..dd9d504 --- /dev/null +++ b/src/FromReflectionFunction.php @@ -0,0 +1,21 @@ +getAttribute($subject, $attribute) ?? new $attribute; + + if ($funcDef instanceof FromReflectionFunction) { + $funcDef->fromReflection($subject); + } + + $defBuilder->loadSubAttributes($funcDef, $subject); + + if ($funcDef instanceof ParseParameters) { + $parameters = $defBuilder->getDefinitions( + $subject->getParameters(), + fn (\ReflectionParameter $p) + => $defBuilder->getComponentDefinition($p, $funcDef->parameterAttribute(), $funcDef->includeParametersByDefault(), FromReflectionParameter::class, $funcDef) + ); + $funcDef->setParameters($parameters); + } + + if ($funcDef instanceof Finalizable) { + $funcDef->finalize(); + } + + return $funcDef; + } catch (\ArgumentCountError $e) { + $this->translateArgumentCountError($e); + } + } + + /** + * Throws a domain-specific exception based on an ArgumentCountError. + * + * This is absolutely hideous, but this is what happens when your throwable + * puts all the useful information in the message text rather than as useful + * properties or methods or something. + * + * Conclusion: Write better, more debuggable exceptions than PHP does. + */ + protected function translateArgumentCountError(\ArgumentCountError $error): never + { + $message = $error->getMessage(); + // PHPStan doesn't understand this syntax style of sscanf(), so skip it. + // @phpstan-ignore-next-line + [$classAndMethod, $passedCount, $file, $line, $expectedCount] = sscanf( + string: $message, + format: "Too few arguments to function %s::%s, %d passed in %s on line %d and exactly %d expected" + ); + [$className, $methodName] = \explode('::', $classAndMethod ?? ''); + + throw RequiredAttributeArgumentsMissing::create($className, $error); + } +} diff --git a/src/FunctionAnalyzer.php b/src/FunctionAnalyzer.php new file mode 100644 index 0000000..5f62922 --- /dev/null +++ b/src/FunctionAnalyzer.php @@ -0,0 +1,21 @@ + $attribute + * The fully qualified class name of the class attribute to analyze. + * @param array $scopes + * The scopes for which this analysis should run. + * @return T + * The function attribute requested, including dependent data as appropriate. + */ + public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object; +} diff --git a/src/ReflectionDefinitionBuilder.php b/src/ReflectionDefinitionBuilder.php index 4fc6515..3a2c280 100644 --- a/src/ReflectionDefinitionBuilder.php +++ b/src/ReflectionDefinitionBuilder.php @@ -17,7 +17,7 @@ class ReflectionDefinitionBuilder { public function __construct( protected readonly AttributeParser $parser, - protected readonly Analyzer $analyzer, + protected readonly ?Analyzer $analyzer = null, ) {} /** @@ -64,7 +64,7 @@ public function getComponentDefinition(\Reflector $reflection, string $attribute $this->loadSubAttributes($def, $reflection); - if ($def instanceof CustomAnalysis) { + if ($def instanceof CustomAnalysis && $this->analyzer) { $def->customAnalysis($this->analyzer); } @@ -105,7 +105,7 @@ public function getMethodDefinition(\ReflectionMethod $reflection, string $attri $def->setParameters($parameters); } - if ($def instanceof CustomAnalysis) { + if ($def instanceof CustomAnalysis && $this->analyzer) { $def->customAnalysis($this->analyzer); } diff --git a/tests/Attributes/Functions/HasParameters.php b/tests/Attributes/Functions/HasParameters.php new file mode 100644 index 0000000..9413219 --- /dev/null +++ b/tests/Attributes/Functions/HasParameters.php @@ -0,0 +1,32 @@ +parameters = $parameters; + } + + public function includeParametersByDefault(): bool + { + return $this->parseParametersByDefault; + } + + public function parameterAttribute(): string + { + return $this->parameter; + } + + +} diff --git a/tests/Attributes/Functions/IncludesReflection.php b/tests/Attributes/Functions/IncludesReflection.php new file mode 100644 index 0000000..d8f9b72 --- /dev/null +++ b/tests/Attributes/Functions/IncludesReflection.php @@ -0,0 +1,16 @@ +name = $subject->name; + } +} diff --git a/tests/Attributes/Functions/ParameterAttrib.php b/tests/Attributes/Functions/ParameterAttrib.php new file mode 100644 index 0000000..4166633 --- /dev/null +++ b/tests/Attributes/Functions/ParameterAttrib.php @@ -0,0 +1,9 @@ + 'fromSubChild']; + } + + public function fromSubChild(?SubChild $child): void + { + $this->child = $child; + } +} diff --git a/tests/FunctionAnalyzerTest.php b/tests/FunctionAnalyzerTest.php new file mode 100644 index 0000000..bbe9828 --- /dev/null +++ b/tests/FunctionAnalyzerTest.php @@ -0,0 +1,105 @@ +ns = __NAMESPACE__; + } + + #[Test, DataProvider('attributeTestProvider')] + public function analyze_functions(string|\Closure $subject, string $attribute, callable $test): void + { + $analyzer = new FuncAnalyzer(); + + $classDef = $analyzer->analyze($subject, $attribute); + + $test($classDef); + } + + #[Test] + public function missing_required_fields(): void + { + $this->expectException(RequiredAttributeArgumentsMissing::class); + $analyzer = new FuncAnalyzer(); + + $analyzer->analyze("{$this->ns}\\required_attribute_arguments_missing", Attributes\Functions\RequiredArg::class); + } + + /** + * @see analyze_classes() + */ + public static function attributeTestProvider(): \Generator + { + $ns = __NAMESPACE__ . '\\'; + + yield 'Includes Reflection' => [ + 'subject' => "{$ns}from_reflection", + 'attribute' => IncludesReflection::class, + 'test' => static function(IncludesReflection $funcDef) use ($ns) { + static::assertEquals("{$ns}from_reflection", $funcDef->name); + }, + ]; + + yield 'Includes Sub-attribute' => [ + 'subject' => "{$ns}has_sub_attributes", + 'attribute' => SubParent::class, + 'test' => static function(SubParent $funcDef) use ($ns) { + static::assertEquals('Override', $funcDef->child->b); + }, + ]; + + yield 'Includes parameters' => [ + 'subject' => "{$ns}has_parameters", + 'attribute' => HasParameters::class, + 'test' => static function(HasParameters $funcDef) use ($ns) { + static::assertEquals('default', $funcDef->parameters['first']->a); + static::assertEquals('Override', $funcDef->parameters['second']->a); + }, + ]; + + yield 'Closure' => [ + 'subject' => #[HasParameters(ParameterAttrib::class)] fn (#[ParameterAttrib] int $first, + #[ParameterAttrib('Override')] int $second, + ): int => $first * $second, + 'attribute' => HasParameters::class, + 'test' => static function(HasParameters $funcDef) use ($ns) { + static::assertEquals('default', $funcDef->parameters['first']->a); + static::assertEquals('Override', $funcDef->parameters['second']->a); + }, + ]; + } +} From 1adb8221fba8586b009c33e8bc7d736bd425de3e Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Wed, 7 Feb 2024 19:43:28 -0600 Subject: [PATCH 2/7] Add a memory cache for the Function Analyzer. --- src/FuncAnalyzer.php | 1 - src/MemoryCacheFunctionAnalyzer.php | 30 ++++++++ ....php => ClassAnalyzerCacheTestMethods.php} | 2 +- tests/FunctionAnalyzerCacheTestMethods.php | 74 +++++++++++++++++++ tests/MemoryCacheAnalyzerTest.php | 3 +- tests/MemoryCacheFunctionAnalyzerTest.php | 15 ++++ tests/Psr6CacheAnalyzerTest.php | 2 +- 7 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/MemoryCacheFunctionAnalyzer.php rename tests/{CacheTestMethods.php => ClassAnalyzerCacheTestMethods.php} (98%) create mode 100644 tests/FunctionAnalyzerCacheTestMethods.php create mode 100644 tests/MemoryCacheFunctionAnalyzerTest.php diff --git a/src/FuncAnalyzer.php b/src/FuncAnalyzer.php index 8a69a17..86a9439 100644 --- a/src/FuncAnalyzer.php +++ b/src/FuncAnalyzer.php @@ -9,7 +9,6 @@ public function analyze(string|\Closure $function, string $attribute, array $sco $parser = new AttributeParser($scopes); $defBuilder = new ReflectionDefinitionBuilder($parser); - try { $subject = new \ReflectionFunction($function); diff --git a/src/MemoryCacheFunctionAnalyzer.php b/src/MemoryCacheFunctionAnalyzer.php new file mode 100644 index 0000000..af15d9c --- /dev/null +++ b/src/MemoryCacheFunctionAnalyzer.php @@ -0,0 +1,30 @@ +analyzer->analyze($function, $attribute, $scopes); + } + + $scopekey = ''; + if ($scopes) { + sort($scopes); + $scopekey = implode(',', $scopes); + } + + return $this->cache[$function][$attribute][$scopekey] ??= $this->analyzer->analyze($function, $attribute, $scopes); + } +} diff --git a/tests/CacheTestMethods.php b/tests/ClassAnalyzerCacheTestMethods.php similarity index 98% rename from tests/CacheTestMethods.php rename to tests/ClassAnalyzerCacheTestMethods.php index 6cc37ee..074dae1 100644 --- a/tests/CacheTestMethods.php +++ b/tests/ClassAnalyzerCacheTestMethods.php @@ -14,7 +14,7 @@ /** * Use this trait in a test class for each cache implementation. */ -trait CacheTestMethods +trait ClassAnalyzerCacheTestMethods { abstract public function getTestSubject(): ClassAnalyzer; diff --git a/tests/FunctionAnalyzerCacheTestMethods.php b/tests/FunctionAnalyzerCacheTestMethods.php new file mode 100644 index 0000000..bd21d08 --- /dev/null +++ b/tests/FunctionAnalyzerCacheTestMethods.php @@ -0,0 +1,74 @@ +getTestSubject(); + + $ns = __NAMESPACE__ . '\\'; + + $def1 = $analyzer->analyze("{$ns}test_function", IncludesReflection::class); + $def2 = $analyzer->analyze("{$ns}test_function", IncludesReflection::class); + $def3 = $analyzer->analyze("{$ns}test_function", SubParent::class); + + self::assertSame($def1, $def2); + self::assertNotSame($def1, $def3); + } + + #[Test] + public function cache_analysis_scopes(): void + { + $analyzer = $this->getTestSubject(); + + $ns = __NAMESPACE__ . '\\'; + + $def1 = $analyzer->analyze("{$ns}test_function", IncludesReflection::class); + $def2 = $analyzer->analyze("{$ns}test_function", IncludesReflection::class); + $def3 = $analyzer->analyze("{$ns}test_function", IncludesReflection::class, scopes: ['One']); + + self::assertSame($def1, $def2); + self::assertNotSame($def1, $def3); + } +} diff --git a/tests/MemoryCacheAnalyzerTest.php b/tests/MemoryCacheAnalyzerTest.php index ef535a1..380d8d3 100644 --- a/tests/MemoryCacheAnalyzerTest.php +++ b/tests/MemoryCacheAnalyzerTest.php @@ -8,11 +8,10 @@ class MemoryCacheAnalyzerTest extends TestCase { - use CacheTestMethods; + use ClassAnalyzerCacheTestMethods; public function getTestSubject(): ClassAnalyzer { return new MemoryCacheAnalyzer($this->getMockAnalyzer()); } - } diff --git a/tests/MemoryCacheFunctionAnalyzerTest.php b/tests/MemoryCacheFunctionAnalyzerTest.php new file mode 100644 index 0000000..5157340 --- /dev/null +++ b/tests/MemoryCacheFunctionAnalyzerTest.php @@ -0,0 +1,15 @@ +getMockAnalyzer()); + } +} diff --git a/tests/Psr6CacheAnalyzerTest.php b/tests/Psr6CacheAnalyzerTest.php index 1a1145b..b5d19af 100644 --- a/tests/Psr6CacheAnalyzerTest.php +++ b/tests/Psr6CacheAnalyzerTest.php @@ -9,7 +9,7 @@ class Psr6CacheAnalyzerTest extends TestCase { - use CacheTestMethods; + use ClassAnalyzerCacheTestMethods; public function getTestSubject(): ClassAnalyzer { From 3eec1e12b304d1743349568900d19b72000c9e01 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Wed, 7 Feb 2024 19:57:51 -0600 Subject: [PATCH 3/7] Add a PSR-6 Function analyzer cache. --- src/MemoryCacheFunctionAnalyzer.php | 3 +- src/Psr6FunctionCacheAnalyzer.php | 54 +++++++++++++++++++ ...hp => MemoryFunctionCacheAnalyzerTest.php} | 4 +- tests/Psr6CacheAnalyzerTest.php | 1 - tests/Psr6FunctionCacheAnalyzerTest.php | 18 +++++++ 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/Psr6FunctionCacheAnalyzer.php rename tests/{MemoryCacheFunctionAnalyzerTest.php => MemoryFunctionCacheAnalyzerTest.php} (76%) create mode 100644 tests/Psr6FunctionCacheAnalyzerTest.php diff --git a/src/MemoryCacheFunctionAnalyzer.php b/src/MemoryCacheFunctionAnalyzer.php index af15d9c..9764173 100644 --- a/src/MemoryCacheFunctionAnalyzer.php +++ b/src/MemoryCacheFunctionAnalyzer.php @@ -1,5 +1,7 @@ analyzer->analyze($function, $attribute, $scopes); + } + + $key = $this->buildKey($function, $attribute, $scopes); + + $item = $this->pool->getItem($key); + if ($item->isHit()) { + return $item->get(); + } + + // No expiration; the cached data would only need to change + // if the source code changes. + $value = $this->analyzer->analyze($function, $attribute, $scopes); + $item->set($value); + $this->pool->save($item); + return $value; + } + + /** + * Generates the cache key for this request. + * + * @param array $scopes + * The scopes for which this analysis should run. + */ + private function buildKey(string $function, string $attribute, array $scopes): string + { + $parts = [ + $function, + $attribute, + implode(',', $scopes), + ]; + + return str_replace('\\', '_', \implode('-', $parts)); + } +} diff --git a/tests/MemoryCacheFunctionAnalyzerTest.php b/tests/MemoryFunctionCacheAnalyzerTest.php similarity index 76% rename from tests/MemoryCacheFunctionAnalyzerTest.php rename to tests/MemoryFunctionCacheAnalyzerTest.php index 5157340..a388d0c 100644 --- a/tests/MemoryCacheFunctionAnalyzerTest.php +++ b/tests/MemoryFunctionCacheAnalyzerTest.php @@ -1,10 +1,12 @@ getMockAnalyzer(), new MemoryPool()); } - } diff --git a/tests/Psr6FunctionCacheAnalyzerTest.php b/tests/Psr6FunctionCacheAnalyzerTest.php new file mode 100644 index 0000000..9a9caec --- /dev/null +++ b/tests/Psr6FunctionCacheAnalyzerTest.php @@ -0,0 +1,18 @@ +getMockAnalyzer(), new MemoryPool()); + } +} From 47078a326ab9c5e3f7a58c63bdac2f948533a9a7 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Wed, 7 Feb 2024 19:59:39 -0600 Subject: [PATCH 4/7] Make more properties readonly. --- src/MemoryCacheAnalyzer.php | 2 +- src/Psr6CacheAnalyzer.php | 4 ++-- src/RequiredAttributeArgumentsMissing.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/MemoryCacheAnalyzer.php b/src/MemoryCacheAnalyzer.php index 3440306..3f26bd8 100644 --- a/src/MemoryCacheAnalyzer.php +++ b/src/MemoryCacheAnalyzer.php @@ -14,7 +14,7 @@ class MemoryCacheAnalyzer implements ClassAnalyzer */ private array $cache = []; - public function __construct(private ClassAnalyzer $analyzer) + public function __construct(private readonly ClassAnalyzer $analyzer) {} public function analyze(object|string $class, string $attribute, array $scopes = []): object diff --git a/src/Psr6CacheAnalyzer.php b/src/Psr6CacheAnalyzer.php index 7d6bf35..1589994 100644 --- a/src/Psr6CacheAnalyzer.php +++ b/src/Psr6CacheAnalyzer.php @@ -12,8 +12,8 @@ class Psr6CacheAnalyzer implements ClassAnalyzer { public function __construct( - private ClassAnalyzer $analyzer, - private CacheItemPoolInterface $pool, + private readonly ClassAnalyzer $analyzer, + private readonly CacheItemPoolInterface $pool, ) {} public function analyze(object|string $class, string $attribute, array $scopes = []): object diff --git a/src/RequiredAttributeArgumentsMissing.php b/src/RequiredAttributeArgumentsMissing.php index 1b18408..b05659a 100644 --- a/src/RequiredAttributeArgumentsMissing.php +++ b/src/RequiredAttributeArgumentsMissing.php @@ -6,7 +6,7 @@ class RequiredAttributeArgumentsMissing extends \LogicException { - public string $attributeType; + public readonly string $attributeType; public static function create(string $attributeType, \Throwable $previous): self { From 7099e4d20ee30b0392e4b74c93a4dd7b53430826 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Wed, 7 Feb 2024 20:03:26 -0600 Subject: [PATCH 5/7] Missing property. --- src/MemoryCacheFunctionAnalyzer.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/MemoryCacheFunctionAnalyzer.php b/src/MemoryCacheFunctionAnalyzer.php index 9764173..5ed5930 100644 --- a/src/MemoryCacheFunctionAnalyzer.php +++ b/src/MemoryCacheFunctionAnalyzer.php @@ -9,6 +9,11 @@ */ class MemoryCacheFunctionAnalyzer implements FunctionAnalyzer { + /** + * @var array>> + */ + private array $cache = []; + public function __construct( private readonly FunctionAnalyzer $analyzer, ) {} From fc1e4a133e8f6478b0eeb209d15d6d12d44ed1c0 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Sat, 24 Feb 2024 10:26:56 -0600 Subject: [PATCH 6/7] Add FuncAnalyzer to README docs. --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 3f7d795..13dab8e 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,46 @@ As a last resort, an attribute may also implement the [`CustomAnalysis`](src/Cus The Analyzer is designed to be usable on its own without any setup. Making it available via a Dependency Injection Container is recommended. An appropriate cache wrapper should also be included in the DI configuration. +## Function analysis + +There is also support for retrieving attributes on functions, via a separate analyzer (that works essentially the same way). The `FuncAnalyzer` class implements the `FunctionAnalyzer` interface. + +```php +use Crell\AttributeUtils\FuncAnalyzer; + +#[MyFunc] +function beep(int $a) {} + +$closure = #[MyClosure] fn(int $a) => $a + 1; + +// For functions... +$analyzer = new FuncAnalyzer(); +$funcDef = $analyzer->analyze('beep', MyFunc::class); + +// For closures +$analyzer = new FuncAnalyzer(); +$funcDef = $analyzer->analyze($closure, MyFunc::class); +``` + +Sub-attributes, `ParseParameters`, and `Finalizable` all work on functions exactly as they do on classes and methods, as do scopes. There is also a corresponding `FromReflectionFunction` interface for receiving the `ReflectionFunction` object. + +There are also cache wrappers available for the FuncAnalyzer as well. They work the same way as on the class analyzer. + +```php +# In-memory cache. +$analyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer()); + +# PSR-6 cache. +$anaylzer = new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $somePsr6CachePoolObject); + +# Both caches. +$analyzer = new MemoryCacheFunctionAnalyzer( + new Psr6CacheFunctionAnalyzer(new FuncAnalyzer(), $psr6CachePool) +); +``` + +As with the class analyzer, it's best to wire these up in your DI container. + ## The Reflect library One of the many uses for `Analyzer` is to extract reflection information from a class. Sometimes you only need some of it, but there's no reason you can't grab all of it. The result is an attribute that can carry all the same information as reflection, but can be cached if desired while reflection objects cannot be. From 96a0d95751ccf8c376fdf764037a9972dbf592e5 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Sat, 24 Feb 2024 10:27:54 -0600 Subject: [PATCH 7/7] Update Changelog. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d1509..bbecfc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `AttributeUtils` will be documented in this file. Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. +## 1.1.0 - 2024-02-24 + +### Added +- Added a separate analyzer for functions and closures. + ## 1.0.0 - 2023-10-30 ### Added