From c9f033ec2628944feb3535a6c21a8f9ae5c435b1 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:01:03 -0600 Subject: [PATCH 01/12] Use AttributeUtils for introspecting listeners. --- composer.json | 1 + src/Listener.php | 132 ++++++++++++++++-- src/ListenerAfter.php | 5 +- src/ListenerBefore.php | 5 +- src/ListenerPriority.php | 2 +- src/OrderedListenerProvider.php | 4 +- src/OrderedProviderInterface.php | 8 +- src/ProviderBuilder.php | 3 +- src/ProviderCollector.php | 98 ++++++++----- .../CompiledListenerProviderAttributeTest.php | 23 +-- tests/Listeners/AtListen.php | 15 ++ tests/Listeners/AtListenService.php | 13 ++ tests/Listeners/MockMalformedSubscriber.php | 4 + tests/OrderedListenerProviderServiceTest.php | 4 +- 14 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 tests/Listeners/AtListen.php create mode 100644 tests/Listeners/AtListenService.php diff --git a/composer.json b/composer.json index 28969d5..c9cbd22 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ ], "require": { "php": "~8.1", + "crell/attributeutils": "dev-func-analyzer", "crell/ordered-collection": "v2.x-dev", "fig/event-dispatcher-util": "^1.3", "psr/container": "^1.0 || ^2.0", diff --git a/src/Listener.php b/src/Listener.php index b5b4384..e08cb38 100644 --- a/src/Listener.php +++ b/src/Listener.php @@ -5,13 +5,34 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Finalizable; +use Crell\AttributeUtils\FromReflectionMethod; +use Crell\AttributeUtils\HasSubAttributes; +use Crell\AttributeUtils\ParseMethods; +use Crell\AttributeUtils\ParseStaticMethods; +use Crell\AttributeUtils\ReadsClass; /** * The main attribute to customize a listener. */ #[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -class Listener implements ListenerAttribute +class Listener implements ListenerAttribute, HasSubAttributes, ParseMethods, ReadsClass, Finalizable, FromReflectionMethod, ParseStaticMethods { + /** + * @var Listener[] + * + * This is only used by the class-level attribute. When used on a method level it is ignored. + */ + public readonly array $methods; + + /** + * @var Listener[] + * + * This is only used by the class-level attribute. When used on a method level it is ignored. + */ + public readonly array $staticMethods; + + /** @var string[] */ public array $before = []; @@ -19,6 +40,13 @@ class Listener implements ListenerAttribute public array $after = []; public ?int $priority = null; + public readonly bool $hasDefinition; + + /** + * This is only meaningful on the method attribute. + */ + public readonly int $paramCount; + /** * @param ?string $id * The identifier by which this listener should be known. If not specified one will be generated. @@ -29,13 +57,88 @@ class Listener implements ListenerAttribute public function __construct( public ?string $id = null, public ?string $type = null, - ) {} + ) { + if ($id || $this->type) { + $this->hasDefinition ??= true; + } + } + + public function fromReflection(\ReflectionMethod $subject): void + { + $this->paramCount = $subject->getNumberOfRequiredParameters(); + if ($this->paramCount === 1) { + $params = $subject->getParameters(); + $this->type ??= $params[0]?->getType()?->getName(); + } + } + + /** + * This will only get called when this attribute is on a class. + * + * @param Listener[] $methods + */ + public function setMethods(array $methods): void + { + $this->methods = $methods; + } + + public function includeMethodsByDefault(): bool + { + return true; + } + + public function methodAttribute(): string + { + return __CLASS__; + } + + public function setStaticMethods(array $methods): void + { + $this->staticMethods = $methods; + } + + public function includeStaticMethodsByDefault(): bool + { + return true; + } + + public function staticMethodAttribute(): string + { + return __CLASS__; + } + + + /** + * This will only get called when this attribute is used on a method. + * + * @param Listener $class + */ + public function fromClassAttribute(object $class): void + { + $this->id ??= $class->id; + $this->type ??= $class->type; + $this->priority ??= $class->priority; + $this->before ??= $class->before; + $this->after ??= $class->after; + } + + public function subAttributes(): array + { + return [ + ListenerBefore::class => 'fromBefore', + ListenerAfter::class => 'fromAfter', + ListenerPriority::class => 'fromPriority', + ]; + } /** * @param array $attribs */ - public function absorbBefore(array $attribs): void + public function fromBefore(array $attribs): void { + if ($attribs) { + $this->hasDefinition ??= true; + } foreach ($attribs as $attrib) { $this->id ??= $attrib->id; $this->type ??= $attrib->type; @@ -46,8 +149,11 @@ public function absorbBefore(array $attribs): void /** * @param array $attribs */ - public function absorbAfter(array $attribs): void + public function fromAfter(array $attribs): void { + if ($attribs) { + $this->hasDefinition ??= true; + } foreach ($attribs as $attrib) { $this->id ??= $attrib->id; $this->type ??= $attrib->type; @@ -55,10 +161,20 @@ public function absorbAfter(array $attribs): void } } - public function absorbPriority(ListenerPriority $attrib): void + public function fromPriority(?ListenerPriority $attrib): void + { + if ($attrib) { + $this->hasDefinition ??= true; + } + $this->id ??= $attrib?->id; + $this->type ??= $attrib?->type; + $this->priority = $attrib?->priority; + } + + public function finalize(): void { - $this->id ??= $attrib->id; - $this->type ??= $attrib->type; - $this->priority = $attrib->priority; + $this->methods ??= []; + $this->hasDefinition ??= false; } + } diff --git a/src/ListenerAfter.php b/src/ListenerAfter.php index ef5e1e5..84e6f1b 100644 --- a/src/ListenerAfter.php +++ b/src/ListenerAfter.php @@ -5,9 +5,10 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Multivalue; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class ListenerAfter implements ListenerAttribute +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +class ListenerAfter implements ListenerAttribute, Multivalue { /** @var string[] */ public array $after = []; diff --git a/src/ListenerBefore.php b/src/ListenerBefore.php index db0db92..3e29fb0 100644 --- a/src/ListenerBefore.php +++ b/src/ListenerBefore.php @@ -5,9 +5,10 @@ namespace Crell\Tukio; use Attribute; +use Crell\AttributeUtils\Multivalue; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class ListenerBefore implements ListenerAttribute +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +class ListenerBefore implements ListenerAttribute, Multivalue { /** @var string[] */ public array $before = []; diff --git a/src/ListenerPriority.php b/src/ListenerPriority.php index 6dd8612..17ba90e 100644 --- a/src/ListenerPriority.php +++ b/src/ListenerPriority.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class ListenerPriority implements ListenerAttribute { public function __construct( diff --git a/src/OrderedListenerProvider.php b/src/OrderedListenerProvider.php index 8d80a81..d1bedc1 100644 --- a/src/OrderedListenerProvider.php +++ b/src/OrderedListenerProvider.php @@ -57,7 +57,8 @@ public function listenerService( if (class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); + $def = $this->classAnalyzer->analyze($service, Listener::class); + $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); // If any ordering is specified explicitly, that completely overrules any @@ -70,7 +71,6 @@ public function listenerService( return $this->listener($this->makeListenerForService($service, $method), priority: $def->priority, before: $def->before, after: $def->after, id: $id, type: $type); } - $id ??= $service . '-' . $method; return $this->listener($this->makeListenerForService($service, $method), priority: $priority, before: $before, after: $after, id: $id, type: $type); } diff --git a/src/OrderedProviderInterface.php b/src/OrderedProviderInterface.php index 8d71734..7d75a28 100644 --- a/src/OrderedProviderInterface.php +++ b/src/OrderedProviderInterface.php @@ -211,12 +211,12 @@ public function addListenerServiceAfter(string $after, string $service, string $ /** * Registers all listener methods on a service as listeners. * - * A method on the specified class is a listener if: - * - It is public. + * A public method on the specified class is a listener if either of these is true: * - It's name is in the form on*. onUpdate(), onUserLogin(), onHammerTime() will all be registered. - * - It has a Listener/ListenerBefore/ListenerAfter attribute. + * - It has a Listener/ListenerBefore/ListenerAfter/ListenerPriority attribute. * - * The event type the listener is for will be derived from the type declaration in the method signature. + * The event type the listener is for will be derived from the type declaration in the method signature, + * unless overriden by an attribute.. * * @param class-string $class * The class name to be registered as a subscriber. diff --git a/src/ProviderBuilder.php b/src/ProviderBuilder.php index d943cda..19e5704 100644 --- a/src/ProviderBuilder.php +++ b/src/ProviderBuilder.php @@ -58,7 +58,8 @@ public function listenerService( if (class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); + $def = $this->classAnalyzer->analyze($service, Listener::class); + $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); // If any ordering is specified explicitly, that completely overrules any diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index ce27568..5eb13cf 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -4,6 +4,11 @@ namespace Crell\Tukio; +use Crell\AttributeUtils\Analyzer; +use Crell\AttributeUtils\ClassAnalyzer; +use Crell\AttributeUtils\FuncAnalyzer; +use Crell\AttributeUtils\FunctionAnalyzer; +use Crell\AttributeUtils\MemoryCacheAnalyzer; use Crell\OrderedCollection\MultiOrderedCollection; use Crell\Tukio\Entry\ListenerEntry; use Fig\EventDispatcher\ParameterDeriverTrait; @@ -17,8 +22,10 @@ abstract class ProviderCollector implements OrderedProviderInterface */ protected MultiOrderedCollection $listeners; - public function __construct() - { + public function __construct( + protected readonly FunctionAnalyzer $funcAnalyzer = new FuncAnalyzer(), + protected readonly ClassAnalyzer $classAnalyzer = new MemoryCacheAnalyzer(new Analyzer()), + ) { $this->listeners = new MultiOrderedCollection(); } @@ -86,26 +93,44 @@ public function addListenerServiceAfter(string $after, string $service, string $ public function addSubscriber(string $class, string $service): void { + // First allow manual registration through the proxy object. + // This is deprecated. Please don't use it. $proxy = $this->addSubscribersByProxy($class, $service); - try { - $methods = (new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC); - - $methods = array_filter($methods, static fn(\ReflectionMethod $r) - => !in_array($r->getName(), $proxy->getRegisteredMethods(), true)); + $proxyRegisteredMethods = $proxy->getRegisteredMethods(); - /** @var \ReflectionMethod $rMethod */ - foreach ($methods as $rMethod) { - $this->addSubscriberMethod($rMethod, $class, $service); + try { + // Get all methods on the class, via AttributeUtils to handle reflection and caching. + $methods = $this->classAnalyzer->analyze($class, Listener::class)->methods; + + /** + * @var string $methodName + * @var Listener $def + */ + foreach ($methods as $methodName => $def) { + if (in_array($methodName, $proxyRegisteredMethods, true)) { + // Exclude anything already registered by proxy. + continue; + } + // If there was an attribute-based definition, that takes priority. + if ($def->hasDefinition) { + $this->listenerService($service, $methodName, $def->type, $def->priority, $def->before,$def->after, $def->id); + } elseif (str_starts_with($methodName, 'on') && $def->paramCount === 1) { + // Try to register it iff the method starts with "on" and has only one required parameter. + // (More than one required parameter is guaranteed to fail when invoked.) + if (!$def->type) { + throw InvalidTypeException::fromClassCallable($class, $methodName); + } + $this->listenerService($service, $methodName, type: $def->type, id: $service . '-' . $methodName); + } } } catch (\ReflectionException $e) { throw new \RuntimeException('Type error registering subscriber.', 0, $e); } } - protected function addSubscriberMethod(\ReflectionMethod $rMethod, string $class, string $service): void + protected function addSubscriberMethod(string $methodName, Listener $rMethod, string $class, string $service): void { - $methodName = $rMethod->getName(); $params = $rMethod->getParameters(); if (count($params) < 1) { @@ -113,7 +138,11 @@ protected function addSubscriberMethod(\ReflectionMethod $rMethod, string $class return; } - $def = $this->getAttributeForRef($rMethod); + // Definitely wasteful to do this here. + // @todo Refactor. + + + $def = $this->getAttributeDefinition([$rMethod->class, $rMethod->name]); if ($def->id || $def->before || $def->after || $def->priority || str_starts_with($methodName, 'on')) { $paramType = $params[0]->getType(); @@ -158,32 +187,27 @@ protected function addSubscribersByProxy(string $class, string $service): Listen */ protected function getAttributeDefinition(callable|array $listener): Listener { - $ref = null; + if ($this->isFunctionCallable($listener) || $this->isClosureCallable($listener)) { + return $this->funcAnalyzer->analyze($listener, Listener::class); + } - if ($this->isFunctionCallable($listener)) { - /** @var string $listener */ - $ref = new \ReflectionFunction($listener); - // @phpstan-ignore-next-line - } elseif ($this->isClassCallable($listener)) { - // PHPStan says you cannot use array destructuring on a callable, but you can - // if you know that it's an array (which in context we do). - // @phpstan-ignore-next-line - [$class, $method] = $listener; - $ref = (new \ReflectionClass($class))->getMethod($method); - // @phpstan-ignore-next-line - } elseif ($this->isObjectCallable($listener)) { - // PHPStan says you cannot use array destructuring on a callable, but you can - // if you know that it's an array (which in context we do). - // @phpstan-ignore-next-line - [$class, $method] = $listener; - $ref = (new \ReflectionObject($class))->getMethod($method); + if ($this->isObjectCallable($listener)) { + /** @var array $listener */ + [$object, $method] = $listener; + + $def = $this->classAnalyzer->analyze($object::class, Listener::class); + return $def->methods[$method]; } - if (!$ref) { - return new Listener(); + if ($this->isClassCallable($listener)) { + /** @var array $listener */ + [$class, $method] = $listener; + + $def = $this->classAnalyzer->analyze($class, Listener::class); + return $def->staticMethods[$method]; } - return $this->getAttributeForRef($ref); + return new Listener(); } protected function getAttributeForRef(\Reflector $ref): Listener @@ -323,10 +347,11 @@ protected function isFunctionCallable(callable|array $callable): bool /** * Determines if a callable represents a method on an object. * + * @param callable|array{0: string, 1: string} $callable * @return bool * True if the callable represents a method object, false otherwise. */ - protected function isObjectCallable(callable $callable): bool + protected function isObjectCallable(callable|array $callable): bool { return is_array($callable) && is_object($callable[0]); } @@ -334,10 +359,11 @@ protected function isObjectCallable(callable $callable): bool /** * Determines if a callable represents a closure/anonymous function. * + * @param callable|array{0: string, 1: string} $callable * @return bool * True if the callable represents a closure object, false otherwise. */ - protected function isClosureCallable(callable $callable): bool + protected function isClosureCallable(callable|array $callable): bool { return $callable instanceof \Closure; } diff --git a/tests/CompiledListenerProviderAttributeTest.php b/tests/CompiledListenerProviderAttributeTest.php index bbaacd3..1da76c7 100644 --- a/tests/CompiledListenerProviderAttributeTest.php +++ b/tests/CompiledListenerProviderAttributeTest.php @@ -33,23 +33,6 @@ function atNoListen(EventOne $event): void throw new \Exception('This should not be called'); } -class AtListen -{ - #[Listener] - public static function listen(CollectingEvent $event): void - { - $event->add('C'); - } -} - -class AtListenService -{ - public static function listen(CollectingEvent $event): void - { - $event->add('D'); - } -} - class CompiledListenerProviderAttributeTest extends TestCase { use MakeCompiledProviderTrait; @@ -63,13 +46,13 @@ public function compiled_provider_triggers_in_order(): void $builder = new ProviderBuilder(); $container = new MockContainer(); - $container->addService('D', new AtListenService()); + $container->addService('D', new Listeners\AtListenService()); $ns = "\\Crell\\Tukio"; $builder->addListener("{$ns}\\atListenerB"); $builder->addListener("{$ns}\\atListenerA"); - $builder->addListener([AtListen::class, 'listen']); + $builder->addListener([Listeners\AtListen::class, 'listen']); $builder->addListener("{$ns}\\atNoListen"); $provider = $this->makeProvider($builder, $container, $class, $namespace); @@ -99,7 +82,7 @@ public function add_subscriber(): void $subscriber = new MockAttributedSubscriber(); $container->addService('subscriber', $subscriber); - $builder->addSubscriber(MockSubscriber::class, 'subscriber'); + $builder->addSubscriber(MockAttributedSubscriber::class, 'subscriber'); $provider = $this->makeProvider($builder, $container, $class, $namespace); diff --git a/tests/Listeners/AtListen.php b/tests/Listeners/AtListen.php new file mode 100644 index 0000000..6d958cc --- /dev/null +++ b/tests/Listeners/AtListen.php @@ -0,0 +1,15 @@ +add('C'); + } +} diff --git a/tests/Listeners/AtListenService.php b/tests/Listeners/AtListenService.php new file mode 100644 index 0000000..0b0f23a --- /dev/null +++ b/tests/Listeners/AtListenService.php @@ -0,0 +1,13 @@ +add('D'); + } +} diff --git a/tests/Listeners/MockMalformedSubscriber.php b/tests/Listeners/MockMalformedSubscriber.php index cf277d7..a02f669 100644 --- a/tests/Listeners/MockMalformedSubscriber.php +++ b/tests/Listeners/MockMalformedSubscriber.php @@ -16,6 +16,7 @@ public function onA(CollectingEvent $event): void { $event->add('A'); } + /** * This function should have automatic registration attempted, and fail due to missing a type. */ @@ -24,6 +25,7 @@ public function onNone($event): void { $event->add('A'); } + /** * This function should have manual registration attempted, and fail due to missing a type. */ @@ -39,12 +41,14 @@ public static function registerListenersDirect(ListenerProxy $proxy): void // Should fail and throw an exception: $proxy->addListener('abnormalNameWithoutType'); } + public static function registerListenersBefore(ListenerProxy $proxy): void { $a = $proxy->addListener('onA'); // Should fail and throw an exception: $proxy->addListenerBefore($a, 'abnormalNameWithoutType'); } + public static function registerListenersAfter(ListenerProxy $proxy): void { $a = $proxy->addListener('onA'); diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index 611ef74..b198de5 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -176,11 +176,11 @@ public function malformed_subscriber_automatic_fails(): void $subscriber = new MockMalformedSubscriber(); - $container->addService('subscriber', $subscriber); + $container->addService(MockMalformedSubscriber::class, $subscriber); $p = new OrderedListenerProvider($container); - $p->addSubscriber(MockMalformedSubscriber::class, 'subscriber'); + $p->addSubscriber(MockMalformedSubscriber::class, MockMalformedSubscriber::class); } #[Test] From fe4dd8f04633cafe618cd2983bed871a42d3ece9 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:01:48 -0600 Subject: [PATCH 02/12] Remove dead code. --- src/ProviderCollector.php | 64 --------------------------------------- 1 file changed, 64 deletions(-) diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index 5eb13cf..bc0f0bc 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -129,44 +129,6 @@ public function addSubscriber(string $class, string $service): void } } - protected function addSubscriberMethod(string $methodName, Listener $rMethod, string $class, string $service): void - { - $params = $rMethod->getParameters(); - - if (count($params) < 1) { - // Skip this method, as it doesn't take arguments. - return; - } - - // Definitely wasteful to do this here. - // @todo Refactor. - - - $def = $this->getAttributeDefinition([$rMethod->class, $rMethod->name]); - - if ($def->id || $def->before || $def->after || $def->priority || str_starts_with($methodName, 'on')) { - $paramType = $params[0]->getType(); - - $id = $def->id ?? $service . '-' . $methodName; - // getName() is not a documented part of the Reflection API, but it's always there. - // @phpstan-ignore-next-line - $type = $def->type ?? $paramType?->getName() ?? throw InvalidTypeException::fromClassCallable($class, $methodName); - - $this->listenerService($service, $methodName, $type, $def->priority, $def->before,$def->after, $id); - } - } - - /** - * @return array - */ - protected function findAttributesOnMethod(\ReflectionMethod $rMethod): array - { - $attributes = array_map(static fn (\ReflectionAttribute $attrib): object - => $attrib->newInstance(), $rMethod->getAttributes(Listener::class, \ReflectionAttribute::IS_INSTANCEOF)); - - return $attributes; - } - /** * @param class-string $class */ @@ -210,32 +172,6 @@ protected function getAttributeDefinition(callable|array $listener): Listener return new Listener(); } - protected function getAttributeForRef(\Reflector $ref): Listener - { - // All this logic is very similar to AttributeUtils Sub-Attributes. - // Maybe AU can be improved to make sub-attributes accessible outside - // the analyzer? - - /** @var Listener $def */ - $def = $this->getAttributes(Listener::class, $ref)[0] ?? new Listener(); - - /** @var ListenerBefore[] $beforeAttribs */ - $beforeAttribs = $this->getAttributes(ListenerBefore::class, $ref); - $def->absorbBefore($beforeAttribs); - - /** @var ListenerAfter[] $afterAttribs */ - $afterAttribs = $this->getAttributes(ListenerAfter::class, $ref); - $def->absorbAfter($afterAttribs); - - /** @var ListenerPriority|null $priorityAttrib */ - $priorityAttrib = $this->getAttributes(ListenerPriority::class, $ref)[0] ?? null; - if ($priorityAttrib) { - $def->absorbPriority($priorityAttrib); - } - - return $def; - } - /** * @param class-string $attribute * @param \Reflector $ref From b1e36f852b870e56a534d801a6703e031c756414 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:09:20 -0600 Subject: [PATCH 03/12] Make the separate service name optional when registering a subscriber. --- src/OrderedProviderInterface.php | 6 +++--- src/ProviderCollector.php | 4 +++- tests/OrderedListenerProviderServiceTest.php | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/OrderedProviderInterface.php b/src/OrderedProviderInterface.php index 7d75a28..d92331f 100644 --- a/src/OrderedProviderInterface.php +++ b/src/OrderedProviderInterface.php @@ -220,8 +220,8 @@ public function addListenerServiceAfter(string $after, string $service, string $ * * @param class-string $class * The class name to be registered as a subscriber. - * @param string $service - * The name of a service in the container. + * @param null|string $service + * The name of a service in the container. If not specified, it's assumed to be the same as the class. */ - public function addSubscriber(string $class, string $service): void; + public function addSubscriber(string $class, ?string $service = null): void; } diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index bc0f0bc..288dadd 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -91,8 +91,10 @@ public function addListenerServiceAfter(string $after, string $service, string $ return $this->listenerService($service, $method, $type, after: [$after], id: $id); } - public function addSubscriber(string $class, string $service): void + public function addSubscriber(string $class, ?string $service = null): void { + $service ??= $class; + // First allow manual registration through the proxy object. // This is deprecated. Please don't use it. $proxy = $this->addSubscribersByProxy($class, $service); diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index b198de5..c01cd6d 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -180,7 +180,7 @@ public function malformed_subscriber_automatic_fails(): void $p = new OrderedListenerProvider($container); - $p->addSubscriber(MockMalformedSubscriber::class, MockMalformedSubscriber::class); + $p->addSubscriber(MockMalformedSubscriber::class); } #[Test] From 62ad14267dc7c3eabb80f858fd22865caabfcf6a Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:35:39 -0600 Subject: [PATCH 04/12] PHPStan fixes. --- src/Listener.php | 9 +++- src/ListenerProxy.php | 2 +- src/ProviderCollector.php | 51 +++++++++++++++----- tests/OrderedListenerProviderServiceTest.php | 2 +- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/Listener.php b/src/Listener.php index e08cb38..36ae5c3 100644 --- a/src/Listener.php +++ b/src/Listener.php @@ -59,7 +59,7 @@ public function __construct( public ?string $type = null, ) { if ($id || $this->type) { - $this->hasDefinition ??= true; + $this->hasDefinition = true; } } @@ -68,7 +68,9 @@ public function fromReflection(\ReflectionMethod $subject): void $this->paramCount = $subject->getNumberOfRequiredParameters(); if ($this->paramCount === 1) { $params = $subject->getParameters(); - $this->type ??= $params[0]?->getType()?->getName(); + // getName() isn't part of the interface, but is present. PHP bug. + // @phpstan-ignore-next-line + $this->type ??= $params[0]->getType()?->getName(); } } @@ -92,6 +94,9 @@ public function methodAttribute(): string return __CLASS__; } + /** + * @param array $methods + */ public function setStaticMethods(array $methods): void { $this->staticMethods = $methods; diff --git a/src/ListenerProxy.php b/src/ListenerProxy.php index 8541aea..24e9091 100644 --- a/src/ListenerProxy.php +++ b/src/ListenerProxy.php @@ -123,7 +123,7 @@ protected function getServiceMethodType(string $methodName): string { try { // We don't have a real object here, so we cannot use first-class-closures. - // PHPStan complains that an aray is not a callable, even though it is, because PHP. + // PHPStan complains that an array is not a callable, even though it is, because PHP. // @phpstan-ignore-next-line $type = $this->getParameterType([$this->serviceClass, $methodName]); } catch (\InvalidArgumentException $exception) { diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index 288dadd..1fbbd7c 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -147,24 +147,26 @@ protected function addSubscribersByProxy(string $class, string $service): Listen } /** - * @param callable|array{0: string, 1: string} $listener + * @param callable|array{0: class-string, 1: string}|array{0: object, 1: string} $listener */ protected function getAttributeDefinition(callable|array $listener): Listener { if ($this->isFunctionCallable($listener) || $this->isClosureCallable($listener)) { + /** @var \Closure|string $listener */ return $this->funcAnalyzer->analyze($listener, Listener::class); } if ($this->isObjectCallable($listener)) { - /** @var array $listener */ + /** @var array{0: object, 1: string} $listener */ [$object, $method] = $listener; $def = $this->classAnalyzer->analyze($object::class, Listener::class); return $def->methods[$method]; } + /** @var array{0: class-string, 1: string} $listener */ if ($this->isClassCallable($listener)) { - /** @var array $listener */ + /** @var array{0: class-string, 1: string} $listener */ [$class, $method] = $listener; $def = $this->classAnalyzer->analyze($class, Listener::class); @@ -241,7 +243,7 @@ protected function getType(callable $listener): string * generate a random ID if necessary. It will also handle duplicates * for us. This method is just a suggestion. * - * @param callable|array{0: string, 1: string} $listener + * @param callable|array{0: class-string, 1: string}|array{0: object, 1: string} $listener * The listener for which to derive an ID. * * @return string|null @@ -251,17 +253,19 @@ protected function getListenerId(callable|array $listener): ?string { if ($this->isFunctionCallable($listener)) { // Function callables are strings, so use that directly. - // @phpstan-ignore-next-line - return (string)$listener; + /** @var string $listener */ + return $listener; } - // @phpstan-ignore-next-line + + if ($this->isObjectCallable($listener)) { + /** @var array{0: object, 1: string} $listener */ + return get_class($listener[0]) . '::' . $listener[1]; + } + if ($this->isClassCallable($listener)) { /** @var array{0: class-string, 1: string} $listener */ return $listener[0] . '::' . $listener[1]; } - if (is_array($listener) && is_object($listener[0])) { - return get_class($listener[0]) . '::' . $listener[1]; - } // Anything else we can't derive an ID for logically. return null; @@ -272,7 +276,7 @@ protected function getListenerId(callable|array $listener): ?string * * Or at least a reasonable approximation, since a function name may not be defined yet. * - * @param callable|array{0: string, 1: string} $callable + * @param callable|array $callable * @return bool * True if the callable represents a function, false otherwise. */ @@ -285,7 +289,7 @@ protected function isFunctionCallable(callable|array $callable): bool /** * Determines if a callable represents a method on an object. * - * @param callable|array{0: string, 1: string} $callable + * @param callable|array $callable * @return bool * True if the callable represents a method object, false otherwise. */ @@ -297,7 +301,7 @@ protected function isObjectCallable(callable|array $callable): bool /** * Determines if a callable represents a closure/anonymous function. * - * @param callable|array{0: string, 1: string} $callable + * @param callable|array $callable * @return bool * True if the callable represents a closure object, false otherwise. */ @@ -306,5 +310,26 @@ protected function isClosureCallable(callable|array $callable): bool return $callable instanceof \Closure; } + /** + * Determines if a callable represents a static class method. + * + * The parameter here is untyped so that this method may be called with an + * array that represents a class name and a non-static method. The routine + * to determine the parameter type is identical to a static method, but such + * an array is still not technically callable. Omitting the parameter type here + * allows us to use this method to handle both cases. + * + * This method must therefore be called first above, as the array is not actually + * an `is_callable()` and will fail `Closure::fromCallable()`. Because PHP. + * + * @param callable|array $callable + * @return bool + * True if the callable represents a static method, false otherwise. + */ + protected function isClassCallable($callable): bool + { + return is_array($callable) && is_string($callable[0]) && class_exists($callable[0]); + } + abstract protected function getListenerEntry(callable $listener, string $type): ListenerEntry; } diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index c01cd6d..f5e3c4d 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -287,7 +287,7 @@ public function rejects_missing_auto_detected_service(): void $provider = new OrderedListenerProvider($container); - /** @phpstan-ignore-next-line */ + // @phpstan-ignore-next-line $provider->listenerService(DoesNotExist::class); } From 211c22744475518681d586d4ffd43da16f209c4a Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:53:23 -0600 Subject: [PATCH 05/12] Skip routing everything through listener() to avoid double-deriving everything. --- src/OrderedListenerProvider.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/OrderedListenerProvider.php b/src/OrderedListenerProvider.php index d1bedc1..830c38f 100644 --- a/src/OrderedListenerProvider.php +++ b/src/OrderedListenerProvider.php @@ -68,11 +68,23 @@ public function listenerService( $def->before = $before; $def->after = $after; } - return $this->listener($this->makeListenerForService($service, $method), priority: $def->priority, before: $def->before, after: $def->after, id: $id, type: $type); + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $def->priority, + before: $def->before, + after: $def->after + ); } $id ??= $service . '-' . $method; - return $this->listener($this->makeListenerForService($service, $method), priority: $priority, before: $before, after: $after, id: $id, type: $type); + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $priority, + before: $before, + after: $after, + ); } /** From ff1110a242d5e6350664bedd309768ff9375f1ff Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 18:55:47 -0600 Subject: [PATCH 06/12] Remove dead code. --- src/ProviderCollector.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index 1fbbd7c..dc525fd 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -176,20 +176,6 @@ protected function getAttributeDefinition(callable|array $listener): Listener return new Listener(); } - /** - * @param class-string $attribute - * @param \Reflector $ref - * @return array - */ - protected function getAttributes(string $attribute, \Reflector $ref): array - { - // The Reflector interface doesn't have getAttributes() defined, but - // it's always there. PHP bug. - // @phpstan-ignore-next-line - $attribs = $ref->getAttributes($attribute, \ReflectionAttribute::IS_INSTANCEOF); - return array_map(fn(\ReflectionAttribute $attrib) => $attrib->newInstance(), $attribs); - } - protected function deriveMethod(string $service): string { if (!class_exists($service)) { From 23946aa7b2360534e1d839b7f2a622df1e848edc Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 19:00:56 -0600 Subject: [PATCH 07/12] Be consistent in ID generation. --- src/OrderedListenerProvider.php | 2 +- tests/OrderedListenerProviderIdTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/OrderedListenerProvider.php b/src/OrderedListenerProvider.php index 830c38f..35fd629 100644 --- a/src/OrderedListenerProvider.php +++ b/src/OrderedListenerProvider.php @@ -77,7 +77,7 @@ public function listenerService( ); } - $id ??= $service . '-' . $method; + $id ??= $service . '::' . $method; return $this->listeners->add( item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), id: $id, diff --git a/tests/OrderedListenerProviderIdTest.php b/tests/OrderedListenerProviderIdTest.php index 3a39500..d5d772f 100644 --- a/tests/OrderedListenerProviderIdTest.php +++ b/tests/OrderedListenerProviderIdTest.php @@ -137,14 +137,14 @@ public function listen(CollectingEvent $event): void $p = new OrderedListenerProvider($container); $idA = $p->addListenerService('A', 'listen', CollectingEvent::class, -4); - $p->addListenerServiceAfter('A-listen', 'B', 'listen', CollectingEvent::class); + $p->addListenerServiceAfter('A::listen', 'B', 'listen', CollectingEvent::class); $event = new CollectingEvent(); foreach ($p->getListenersForEvent($event) as $listener) { $listener($event); } - self::assertEquals('A-listen', $idA); + self::assertEquals('A::listen', $idA); self::assertEquals('AB', implode($event->result())); } From cb4ec6f351f7782906e70e7679bbc80a1968e9fa Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 19:05:24 -0600 Subject: [PATCH 08/12] Minor refactor for performance and cleanliness. --- src/OrderedListenerProvider.php | 11 +++-------- src/ProviderBuilder.php | 12 +++--------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/OrderedListenerProvider.php b/src/OrderedListenerProvider.php index 35fd629..722f42c 100644 --- a/src/OrderedListenerProvider.php +++ b/src/OrderedListenerProvider.php @@ -52,22 +52,17 @@ public function listenerService( $type = $this->getParameterType([$service, $method]); } + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + // In the special case that the service is the class name, we can // leverage attributes. - if (class_exists($service)) { + if (!$orderSpecified && class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ $def = $this->classAnalyzer->analyze($service, Listener::class); $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; - } return $this->listeners->add( item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), id: $id, diff --git a/src/ProviderBuilder.php b/src/ProviderBuilder.php index 19e5704..2e03110 100644 --- a/src/ProviderBuilder.php +++ b/src/ProviderBuilder.php @@ -53,23 +53,17 @@ public function listenerService( $type = $this->getParameterType([$service, $method]); } + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + // In the special case that the service is the class name, we can // leverage attributes. - if (class_exists($service)) { + if (!$orderSpecified && class_exists($service)) { $listener = [$service, $method]; /** @var Listener $def */ $def = $this->classAnalyzer->analyze($service, Listener::class); $def = $def->methods[$method]; $id ??= $def?->id ?? $this->getListenerId($listener); - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; - } - $entry = new ListenerServiceEntry($service, $method, $type); return $this->listeners->add($entry, $id, priority: $def->priority, before: $def->before, after: $def->after); } From 56b5674701469594598c9c6b2f9584fd61a40f6d Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 19:11:05 -0600 Subject: [PATCH 09/12] Refactor to avoid parsing for an attribute if we can avoid it. --- src/ProviderCollector.php | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index dc525fd..4211d52 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -37,17 +37,21 @@ public function listener( ?string $id = null, ?string $type = null ): string { - /** @var Listener $def */ - $def = $this->getAttributeDefinition($listener); - $id ??= $def?->id ?? $this->getListenerId($listener); - $type ??= $def?->type ?? $this->getType($listener); - - // If any ordering is specified explicitly, that completely overrules any - // attributes. - if (!is_null($priority) || $before || $after) { - $def->priority = $priority; - $def->before = $before; - $def->after = $after; + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + + if (!$orderSpecified || !$type || !$id) { + /** @var Listener $def */ + $def = $this->getAttributeDefinition($listener); + $id ??= $def?->id ?? $this->getListenerId($listener); + $type ??= $def?->type ?? $this->getType($listener); + + // If any ordering is specified explicitly, that completely overrules any + // attributes. + if (!$orderSpecified) { + $priority = $def->priority; + $before = $def->before; + $after = $def->after; + } } $entry = $this->getListenerEntry($listener, $type); @@ -55,9 +59,9 @@ public function listener( return $this->listeners->add( item: $entry, id: $id, - priority: $def->priority, - before: $def->before, - after: $def->after + priority: $priority, + before: $before, + after: $after ); } From 377f51d8f5560e7dc97cb75a8cdb1b3324c64a2a Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Tue, 9 Jan 2024 19:23:31 -0600 Subject: [PATCH 10/12] Add tests to confirm class-level attributes get propagated down to methods. --- tests/CompiledListenerProviderTest.php | 2 -- .../InvokableListenerClassAttribute.php | 15 ++++++++++++ tests/OrderedListenerProviderServiceTest.php | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/Listeners/InvokableListenerClassAttribute.php diff --git a/tests/CompiledListenerProviderTest.php b/tests/CompiledListenerProviderTest.php index 96a875b..3b2bf78 100644 --- a/tests/CompiledListenerProviderTest.php +++ b/tests/CompiledListenerProviderTest.php @@ -245,8 +245,6 @@ public function optimize_event_anonymous_class(): void self::assertEquals('BACD', implode($event->result())); } - - #[Test, DataProvider('detection_class_examples')] public function detects_invoke_method_and_type(string $class): void { diff --git a/tests/Listeners/InvokableListenerClassAttribute.php b/tests/Listeners/InvokableListenerClassAttribute.php new file mode 100644 index 0000000..1d952a8 --- /dev/null +++ b/tests/Listeners/InvokableListenerClassAttribute.php @@ -0,0 +1,15 @@ +add(static::class); + } +} diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index f5e3c4d..1e0287c 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -6,6 +6,7 @@ use Crell\Tukio\Events\CollectingEvent; use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\InvokableListenerClassAttribute; use Crell\Tukio\Listeners\MockMalformedSubscriber; use Crell\Tukio\Listeners\MockSubscriber; use PHPUnit\Framework\Attributes\DataProvider; @@ -266,6 +267,29 @@ public static function detection_class_examples(): iterable ]; } + #[Test] + public function detects_invoke_method_and_type_with_class_attribute(): void + { + $container = new MockContainer(); + + $container->addService(InvokableListenerClassAttribute::class, new InvokableListenerClassAttribute()); + + $provider = new OrderedListenerProvider($container); + + $provider->listenerService(InvokableListenerClassAttribute::class); + $provider->listener(fn(CollectingEvent $event) => $event->add('A'), priority: 10); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $results = $event->result(); + self::assertEquals('A', $results[0]); + self::assertEquals(InvokableListenerClassAttribute::class, $results[1]); + } + #[Test] public function rejects_multi_method_class_without_invoke(): void { From 2c5827b78b5388e2a999a6a31558026c0181635b Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Sun, 18 Feb 2024 11:59:22 -0600 Subject: [PATCH 11/12] Use a cached function analyzer by default. --- src/ProviderCollector.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php index 4211d52..561892b 100644 --- a/src/ProviderCollector.php +++ b/src/ProviderCollector.php @@ -9,6 +9,7 @@ use Crell\AttributeUtils\FuncAnalyzer; use Crell\AttributeUtils\FunctionAnalyzer; use Crell\AttributeUtils\MemoryCacheAnalyzer; +use Crell\AttributeUtils\MemoryCacheFunctionAnalyzer; use Crell\OrderedCollection\MultiOrderedCollection; use Crell\Tukio\Entry\ListenerEntry; use Fig\EventDispatcher\ParameterDeriverTrait; @@ -23,7 +24,7 @@ abstract class ProviderCollector implements OrderedProviderInterface protected MultiOrderedCollection $listeners; public function __construct( - protected readonly FunctionAnalyzer $funcAnalyzer = new FuncAnalyzer(), + protected readonly FunctionAnalyzer $funcAnalyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer()), protected readonly ClassAnalyzer $classAnalyzer = new MemoryCacheAnalyzer(new Analyzer()), ) { $this->listeners = new MultiOrderedCollection(); From 2b14d4985ffffac3aa6071be8bb610d98d2501e2 Mon Sep 17 00:00:00 2001 From: Larry Garfield Date: Sat, 24 Feb 2024 10:42:31 -0600 Subject: [PATCH 12/12] Use stable release of AttributeUtils. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c9cbd22..76aedb5 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ ], "require": { "php": "~8.1", - "crell/attributeutils": "dev-func-analyzer", + "crell/attributeutils": "^1.1", "crell/ordered-collection": "v2.x-dev", "fig/event-dispatcher-util": "^1.3", "psr/container": "^1.0 || ^2.0",