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 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. 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/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/MemoryCacheFunctionAnalyzer.php b/src/MemoryCacheFunctionAnalyzer.php new file mode 100644 index 0000000..5ed5930 --- /dev/null +++ b/src/MemoryCacheFunctionAnalyzer.php @@ -0,0 +1,36 @@ +>> + */ + private array $cache = []; + + public function __construct( + private readonly FunctionAnalyzer $analyzer, + ) {} + + public function analyze(string|\Closure $function, string $attribute, array $scopes = []): object + { + // We cannot cache a closure, as we have no reliable identifier for it. + if ($function instanceof \Closure) { + return $this->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/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/Psr6FunctionCacheAnalyzer.php b/src/Psr6FunctionCacheAnalyzer.php new file mode 100644 index 0000000..29b89a3 --- /dev/null +++ b/src/Psr6FunctionCacheAnalyzer.php @@ -0,0 +1,54 @@ +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/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/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 { 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/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/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); + }, + ]; + } +} 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/MemoryFunctionCacheAnalyzerTest.php b/tests/MemoryFunctionCacheAnalyzerTest.php new file mode 100644 index 0000000..a388d0c --- /dev/null +++ b/tests/MemoryFunctionCacheAnalyzerTest.php @@ -0,0 +1,17 @@ +getMockAnalyzer()); + } +} diff --git a/tests/Psr6CacheAnalyzerTest.php b/tests/Psr6CacheAnalyzerTest.php index 1a1145b..19724aa 100644 --- a/tests/Psr6CacheAnalyzerTest.php +++ b/tests/Psr6CacheAnalyzerTest.php @@ -9,11 +9,10 @@ class Psr6CacheAnalyzerTest extends TestCase { - use CacheTestMethods; + use ClassAnalyzerCacheTestMethods; public function getTestSubject(): ClassAnalyzer { return new Psr6CacheAnalyzer($this->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()); + } +}