diff --git a/.github/workflows/phpstan.yaml b/.github/workflows/phpstan.yaml deleted file mode 100644 index f58a26b..0000000 --- a/.github/workflows/phpstan.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: PHPStan checks -on: - push: ~ - pull_request: ~ - -jobs: - phpstan: - name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} - runs-on: ubuntu-latest - strategy: - matrix: - php: [ '7.4', '8.0', '8.1', '8.2' ] - composer-flags: [ '' ] - steps: - - uses: actions/checkout@v2 - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: pcov - tools: composer:v2 - - run: composer update --no-progress ${{ matrix.composer-flags }} - - run: vendor/bin/phpstan diff --git a/.github/workflows/quality-assurance.yaml b/.github/workflows/quality-assurance.yaml new file mode 100644 index 0000000..dd958f1 --- /dev/null +++ b/.github/workflows/quality-assurance.yaml @@ -0,0 +1,41 @@ +--- +name: Quality assurance +on: + push: + branches: ['master'] + pull_request: ~ + +jobs: + phpunit: + name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '8.1', '8.2', '8.3' ] + composer-flags: [ '' ] + phpunit-flags: [ '--coverage-text' ] + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + tools: composer:v2 + - run: composer install --no-progress ${{ matrix.composer-flags }} + - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} + phpstan: + name: PHPStan checks on ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + matrix: + php: [ '8.1', '8.2', '8.3' ] + composer-flags: [ '' ] + steps: + - uses: actions/checkout@v2 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + tools: composer:v2 + - run: composer install --no-progress ${{ matrix.composer-flags }} + - run: vendor/bin/phpstan diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml deleted file mode 100644 index cdbfa33..0000000 --- a/.github/workflows/testing.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: PHPUnit tests -on: - push: ~ - pull_request: ~ - -jobs: - phpunit: - name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} - runs-on: ubuntu-latest - strategy: - matrix: - php: [ '7.4', '8.0', '8.1', '8.2' ] - composer-flags: [ '' ] - phpunit-flags: [ '--coverage-text' ] - steps: - - uses: actions/checkout@v2 - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: pcov - tools: composer:v2 - - run: composer update --no-progress ${{ matrix.composer-flags }} - - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} diff --git a/.gitignore b/.gitignore index 16ab93b..0a4b7e6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ build composer.lock vendor .phpunit.result.cache +.phpunit.cache /vendor-bin/**/vendor .env +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e69172..16a408e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `Tukio` will be documented in this file. Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. +## 2.0.0 - YYYY-MM-DD + +### Added +- Major internal refactoring. +- There is now a `listener()` method on the Provider and Compiler classes that allows specifying multiple before/after rules at once, in addition to priority. It is *recommended* to use this method in place of the older ones. +- Similarly, there is a `listenerService()` method for registering any service-based listener. +- Upgraded to OrderedCollection v2, and switched to a Topological-based sort. The main advantage is the ability to support multiple before/after rules. However, this has a side effect that the order of listeners that had no relative order specified may have changed. This is not an API break as that order was never guaranteed, but may still affect some order-sensitive code that worked by accident. If that happens, and you care about the order, specify before/after orders as appropriate. +- Attributes are now the recommended way to register listeners. +- Attributes may be placed on the class level, and will be inherited by method-level listeners. + +### Deprecated +- `SubscriberInterface` is now deprecated. It will be removed in v3. +- the `addListener`, `addListenerBefore`, `addListenerAfter`, `addListenerService`, `addListenerServiceBefore`, and `addListenerServiceAfter` methods have been deprecated. They will be removed in v3. Use `listener()` and `listenerService()` instead. + +### Fixed +- Nothing + +### Removed +- Nothing + +### Security +- Nothing + + ## 1.5.0 - 2023-03-25 ### Added diff --git a/README.md b/README.md index 61437e8..260d962 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Which Provider or Providers you use depends on your use case. All of them are v As the name implies, `OrderedListenerProvider` is all about ordering. Users can explicitly register Listeners on it that will be matched against an Event based on its type. -If no order is specified, then the order that Listeners will be returned is undefined (although in practice order-added is what it will most likely be). That makes the degenerate case super-easy: +If no order is specified, then the order that Listeners will be returned is undefined, and in practice users should expect the order to be stable, but not predictable. That makes the degenerate case super-easy: ```php use Crell\Tukio\OrderedListenerProvider; @@ -92,34 +92,34 @@ function handleStuff(StuffHappened $stuff) { ... } $provider = new OrderedListenerProvider(); -$provider->addListener(function(SpecificStuffHappened) { +$provider->listener(function(SpecificStuffHappened) { // ... }); -$provider->addListener('handleStuff'); +$provider->listener('handleStuff'); ``` That adds two Listeners to the Provider; one anonymous function and one named function. The anonymous function will be called for any `SpecificStuffHappened` event. The named function will be called for any `StuffHappened` *or* `SpecificStuffHappened` event. And the user doesn't really care which one happens first (which is the typical case). #### Ordering listeners -However, the user can also be picky about the order in which Listeners will fire. The most common approach for that today is via an integral "priority", and Tukio offers that: +However, the user can also be picky about the order in which Listeners will fire. Tukio supports two ordering mechanisms: Priority order, and Topological sorting (before/after markers). Internally, Tukio will convert priority ordering into topological ordering. ```php use Crell\Tukio\OrderedListenerProvider; $provider = new OrderedListenerProvider(); -$provider->addListener(function(SpecificStuffHappened) { +$provider->listener(function(SpecificStuffHappened) { // ... -}, 10); +}, priority: 10); -$provider->addListener('handleStuff', 20); +$provider->listener('handleStuff', priority: 20); ``` Now, the named function Listener will get called before the anonymous function does. (Higher priority number comes first, and negative numbers are totally legal.) If two listeners have the same priority then their order relative to each other is undefined. -Sometimes, though, you may not know the priority of another Listener, but it's important your Listener happen before or after it. For that we need to add a new concept: IDs. Every Listener has an ID, which can be provided when the Listener is added or will be auto-generated if not. The auto-generated value is predictable (the name of a function, the class-and-method of an object method, etc.), so in most cases it's not necessary to read the return value of `addListener()` although that is slightly more robust. +Sometimes, though, you may not know the priority of another Listener, but it's important your Listener happen before or after it. For that we need to add a new concept: IDs. Every Listener has an ID, which can be provided when the Listener is added or will be auto-generated if not. The auto-generated value is predictable (the name of a function, the class-and-method of an object method, etc.), so in most cases it's not necessary to read the return value of `listener()` although that is slightly more robust. ```php use Crell\Tukio\OrderedListenerProvider; @@ -128,92 +128,257 @@ $provider = new OrderedListenerProvider(); // The ID will be "handleStuff", unless there is such an ID already, //in which case it would be "handleStuff-1" or similar. -$id = $provider->addListener('handleStuff'); +$id = $provider->listener('handleStuff'); // This Listener will get called before handleStuff does. If you want to specify an ID // you can, since anonymous functions just get a random string as their generated ID. -$provider->addListenerBefore($id, function(SpecificStuffHappened) { +$provider->listener($id, function(SpecificStuffHappened) { // ... -}, 'my_specifics'); +}, before: ['my_specifics']); ``` Here, the priority of `handleStuff` is undefined; the user doesn't care when it gets called. However, the anonymous function, if it should get called at all, will always get called after `handleStuff` does. It's possible that some other Listener could also be called in between the two, but one will always happen after the other. -The full API for those three methods is: +The `listener()` method is used for all registration, and can accept a priority, a list of listener IDs the new listener must come before, and a list of listener IDs the new listener must come after. It also supports specifying a custom ID, and a custom `$type`. -```php -public function addListener(callable $listener, $priority = 0, string $id = null, string $type = null): string; - -public function addListenerBefore(string $before, callable $listener, string $id = null, string $type = null): string; +Because that's a not-small number of options, it is *strongly recommended* that you use named arguments for all arguments other than the listener callable itself. -public function addListenerAfter(string $after, callable $listener, string $id = null, string $type = null): string; +```php +public function listener( + callable $listener, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null, + ?string $type = null +): string; ``` -All three can register any callable as a Listener and return an ID. If desired the `$type` parameter allows a user to specify the Event type that the Listener is for if different than the type declaration in the function. For example, if the Listener doesn't have a type declaration or should only apply to some parent class of what it's type declaration is. (That's a rare edge case, which is why it's the last parameter.) +The `listener()` method will always return the ID that was used for that listener. If desired the `$type` parameter allows a user to specify the Event type that the Listener is for if different than the type declaration in the function. For example, if the Listener doesn't have a type declaration or should only apply to some parent class of what it's type declaration is. (That's a rare edge case, which is why it's the last parameter.) #### Service Listeners -Often, though, Listeners are themselves methods of objects that should not be instantiated until and unless needed. That's exactly what a Dependency Injection Container allows, and `OrderedListenerProvider` fully supports those, called "Service Listeners". They work almost exactly the same, except you specify a service and method name: +Often, though, Listeners are themselves methods of objects that should not be instantiated until and unless needed. That's exactly what a Dependency Injection Container allows, and `OrderedListenerProvider` fully supports those, called "Service Listeners." They work almost exactly the same, except you specify a service and method name: ```php -public function addListenerService(string $serviceName, string $methodName, string $type, $priority = 0, string $id = null): string; +public function listenerService( + string $service, + ?string $method = null, + ?string $type = null, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null +): string; +``` -public function addListenerServiceBefore(string $before, string $serviceName, string $methodName, string $type, string $id = null): string; +The `$type`, `$priority`, `$before`, `$after`, and `$id` parameters work the same way as for `listener()`. `$service` is any service name that will be retrieved from a container on-demand, and `$method` is the method on the object. -public function addListenerServiceAfter(string $after, string $serviceName, string $methodName, string $type, string $id = null) : string; -``` +If the service name is the same as that of a found class (which is typical in most modern conventions), then Tukio can attempt to derive the method and type from the class. If the service name is not the same as a defined class, it cannot do so and both `$method` and `$type` are required and will throw an exception if missing. -All three take a service name and method name to identify a Listener. However, since the callable itself doesn't exist yet it's not possible to derive the Event it should listen to through reflection. It must be specified explicitly. All three also return an ID, so the same "priority-or-before/after" functionality is supported. +If no `$method` is specified and the service name matches a class, Tukio will attempt to derive the method for you. If the class has only one method, that method will be automatically selected. Otherwise, if there is a `__invoke()` method, that will be automatically selected. Otherwise, the auto-detection fails and an exception is thrown. The services themselves can be from any [PSR-11](http://www.php-fig.org/psr/psr-11/)-compatible Container. ```php use Crell\Tukio\OrderedListenerProvider; +class SomeService +{ + public function methodA(ThingHappened $event): void { ... } + + public function methodB(SpecificThingHappened $event): void { ... } +} + +class MyListeners +{ + public function methodC(WhatHappened $event): void { ... } + + public function somethingElse(string $beep): string { ... } +} + +class EasyListening +{ + public function __invoke(SpecificThingHappened $event): void { ... } +} + $container = new SomePsr11Container(); // Configure the container somehow. +$container->register('some_service', SomeService::class); +$container->register(MyListeners::class, MyListeners::class); +$container->register(EasyListening::class, EasyListening::class); $provider = new OrderedListenerProvider($container); -$provider->addListenerService('some_service', 'methodA', ThingHappened::class); +// Manually register two methods on the same service. +$idA = $provider->listenerService('some_service', 'methodA', ThingHappened::class); +$idB = $provider->listenerService('some_service', 'methodB', SpecificThingHappened::class); -$id = $provider->addListenerServiceBefore('some_service-methodA', 'some_service', 'methodB', SpecificThingHappened::class); +// Register a specific method on a derivable service class. +// The type (WhatHappened) will be derived automatically. +$idC = $provider->listenerService(MyListeners::class, 'methodC', after: 'some_service-methodB'); -$provider->addListenerServiceAfter($id, 'some_other_service', 'methodC', SpecificThingHappened::class); +// Auto-everything! This is the easiest option. +$provider->listenerService(EasyListening::class, before: $idC); ``` -In this example, we assume that `$container` has two services defined: `some_service` and `some_other_service`. (Creative, I know.) We then register three Listeners: Two of them are methods on `some_service`, the other on `some_other_service`. Both services will be requested from the container as needed, so won't be instantiated until the Listener method is about to be called. +In this example, we have listener methods defined in three different classes, all of which are registered with a PSR-11 container. In the first code block, we register two listeners out of a class whose service name does not match its class name. In the second, we register a method on a class whose service name does match its class name, so we can derive the event type by reflection. In the third block, we use a single-method listener class, which allows everything to be derived! Of note, the `methodB` Listener is referencing the `methodA` listener by an explict ID. The generated ID is as noted predictable, so in most cases you don't need to use the return value. The return value is the more robust and reliable option, though, as if the requested ID is already in-use a new one will be generated. -#### Subscribers +#### Attribute-based registration -As in the last example, it's quite common to have multiple Listeners in a single service. In fact, it's common to have a service that is nothing but listeners. Tukio calls those "Subscribers" (a name openly and unashamedly borrowed from Symfony), and has specific support for them. +The preferred way to configure Tukio, however, is via attributes. There are four relevant attributes: `Listener`, `ListenerPriority`, `ListenerBefore`, and `ListenerAfter`. All can be used with sequential parameters or named parameters. In most cases, named parameters will be more self-documenting. All attributes are valid only on functions and methods. -The basic case works like this: +* `Listener` declares a callable a listener and optionally sets the `id` and `type`: `#[Listener(id: 'a_listener', type: 'SomeClass')]. +* `ListenerPriority` has a required `priority` parameter, and optional `id` and `type: `#[ListenerPriority(5)]` or `#[ListenerPriority(priority: 3, id: "a_listener")]`. +* `ListenerBefore` has a required `before` parameter, and optional `id` and `type: `#[ListenerBefore('other_listener')]` or `#[ListenerBefore(before: 'other_listener', id: "a_listener")]`. +* `ListenerAfter` has a required `after` parameter, and optional `id` and `type: `#[ListenerAfter('other_listener')]` or `#[ListenerAfter(after: ['other_listener'], id: "a_listener")]`. + +The `$before` and `$after` parameters will accept either a single string, or an array of strings. + +As multiple attributes may be included in a single block, that allows for compact syntax like so: ```php -use Crell\Tukio\OrderedListenerProvider; +#[Listener(id: 'a_listener'), ListenerBefore('other'), ListenerAfter('something', 'else')] +function my_listener(SomeEvent $event): void { ... } + +// Or just use the one before/after you care about: +#[ListenerAfter('something_early')] +function other(SomeEvent $event): void { ... } +``` + +If you pass a listener with Listener attributes to `listener()` or `listenerService()`, the attribute defined configuration will be used. If you pass configuration in the method signature, however, that will override any values taken from the attributes. + +### Subscribers -class Subscriber +A "Subscriber" (a name openly and unashamedly borrowed from Symfony) is a class with multiple listener methods on it. Tukio allows you to bulk-register any listener-like methods on a class, just by registering the class. + +```php +$provider->addSubscriber(SomeCollectionOfListeners::class, 'service_name'); +``` + +As before, if the service name is the same as that of the class, it may be omitted. A method will be registered if either: + +* it has any `Listener*` attributes on it. +* the method name begins with `on`. + +For example: + +```php +class SomeCollectionOfListeners { - public function onThingsHappening(ThingHappened $event) : void { ... } + // Registers, with a custom ID. + #[Listener(id: 'a')] + public function onA(CollectingEvent $event) : void + { + $event->add('A'); + } - public function onSpecialEvent(SpecificThingHappened $event) : void { ... } + // Registers, with a custom priority. + #[ListenerPriority(priority: 5)] + public function onB(CollectingEvent $event) : void + { + $event->add('B'); + } + + // Registers, before listener "a" above. + #[ListenerBefore(before: 'a')] + public function onC(CollectingEvent $event) : void + { + $event->add('C'); + } + + // Registers, after listener "a" above. + #[ListenerAfter(after: 'a')] + public function onD(CollectingEvent $event) : void + { + $event->add('D'); + } + + // This still self-registers because of the name. + public function onE(CollectingEvent $event) : void + { + $event->add('E'); + } + + // Registers, with a given priority despite its non-standard name. + #[ListenerPriority(priority: -5)] + public function notNormalName(CollectingEvent $event) : void + { + $event->add('F'); + } + + // No attribute, non-standard name, this method is not registered. + public function ignoredMethodThatDoesNothing() : void + { + throw new \Exception('What are you doing here?'); + } } +``` -$container = new SomePsr11Container(); -// Configure the container so that the service 'listeners' is an instance of Subscriber above. +### Listener classes -$provider = new OrderedListenerProvider($container); +As hinted above, one of the easiest ways to structure a listener is to make it the only method on a class, particularly if it is named `__invoke()`, and give the service the same name as the class. That way, it can be registered trivially and derive all of its configuration through attributes. Since it is extremely rare for a listener to be registered twice (use cases likely exist, but we are not aware of one), this does not cause a name collision issue. +Tukio has two additional features to make it even easier. One, if the listener method is `__invoke()`, then the ID of the listener will by default be just the class name. Two, the Listener attributes may also be placed on the class, not the method, in which case the class-level settings will inherit to every method. -$provider->addSubscriber(Subscriber::class, 'listeners'); +The result is that the easiest way to define listeners is as single-method classes, like so: + +```php +class ListenerOne +{ + public function __construct( + private readonly DependencyA $depA, + private readonly DependencyB $depB, + ) {} + + public function __invoke(MyEvent $event): void { ... } +} + +#[ListenerBefore(ListenerOne::class)] +class ListenerTwo +{ + public function __invoke(MyEvent $event): void { ... } +} + +$provider->listenerService(ListenerOne::class); +$provider->listenerService(ListenerTwo::class); ``` -That's it! Because we don't know what the class of the service is it will need to be specified explicitly, but the rest is automatic. Any public method whose name begins with "on" will be registered as a listener, and the Event type it is for will be derived from reflection, while the rest of the class is ignored. There is no limit to how many listeners can be added this way. The method names can be anything that makes sense in context, so make it descriptive. And because the service is pulled on-demand from the container it will only be instantiated once, and not before it's needed. That's the ideal. +Now, the API call itself is trivially easy. Just specify the class name. `ListenerTwo::__invoke()` will be called before `ListnerOne::__invoke()`, regardless of the order in which they were registered. When `ListenerOne` is requested from your DI container, the container will fill in its dependencies automatically. + +This is the recommended way to write listeners for use with Tukio. + +### Deprecated functionality + +A few registration mechanisms left over from Tukio version 1 are still present, but explicitly deprecated. They will be removed in a future version. Please migrate off of them as soon as possible. -Sometimes, though, you want to order the Listeners in the Subscriber, or need to use a different naming convention for the Listener method. For that case, your Subscriber class can implement an extra interface that allows for manual registration: +#### Dedicated registration methods + +The following methods still work, but are just aliases around calling `listener()` or `listenerService()`. They are less capable than just using `listener()`, as `listener()` allows for specifying a priority, before, and after all at once, including multiple before/after targets. The methods below do neither. Please migrate to `listener()` and `listenerService()`. + +```php +public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string; + +public function addListenerBefore(string $before, callable $listener, ?string $id = null, ?string $type = null): string; + +public function addListenerAfter(string $after, callable $listener, ?string $id = null, ?string $type = null): string; + +public function addListenerService(string $service, string $method, string $type, ?int $priority = null, ?string $id = null): string; + +public function addListenerServiceBefore(string $before, string $service, string $method, string $type, ?string $id = null): string; + +public function addListenerServiceAfter(string $after, string $service, string $method, string $type, ?string $id = null): string; +``` + +#### Subscriber interface + +In Tukio v1, there was an optional `SubscriberInterface` to allow for customizing the registration of methods as listeners via a static method that bundled the various `addListener*()` calls up within the class. With the addition of attributes in PHP 8, however, that functionality is no longer necessary as attributes can do everything the Subscriber interface could, with less work. + +The `SubscriberInterface` is still supported, but deprecated. It will be removed in a future version. Please migrate to attributes. + +The basic case works like this: ```php use Crell\Tukio\OrderedListenerProvider; @@ -244,17 +409,13 @@ $provider->addSubscriber(Subscriber::class, 'listeners'); As before, `onThingsHappen()` will be registered automatically. However, `somethingElse()` will also be registered as a Listener with a priority of 10, and `onSpecialEvent()` will be registered to fire after it. -In practice, using Subscribers is the most robust way to register Listeners in a production system and so is the recommended approach. However, all approaches have their uses and can be used as desired. - -If you are using PHP 8.0 or later, you can use attributes to register your subscriber methods instead. That is preferred, in fact. See the section below. - ### Compiled Provider All of that registration and ordering logic is powerful, and it's surprisingly fast in practice. What's even faster, though, is not having to re-register on every request. For that, Tukio offers a compiled provider option. The compiled provider comes in three parts: `ProviderBuilder`, `ProviderCompiler`, and a generated provider class. `ProviderBuilder`, as the name implies, is an object that allows you to build up a set of Listeners that will make up a Provider. They work exactly the same as on `OrderedListenerProvider`, and in fact it exposes the same `OrderedProviderInterface`. -`ProviderCompiler` then takes a builder object and writes a new PHP class to a provided stream (presumably a file on disk) that matches the definitions in the builder. That built Provider is fixed; it cannot be modified and no new Listeners can be added to it, but all of the ordering and sorting has already been done, making it notably faster (to say nothing of skipping the registration process itself). +`ProviderCompiler` then takes a builder object and writes a new PHP class to a provided stream (presumably a file on disk) that matches the definitions in the builder. That built Provider is fixed; it cannot be modified and no new Listeners can be added to it, but all the ordering and sorting has already been done, making it notably faster (to say nothing of skipping the registration process itself). Let's see it in action: @@ -264,11 +425,11 @@ use Crell\Tukio\ProviderCompiler; $builder = new ProviderBuilder(); -$builder->addListener('listenerA', 100); -$builder->addListenerAfter('listenerA', 'listenerB'); -$builder->addListener([Listen::class, 'listen']); -$builder->addListenerService('listeners', 'listen', CollectingEvent::class); -$builder->addSubscriber('subscriber', Subscriber::class); +$builder->listener('listenerA', priority: 100); +$builder->listener('listenerB', after: 'listenerA'); +$builder->listener([Listen::class, 'listen']); +$builder->listenerService(MyListener::class); +$builder->addSubscriber('subscriberId', Subscriber::class); $compiler = new ProviderCompiler(); @@ -300,6 +461,26 @@ $provider = new Name\Space\Of\My\App\MyCompiledProvider($container); And boom! `$provider` is now a fully functional Provider you can pass to a Dispatcher. It will work just like any other, but faster. +Alternatively, the compiler can output a file with an anonymous class. In this case, a class name or namespace are irrelevant. + +```php +// Write the generated compiler out to a file. +$filename = 'MyCompiledProvider.php'; +$out = fopen($filename, 'w'); + +$compiler->compileAnonymous($builder, $out); + +fclose($out); +``` + +Because the compiled container will be instantiated by including a file, but it needs a container instance to function, it cannot be easily just `require()`ed. Instead, use the `loadAnonymous()` method on a `ProviderCompiler` instance to load it. (It does not need to be the same instance that was used to create it.) + +```php +$compiler = new ProviderCompiler(); + +$provider = $compiler->loadAnonymous($filename, $containerInstance); +``` + But what if you want to have most of your listeners pre-registered, but have some that you add conditionally at runtime? Have a look at the FIG's [`AggregateProvider`](https://github.com/php-fig/event-dispatcher-util/blob/master/src/AggregateProvider.php), and combine your compiled Provider with an instance of `OrderedListenerProvider`. ### Compiler optimization @@ -312,11 +493,11 @@ use Crell\Tukio\ProviderCompiler; $builder = new ProviderBuilder(); -$builder->addListener('listenerA', 100); -$builder->addListenerAfter('listenerA', 'listenerB'); -$builder->addListener([Listen::class, 'listen']); -$builder->addListenerService('listeners', 'listen', CollectingEvent::class); -$builder->addSubscriber('subscriber', Subscriber::class); +$builder->listener('listenerA', priority: 100); +$builder->listener('listenerB', after: 'listenerA'); +$builder->listener([Listen::class, 'listen']); +$builder->listenerService(MyListener::class); +$builder->addSubscriber('subscriberId', Subscriber::class); // Here's where you specify what events you know you will have. // Returning the listeners for these events will be near instant. @@ -329,82 +510,11 @@ $compiler = new ProviderCompiler(); $filename = 'MyCompiledProvider.php'; $out = fopen($filename, 'w'); -// Here's the magic: -$compiler->compile($builder, $out, 'MyCompiledProvider', '\\Name\\Space\\Of\\My\\App'); +$compiler->compileAnonymous($builder, $out); fclose($out); ``` -### Attribute-based registration - -If you are using PHP 8.0 or later, Tukio fully supports attributes as a means of registration for both `OrderedListenerProvider` and `ProviderBuilder`. There are four relevant attributes: `Listener`, `ListenerPriority`, `ListenerBefore`, and `ListenerAfter`. All can be used with sequential parameters or named parameters. In most cases, named parameters will be more self-documenting. All attributes are valid only on functions and methods. - -* `Listener` declares a callable a listener and optionally sets the `id` and `type`: `#[Listener(id: 'a_listener', type: `SomeClass`)]. -* `ListenerPriority` has a required `priority` parameter, and optional `id` and `type: `#[ListenerPriority(5)]` or `#[ListeenPriority(priority: 3, id: "a_listener")]`. -* `ListenerBefore` has a required `before` parameter, and optional `id` and `type: `#[ListenerBefore('other_listener')]` or `#[ListenerBefore(before: 'other_listener', id: "a_listener")]`. -* `ListenerAfter` has a required `after` parameter, and optional `id` and `type: `#[ListenerAfter('other_listener')]` or `#[ListenerAfter(after: 'other_listener', id: "a_listener")]`. - -Each attribute matches a corresponding method, and the values in the attribute will be used as if they were passed directly to the `addListener*()` method. If a value is passed directly to `addListener()` and specified in the attribute, the directly-passed value takes precedence. - -If you call `addListenerBefore()`/`addListenerAfter()`, the `before`/`after`/`priority` attribute parameter is ignored in favor of the method's standard behavior. That is, `addListenerBefore()`, if called with a function that has a `#[ListenerAfter]` attribute, will still add the listener "before" the specified other listener. - -There are two common use cases for attributes: One, using just `#[Listener]` to define a custom `id` or `type` and then calling the appropriate `add*` method as normal. The other is on Subscribers, where attributes completely eliminate the need for the `registerListeners()` method. If you're on PHP 8.0, please use attributes instead of `registerListeners()`. It's not deprecated yet, but it may end up deprecated sometime in the future. - -For example: - -```php -class SomeSubscriber -{ - // Registers, with a custom ID. - #[Listener(id: 'a')] - public function onA(CollectingEvent $event) : void - { - $event->add('A'); - } - - // Registers, with a custom priority. - #[ListenerPriority(priority: 5)] - public function onB(CollectingEvent $event) : void - { - $event->add('B'); - } - - // Registers, before listener "a" above. - #[ListenerBefore(before: 'a')] - public function onC(CollectingEvent $event) : void - { - $event->add('C'); - } - - // Registers, after listener "a" above. - #[ListenerAfter(after: 'a')] - public function onD(CollectingEvent $event) : void - { - $event->add('D'); - } - - // This still self-registers because of the name. - public function onE(CollectingEvent $event) : void - { - $event->add('E'); - } - - // Registers, with a given priority despite its non-standard name. - #[ListenerPriority(priority: -5)] - public function notNormalName(CollectingEvent $event) : void - { - $event->add('F'); - } - - // No attribute, non-standard name, this method is not registered. - public function ignoredMethodThatDoesNothing() : void - { - throw new \Exception('What are you doing here?'); - } -} -``` - - ### `CallbackProvider` The third option Tukio provides is a `CallbackProvider`, which takes an entirely different approach. In this case, the Provider works only on events that have a `CallbackEventInterface`. The use case is for Events that are carrying some other object, which itself has methods on it that should be called at certain times. Think lifecycle callbacks for a domain object, for example. @@ -477,7 +587,7 @@ Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT ## Security -If you discover any security related issues, please email larry@garfieldtech.com instead of using the issue tracker. +If you discover any security related issues, please use the [GitHub security reporting form](https://github.com/Crell/Tukio/security) rather than the issue queue. ## Credits diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ec4301b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Brand Promise + +Perfect security is not an achievable goal, but it is a goal to strive for nonetheless. To that end, we welcome responsible security reports from both users and external security researchers. + +# Scope + +If you believe you've found a security issue in software that is maintained in this repository, we encourage you to notify us. + +| Version | In scope | Source code | +| ------- | -------- |--------------------------| +| latest | ✅ | https://github.com/Crell/Tukio | + +Only the latest stable release of this library is supported. In general, bug and security fixes will not be backported unless there is a substantial imminent threat to users in not doing so. + +# How to Submit a Report + +To submit a vulnerability report, please contact us through [GitHub](https://github.com/Crell/Tukio/security). Your submission will be reviewed as soon as feasible, but as this is a volunteer project we cannot guarantee a response time. + +# Safe Harbor + +We support safe harbor for security researchers who: + +* Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our services. +* Only interact with accounts you own or with explicit permission of the account holder. If you do encounter Personally Identifiable Information (PII) contact us immediately, do not proceed with access, and immediately purge any local information. +* Provide us with a reasonable amount of time to resolve vulnerabilities prior to any disclosure to the public or a third-party. + +We will consider activities conducted consistent with this policy to constitute "authorized" conduct and will not pursue civil action or initiate a complaint to law enforcement. We will help to the extent we can if legal action is initiated by a third party against you. + +Please submit a report to us before engaging in conduct that may be inconsistent with or unaddressed by this policy. + +# Preferences + +* Please provide detailed reports with reproducible steps and a clearly defined impact. +* Include the version number of the vulnerable package in your report. +* Providing a suggested fix is welcome, but not required, and we may choose to implement our own, based on your submitted fix or not. +* This is a volunteer project. We will make every effort to respond to security reports in a timely manner, but that may be a week or two on the first contact. + diff --git a/benchmarks/CompiledProviderBench.php b/benchmarks/CompiledProviderBench.php index 96682d9..ce4f0be 100644 --- a/benchmarks/CompiledProviderBench.php +++ b/benchmarks/CompiledProviderBench.php @@ -4,19 +4,13 @@ namespace Crell\Tukio\Benchmarks; -use Crell\Tukio\CollectingEvent; -use Crell\Tukio\DummyEvent; -use Crell\Tukio\MockContainer; +use Crell\Tukio\Events\DummyEvent; +use Crell\Tukio\Fakes\MockContainer; use Crell\Tukio\ProviderBuilder; use Crell\Tukio\ProviderCompiler; use PhpBench\Benchmark\Metadata\Annotations\AfterClassMethods; use PhpBench\Benchmark\Metadata\Annotations\BeforeClassMethods; use PhpBench\Benchmark\Metadata\Annotations\Groups; -use PhpBench\Benchmark\Metadata\Annotations\Iterations; -use PhpBench\Benchmark\Metadata\Annotations\OutputTimeUnit; -use PhpBench\Benchmark\Metadata\Annotations\RetryThreshold; -use PhpBench\Benchmark\Metadata\Annotations\Revs; -use PhpBench\Benchmark\Metadata\Annotations\Warmup; /** * @Groups({"Providers"}) diff --git a/benchmarks/OptimizedCompiledProviderBench.php b/benchmarks/OptimizedCompiledProviderBench.php index 5399149..68856ef 100644 --- a/benchmarks/OptimizedCompiledProviderBench.php +++ b/benchmarks/OptimizedCompiledProviderBench.php @@ -4,8 +4,8 @@ namespace Crell\Tukio\Benchmarks; -use Crell\Tukio\CollectingEvent; -use Crell\Tukio\DummyEvent; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Events\DummyEvent; use PhpBench\Benchmark\Metadata\Annotations\Groups; /** diff --git a/benchmarks/OrderedProviderBench.php b/benchmarks/OrderedProviderBench.php index 64b33d7..c3b6b5d 100644 --- a/benchmarks/OrderedProviderBench.php +++ b/benchmarks/OrderedProviderBench.php @@ -4,10 +4,8 @@ namespace Crell\Tukio\Benchmarks; -use Crell\Tukio\CollectingEvent; use Crell\Tukio\OrderedListenerProvider; use PhpBench\Benchmark\Metadata\Annotations\Groups; -use Psr\EventDispatcher\ListenerProviderInterface; /** * @Groups({"Providers"}) diff --git a/benchmarks/ProviderBenchBase.php b/benchmarks/ProviderBenchBase.php index 40db072..4280f5c 100644 --- a/benchmarks/ProviderBenchBase.php +++ b/benchmarks/ProviderBenchBase.php @@ -4,7 +4,7 @@ namespace Crell\Tukio\Benchmarks; -use Crell\Tukio\CollectingEvent; +use Crell\Tukio\Events\CollectingEvent; use PhpBench\Benchmark\Metadata\Annotations\Groups; use PhpBench\Benchmark\Metadata\Annotations\Iterations; use PhpBench\Benchmark\Metadata\Annotations\OutputTimeUnit; diff --git a/composer.json b/composer.json index 8cc3610..3717dac 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,9 @@ } ], "require": { - "php": "~7.4 || ~8.0", - "crell/ordered-collection": "^1.0", + "php": "~8.1", + "crell/attributeutils": "^1.1", + "crell/ordered-collection": "~2.0", "fig/event-dispatcher-util": "^1.3", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", @@ -27,8 +28,8 @@ }, "require-dev": { "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.5", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.3" }, "provide": { "psr/event-dispatcher-implementation": "1.0" diff --git a/docker-compose.yml b/docker-compose.yml index 6c50fa6..196f4fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,23 +17,3 @@ services: environment: XDEBUG_MODE: "develop,debug" XDEBUG_CONFIG: "client_host=${HOST_IP} idekey=${IDE_KEY} client_port=${XDEBUG_PORT} discover_client_host=1 start_with_request=1" - php80: - build: ./docker/php/80 - volumes: - - ~/.composer:/.composer #uncomment this line to allow usage of local composer cache - - .:/usr/src/myapp - - ./docker/php/80/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - - ./docker/php/conf.d/error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini - environment: - XDEBUG_MODE: "develop,debug" - XDEBUG_CONFIG: "client_host=${HOST_IP} idekey=${IDE_KEY} client_port=${XDEBUG_PORT} discover_client_host=1 start_with_request=1" - php74: - build: ./docker/php/74 - volumes: - - ~/.composer:/.composer #uncomment this line to allow usage of local composer cache - - .:/usr/src/myapp - - ./docker/php/74/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - - ./docker/php/conf.d/error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini - environment: - XDEBUG_MODE: "develop,debug" - XDEBUG_CONFIG: "client_host=${HOST_IP} idekey=${IDE_KEY} client_port=${XDEBUG_PORT} discover_client_host=1 start_with_request=1" diff --git a/docker/php/74/Dockerfile b/docker/php/74/Dockerfile deleted file mode 100644 index 10cdff0..0000000 --- a/docker/php/74/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM php:7.4-cli -WORKDIR /usr/src/myapp - -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -RUN apt-get update && apt-get install zip unzip git -y \ - && pecl install xdebug \ - && pecl install pcov diff --git a/docker/php/74/xdebug.ini b/docker/php/74/xdebug.ini deleted file mode 100644 index b1ec307..0000000 --- a/docker/php/74/xdebug.ini +++ /dev/null @@ -1,2 +0,0 @@ -zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20190902/xdebug.so -xdebug.output_dir=profiles diff --git a/docker/php/80/Dockerfile b/docker/php/80/Dockerfile deleted file mode 100644 index ae37e01..0000000 --- a/docker/php/80/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM php:8.0-cli -WORKDIR /usr/src/myapp - -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -RUN apt-get update && apt-get install zip unzip git -y \ - && pecl install xdebug \ - && pecl install pcov diff --git a/docker/php/80/xdebug.ini b/docker/php/80/xdebug.ini deleted file mode 100644 index d2090a9..0000000 --- a/docker/php/80/xdebug.ini +++ /dev/null @@ -1,2 +0,0 @@ -zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so -xdebug.output_dir=profiles diff --git a/phpstan.neon b/phpstan.neon index aefdbdd..0865407 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,15 +1,9 @@ parameters: - level: 6 + level: 8 paths: - src - tests checkGenericClassInNonGenericObjectType: false - excludePaths: - # Attribute classes use PHP 8 syntax that's invalid on 7.4, but we don't care. - - src/Listener.php - - src/ListenerBefore.php - - src/ListenerAfter.php - - src/ListenerPriority.php ignoreErrors: - message: '#type has no value type specified in iterable type array#' @@ -17,3 +11,6 @@ parameters: - message: '#type has no value type specified in iterable type iterable#' path: tests/ + # PHPStan is overly aggressive on readonly properties. + - '#Class (.*) has an uninitialized readonly property (.*). Assign it in the constructor.#' + - '#Readonly property (.*) is assigned outside of the constructor.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f03fa05..752d521 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,11 @@ - + tests - - src/ - @@ -29,4 +15,9 @@ + + + src/ + + diff --git a/src/CallbackProvider.php b/src/CallbackProvider.php index 4f03261..b10559b 100644 --- a/src/CallbackProvider.php +++ b/src/CallbackProvider.php @@ -26,7 +26,7 @@ public function getListenersForEvent(object $event): iterable if ($event instanceof $type) { foreach ($callbacks as $callback) { if (method_exists($subject, $callback)) { - yield [$subject, $callback]; + yield $subject->$callback(...); } } } diff --git a/src/CompiledListenerProviderBase.php b/src/CompiledListenerProviderBase.php index 114eb1e..a26836e 100644 --- a/src/CompiledListenerProviderBase.php +++ b/src/CompiledListenerProviderBase.php @@ -9,31 +9,36 @@ class CompiledListenerProviderBase implements ListenerProviderInterface { - protected ContainerInterface $container; - - // This nested array will be generated by the compiler in a subclass. It's listed here for reference only. - // Its structure is an ordered list of array definitions, each of which corresponds to one of the defined - // entry types in the classes seen in getListenerForEvent(). See each class's getProperties() method for the - // exact structure. - /** @var array */ + /** + * This nested array will be generated by the compiler in a subclass. It's listed here for reference only. + * + * The structure is an ordered list of array definitions, each of which corresponds to one of the defined + * entry types in the classes seen in getListenerForEvent(). See each class's getProperties() method for the + * exact structure. + * + * @var array + */ protected array $listeners = []; - /** @var array */ + /** + * This nested array will be generated by the compiler in a subclass. It's listed here for reference only. + * + * The keys are an event class name. The value is an array of callables that may be + * returned to the dispatcher as-is. + * + * @var array> + */ protected array $optimized = []; - public function __construct(ContainerInterface $container) - { - $this->container = $container; - } + public function __construct(protected ContainerInterface $container) {} /** * @return iterable */ public function getListenersForEvent(object $event): iterable { - // @todo Switch to ::class syntax in PHP 8. - if (isset($this->optimized[get_class($event)])) { - return $this->optimized[get_class($event)]; + if (isset($this->optimized[$event::class])) { + return $this->optimized[$event::class]; } $count = count($this->listeners); diff --git a/src/DebugEventDispatcher.php b/src/DebugEventDispatcher.php index 2be17c9..e140769 100644 --- a/src/DebugEventDispatcher.php +++ b/src/DebugEventDispatcher.php @@ -9,10 +9,6 @@ class DebugEventDispatcher implements EventDispatcherInterface { - protected EventDispatcherInterface $dispatcher; - - protected LoggerInterface $logger; - /** * DebugEventDispatcher constructor. * @@ -21,11 +17,10 @@ class DebugEventDispatcher implements EventDispatcherInterface * @param LoggerInterface $logger * The logger service through which to log. */ - public function __construct(EventDispatcherInterface $dispatcher, LoggerInterface $logger) - { - $this->dispatcher = $dispatcher; - $this->logger = $logger; - } + public function __construct( + protected EventDispatcherInterface $dispatcher, + protected LoggerInterface $logger, + ) {} /** * {@inheritdoc} diff --git a/src/Entry/CompileableListenerEntryInterface.php b/src/Entry/CompileableListenerEntry.php similarity index 85% rename from src/Entry/CompileableListenerEntryInterface.php rename to src/Entry/CompileableListenerEntry.php index cd5fdf8..f15de40 100644 --- a/src/Entry/CompileableListenerEntryInterface.php +++ b/src/Entry/CompileableListenerEntry.php @@ -4,7 +4,7 @@ namespace Crell\Tukio\Entry; -interface CompileableListenerEntryInterface +interface CompileableListenerEntry { /** * Extracts relevant information for the listener. diff --git a/src/Entry/ListenerEntry.php b/src/Entry/ListenerEntry.php index 52f961c..c69b6dc 100644 --- a/src/Entry/ListenerEntry.php +++ b/src/Entry/ListenerEntry.php @@ -14,11 +14,8 @@ class ListenerEntry /** @var callable */ public $listener; - public string $type; - - public function __construct(callable $listener, string $type) + public function __construct(callable $listener, public string $type) { $this->listener = $listener; - $this->type = $type; } } diff --git a/src/Entry/ListenerFunctionEntry.php b/src/Entry/ListenerFunctionEntry.php index 690f741..bd32a62 100644 --- a/src/Entry/ListenerFunctionEntry.php +++ b/src/Entry/ListenerFunctionEntry.php @@ -9,7 +9,7 @@ * * @internal */ -class ListenerFunctionEntry extends ListenerEntry implements CompileableListenerEntryInterface +class ListenerFunctionEntry extends ListenerEntry implements CompileableListenerEntry { /** * @return array{ diff --git a/src/Entry/ListenerServiceEntry.php b/src/Entry/ListenerServiceEntry.php index db35f41..7d1a8d6 100644 --- a/src/Entry/ListenerServiceEntry.php +++ b/src/Entry/ListenerServiceEntry.php @@ -9,20 +9,13 @@ * * @internal */ -class ListenerServiceEntry implements CompileableListenerEntryInterface +class ListenerServiceEntry implements CompileableListenerEntry { - public string $serviceName; - - public string $method; - - public string $type; - - public function __construct(string $serviceName, string $method, string $type) - { - $this->serviceName = $serviceName; - $this->method = $method; - $this->type = $type; - } + public function __construct( + public string $serviceName, + public string $method, + public string $type, + ) {} /** * @return array{ diff --git a/src/Entry/ListenerStaticMethodEntry.php b/src/Entry/ListenerStaticMethodEntry.php index 4ed00df..a4712da 100644 --- a/src/Entry/ListenerStaticMethodEntry.php +++ b/src/Entry/ListenerStaticMethodEntry.php @@ -9,20 +9,13 @@ * * @internal */ -class ListenerStaticMethodEntry extends ListenerEntry implements CompileableListenerEntryInterface +class ListenerStaticMethodEntry extends ListenerEntry implements CompileableListenerEntry { - public string $class; - - public string $method; - - public string $type; - - public function __construct(string $class, string $method, string $type) - { - $this->class = $class; - $this->method = $method; - $this->type = $type; - } + public function __construct( + public string $class, + public string $method, + public string $type, + ) {} /** * @return array{ diff --git a/src/Listener.php b/src/Listener.php index 4aede2f..6ad67e3 100644 --- a/src/Listener.php +++ b/src/Listener.php @@ -5,12 +5,181 @@ 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; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] -class Listener implements ListenerAttribute +/** + * The main attribute to customize a listener. + */ +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +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 = []; + + /** @var string[] */ + 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. + * @param ?string $type + * The class or interface type of events for which this listener will be registered. If not provided + * it will be derived based on the type declaration of the listener. + */ 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(); + // getName() isn't part of the interface, but is present. PHP bug. + // @phpstan-ignore-next-line + $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__; + } + + /** + * @param array $methods + */ + 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 = [...$this->before, ...$class->before]; + $this->after = [...$this->after, ...$class->after]; + } + + public function subAttributes(): array + { + return [ + ListenerBefore::class => 'fromBefore', + ListenerAfter::class => 'fromAfter', + ListenerPriority::class => 'fromPriority', + ]; + } + + /** + * @param array $attribs + */ + public function fromBefore(array $attribs): void + { + if ($attribs) { + $this->hasDefinition ??= true; + } + foreach ($attribs as $attrib) { + $this->id ??= $attrib->id; + $this->type ??= $attrib->type; + $this->before = [...$this->before, ...$attrib->before]; + } + } + + /** + * @param array $attribs + */ + public function fromAfter(array $attribs): void + { + if ($attribs) { + $this->hasDefinition ??= true; + } + foreach ($attribs as $attrib) { + $this->id ??= $attrib->id; + $this->type ??= $attrib->type; + $this->after = [...$this->after, ...$attrib->after]; + } + } + + 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->methods ??= []; + $this->hasDefinition ??= false; + } + } diff --git a/src/ListenerAfter.php b/src/ListenerAfter.php index 0fec33c..84e6f1b 100644 --- a/src/ListenerAfter.php +++ b/src/ListenerAfter.php @@ -5,13 +5,22 @@ 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 = []; + + /** + * @param string|array $after + */ public function __construct( - public string $after, + string|array $after, public ?string $id = null, public ?string $type = null, - ) {} + ) { + $this->after = is_array($after) ? $after : [$after]; + } } diff --git a/src/ListenerBefore.php b/src/ListenerBefore.php index ec0525f..3e29fb0 100644 --- a/src/ListenerBefore.php +++ b/src/ListenerBefore.php @@ -5,13 +5,22 @@ 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 = []; + + /** + * @param string|array $before + */ public function __construct( - public string $before, + string|array $before, public ?string $id = null, public ?string $type = null, - ) {} + ) { + $this->before = is_array($before) ? $before : [$before]; + } } diff --git a/src/ListenerPriority.php b/src/ListenerPriority.php index bba108d..17ba90e 100644 --- a/src/ListenerPriority.php +++ b/src/ListenerPriority.php @@ -6,11 +6,11 @@ use Attribute; -#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class ListenerPriority implements ListenerAttribute { public function __construct( - public ?int $priority, + public int $priority, public ?string $id = null, public ?string $type = null, ) {} diff --git a/src/ListenerProxy.php b/src/ListenerProxy.php index 86405e1..24e9091 100644 --- a/src/ListenerProxy.php +++ b/src/ListenerProxy.php @@ -10,24 +10,22 @@ class ListenerProxy { use ParameterDeriverTrait; - protected OrderedProviderInterface $provider; - - protected string $serviceName; - - protected string $serviceClass; - /** * @var array * Methods that have already been registered on this subscriber, so we know not to double-subscribe them. */ protected array $registeredMethods = []; - public function __construct(OrderedProviderInterface $provider, string $serviceName, string $serviceClass) - { - $this->provider = $provider; - $this->serviceName = $serviceName; - $this->serviceClass = $serviceClass; - } + /** + * @param OrderedProviderInterface $provider + * @param string $serviceName + * @param class-string $serviceClass + */ + public function __construct( + protected OrderedProviderInterface $provider, + protected string $serviceName, + protected string $serviceClass + ) {} /** * Adds a method on a service as a listener. @@ -57,7 +55,7 @@ public function addListener(string $methodName, ?int $priority = 0, ?string $id * Note: The new listener is only guaranteed to come before the specified existing listener. No guarantee is made * regarding when it comes relative to any other listener. * - * @param string $pivotId + * @param string $before * The ID of an existing listener. * @param string $methodName * The method name of the service that is the listener being registered. @@ -69,11 +67,11 @@ public function addListener(string $methodName, ?int $priority = 0, ?string $id * @return string * The opaque ID of the listener. This can be used for future reference. */ - public function addListenerBefore(string $pivotId, string $methodName, ?string $id = null, ?string $type = null): string + public function addListenerBefore(string $before, string $methodName, ?string $id = null, ?string $type = null): string { $type = $type ?? $this->getServiceMethodType($methodName); $this->registeredMethods[] = $methodName; - return $this->provider->addListenerServiceBefore($pivotId, $this->serviceName, $methodName, $type, $id); + return $this->provider->addListenerServiceBefore($before, $this->serviceName, $methodName, $type, $id); } /** @@ -82,7 +80,7 @@ public function addListenerBefore(string $pivotId, string $methodName, ?string $ * Note: The new listener is only guaranteed to come before the specified existing listener. No guarantee is made * regarding when it comes relative to any other listener. * - * @param string $pivotId + * @param string $after * The ID of an existing listener. * @param string $methodName * The method name of the service that is the listener being registered. @@ -94,11 +92,11 @@ public function addListenerBefore(string $pivotId, string $methodName, ?string $ * @return string * The opaque ID of the listener. This can be used for future reference. */ - public function addListenerAfter(string $pivotId, string $methodName, ?string $id = null, ?string $type = null): string + public function addListenerAfter(string $after, string $methodName, ?string $id = null, ?string $type = null): string { $type = $type ?? $this->getServiceMethodType($methodName); $this->registeredMethods[] = $methodName; - return $this->provider->addListenerServiceAfter($pivotId, $this->serviceName, $methodName, $type, $id); + return $this->provider->addListenerServiceAfter($after, $this->serviceName, $methodName, $type, $id); } /** @@ -124,6 +122,9 @@ public function getRegisteredMethods(): array 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 array is not a callable, even though it is, because PHP. + // @phpstan-ignore-next-line $type = $this->getParameterType([$this->serviceClass, $methodName]); } catch (\InvalidArgumentException $exception) { throw InvalidTypeException::fromClassCallable($this->serviceClass, $methodName, $exception); diff --git a/src/Order.php b/src/Order.php new file mode 100644 index 0000000..c168013 --- /dev/null +++ b/src/Order.php @@ -0,0 +1,42 @@ + - */ - protected OrderedCollection $listeners; - - protected ?ContainerInterface $container; - - public function __construct(?ContainerInterface $container = null) + public function __construct(protected ?ContainerInterface $container = null) { - $this->listeners = new OrderedCollection(); - $this->container = $container; + parent::__construct(); } /** @@ -39,186 +28,58 @@ public function getListenersForEvent(object $event): iterable } } - public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - if ($attrib instanceof ListenerBefore) { - $generatedId = $this->listeners->addItemBefore($attrib->before, new ListenerEntry($listener, $type), $id); - } elseif ($attrib instanceof ListenerAfter) { - $generatedId = $this->listeners->addItemAfter($attrib->after, new ListenerEntry($listener, $type), $id); - } elseif ($attrib instanceof ListenerPriority) { - $generatedId = $this->listeners->addItem(new ListenerEntry($listener, $type), $attrib->priority, $id); - } else { - $generatedId = $this->listeners->addItem(new ListenerEntry($listener, $type), $priority ?? 0, $id); - } - } - // Return the last id only, because that's all we can do. - return $generatedId; - } - - $type = $type ?? $this->getType($listener); - $id = $id ?? $this->getListenerId($listener); - - return $this->listeners->addItem(new ListenerEntry($listener, $type), $priority ?? 0, $id); - } - - public function addListenerBefore(string $before, callable $listener, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - // The before-ness of this method call always overrides the attribute. - $generatedId = $this->listeners->addItemBefore($before, new ListenerEntry($listener, $type), $id); - } - // Return the last id only, because that's all we can do. - return $generatedId; - } - - $type = $type ?? $this->getType($listener); - $id = $id ?? $this->getListenerId($listener); - - return $this->listeners->addItemBefore($before, new ListenerEntry($listener, $type), $id); - } - - public function addListenerAfter(string $after, callable $listener, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - // The after-ness of this method call always overrides the attribute. - $generatedId = $this->listeners->addItemAfter($after, new ListenerEntry($listener, $type), $id); - } - // Return the last id only, because that's all we can do. - return $generatedId; - } - - $type = $type ?? $this->getType($listener); - $id = $id ?? $this->getListenerId($listener); - - return $this->listeners->addItemAfter($after, new ListenerEntry($listener, $type), $id); - } - - public function addListenerService(string $service, string $method, string $type, ?int $priority = null, ?string $id = null): string - { - $id = $id ?? $service . '-' . $method; - $priority = $priority ?? 0; - return $this->addListener($this->makeListenerForService($service, $method), $priority, $id, $type); - } - - public function addListenerServiceBefore(string $before, string $service, string $method, string $type, ?string $id = null): string - { - $id = $id ?? $service . '-' . $method; - return $this->addListenerBefore($before, $this->makeListenerForService($service, $method), $id, $type); - } - - public function addListenerServiceAfter(string $after, string $service, string $method, string $type, ?string $id = null): string + protected function getListenerEntry(callable $listener, string $type): ListenerEntry { - $id = $id ?? $service . '-' . $method; - return $this->addListenerAfter($after, $this->makeListenerForService($service, $method), $id, $type); + return new ListenerEntry($listener, $type); } - public function addSubscriber(string $class, string $service): void - { - $proxy = $this->addSubscribersByProxy($class, $service); - - try { - $methods = (new \ReflectionClass($class))->getMethods(\ReflectionMethod::IS_PUBLIC); - - // Explicitly registered methods ignore all auto-registration mechanisms. - $methods = array_filter($methods, static function(\ReflectionMethod $refm) use ($proxy) { - return !in_array($refm->getName(), $proxy->getRegisteredMethods()); - }); - - // Once we require PHP 7.4, replace the above with this line. - //$methods = array_filter($methods, fn(\ReflectionMethod $r) => !in_array($r->getName(), $proxy->getRegisteredMethods())); - - /** @var \ReflectionMethod $rMethod */ - foreach ($methods as $rMethod) { - $this->addSubscriberMethod($rMethod, $class, $service); + public function listenerService( + string $service, + ?string $method = null, + ?string $type = null, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null + ): string { + $method ??= $this->deriveMethod($service); + + if (!$type) { + if (!class_exists($service)) { + throw ServiceRegistrationClassNotExists::create($service); } - } catch (\ReflectionException $e) { - throw new \RuntimeException('Type error registering subscriber.', 0, $e); - } - } - - protected function addSubscribersByProxy(string $class, string $service): ListenerProxy - { - $proxy = new ListenerProxy($this, $service, $class); - - // Explicit registration is opt-in. - if (in_array(SubscriberInterface::class, class_implements($class))) { - /** @var SubscriberInterface $class */ - $class::registerListeners($proxy); + // @phpstan-ignore-next-line + $type = $this->getParameterType([$service, $method]); } - return $proxy; - } - /** - * @return array - */ - protected function findAttributesOnMethod(\ReflectionMethod $rMethod): array - { - // This extra dance needed to keep the code working on PHP < 8.0. It can be removed once - // 8.0 is made a requirement. - $attributes = []; - if (class_exists('ReflectionAttribute', false)) { - $attributes = array_map(static fn (\ReflectionAttribute $attrib): object - => $attrib->newInstance(), $rMethod->getAttributes(ListenerAttribute::class, \ReflectionAttribute::IS_INSTANCEOF)); + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); + + // In the special case that the service is the class name, we can + // leverage attributes. + 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); + + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $def->priority, + before: $def->before, + after: $def->after + ); } - return $attributes; - } - - protected function addSubscriberMethod(\ReflectionMethod $rMethod, string $class, string $service): void - { - $methodName = $rMethod->getName(); - - $attributes = $this->findAttributesOnMethod($rMethod); - - if (count($attributes)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $params = $rMethod->getParameters(); - $paramType = $params[0]->getType(); - // This can simplify to ?-> once we require PHP 8.0. - // getName() is not part of the declared reflection API, but it's there. - // @phpstan-ignore-next-line - $type = $attrib->type ?? ($paramType ? $paramType->getName() : null); - if (is_null($type)) { - throw InvalidTypeException::fromClassCallable($class, $methodName); - } - if ($attrib instanceof ListenerBefore) { - $this->addListenerServiceBefore($attrib->before, $service, $methodName, $type, $attrib->id); - } elseif ($attrib instanceof ListenerAfter) { - $this->addListenerServiceAfter($attrib->after, $service, $methodName, $type, $attrib->id); - } elseif ($attrib instanceof ListenerPriority) { - $this->addListenerService($service, $methodName, $type, $attrib->priority, $attrib->id); - } else { - $this->addListenerService($service, $methodName, $type, null, $attrib->id); - } - } - } elseif (strpos($methodName, 'on') === 0) { - $params = $rMethod->getParameters(); - $type = $params[0]->getType(); - if (is_null($type)) { - throw InvalidTypeException::fromClassCallable($class, $methodName); - } - // getName() is not part of the declared reflection API, but it's there. - // @phpstan-ignore-next-line - $this->addListenerService($service, $rMethod->getName(), $type->getName()); - } + $id ??= $service . '::' . $method; + return $this->listeners->add( + item: $this->getListenerEntry($this->makeListenerForService($service, $method), $type), + id: $id, + priority: $priority, + before: $before, + after: $after, + ); } /** @@ -246,8 +107,6 @@ protected function makeListenerForService(string $serviceName, string $methodNam // the wrapping listener must listen to just object. The explicit $type means it will still get only // the right event type, and the real listener can still type itself properly. $container = $this->container; - return static function (object $event) use ($serviceName, $methodName, $container): void { - $container->get($serviceName)->$methodName($event); - }; + return static fn (object $event) => $container->get($serviceName)->$methodName($event); } } diff --git a/src/OrderedProviderInterface.php b/src/OrderedProviderInterface.php index 02418de..d5dd3a1 100644 --- a/src/OrderedProviderInterface.php +++ b/src/OrderedProviderInterface.php @@ -6,12 +6,77 @@ interface OrderedProviderInterface { + + /** + * Adds a listener to the provider. + * + * @param callable $listener + * The listener to register. + * @param int|null $priority + * The numeric priority of the listener. + * @param array $before + * A list of listener IDs this listener should come before. + * @param array $after + * A list of listener IDs this listener should come after. + * @param ?string $id + * The identifier by which this listener should be known. If not specified one will be generated. + * @param ?string $type + * The class or interface type of events for which this listener will be registered. If not provided + * it will be derived based on the type declaration of the listener. + * + * @return string + * The opaque ID of the listener. This can be used for future reference. */ + public function listener( + callable $listener, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null, + ?string $type = null + ): string; + + /** + * Adds a method on a service as a listener. + * + * This method does not support attributes, as the class name is unknown at registration. + * + * @param string $service + * The name of a service on which this listener lives. + * @param string|null $method + * The method name of the service that is the listener being registered. + * If not specified, the collector will attempt to derive it on the + * assumption the class and service name are the same. A single-method + * class will use that single method. Otherwise, __invoke() will be assumed. + * @param string|null $type + * The class or interface type of events for which this listener will be registered. + * If not specified, the collector will attempt to derive it on the assumption + * that the class and service name are the same. + * @param int|null $priority + * The numeric priority of the listener. + * @param array $before + * A list of listener IDs this listener should come before. + * @param array $after + * A list of listener IDs this listener should come after. + * @return string + * The opaque ID of the listener. This can be used for future reference. + */ + public function listenerService( + string $service, + ?string $method = null, + ?string $type = null, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null + ): string; + /** * Adds a listener to the provider. * * A Listener, ListenerBefore, or ListenerAfter attribute on the listener may also provide * the priority, id, or type. Values specified in the method call take priority over the attribute. * + * @deprecated * @param callable $listener * The listener to register. * @param ?int $priority @@ -20,7 +85,7 @@ interface OrderedProviderInterface * The identifier by which this listener should be known. If not specified one will be generated. * @param ?string $type * The class or interface type of events for which this listener will be registered. If not provided - * it will be derived based on the type hint of the listener. + * it will be derived based on the type declaration of the listener. * * @return string * The opaque ID of the listener. This can be used for future reference. @@ -36,6 +101,7 @@ public function addListener(callable $listener, ?int $priority = null, ?string $ * A Listener, ListenerBefore, or ListenerAfter attribute on the listener may also provide * the id or type. The $before parameter specified here will always be used and the type of attribute ignored. * + * @deprecated * @param string $before * The ID of an existing listener. * @param callable $listener @@ -44,7 +110,7 @@ public function addListener(callable $listener, ?int $priority = null, ?string $ * The identifier by which this listener should be known. If not specified one will be generated. * @param ?string $type * The class or interface type of events for which this listener will be registered. If not provided - * it will be derived based on the type hint of the listener. + * it will be derived based on the type declaration of the listener. * * @return string * The opaque ID of the listener. This can be used for future reference. @@ -60,6 +126,7 @@ public function addListenerBefore(string $before, callable $listener, ?string $i * A Listener, ListenerBefore, or ListenerAfter attribute on the listener may also provide * the id or type. The $after parameter specified here will always be used and the type of attribute ignored. * + * @deprecated * @param string $after * The ID of an existing listener. * @param callable $listener @@ -68,7 +135,7 @@ public function addListenerBefore(string $before, callable $listener, ?string $i * The identifier by which this listener should be known. If not specified one will be generated. * @param ?string $type * The class or interface type of events for which this listener will be registered. If not provided - * it will be derived based on the type hint of the listener. + * it will be derived based on the type declaration of the listener. * * @return string * The opaque ID of the listener. This can be used for future reference. @@ -80,6 +147,7 @@ public function addListenerAfter(string $after, callable $listener, ?string $id * * This method does not support attributes, as the class name is unknown at registration. * + * @deprecated * @param string $service * The name of a service on which this listener lives. * @param string $method @@ -104,6 +172,7 @@ public function addListenerService(string $service, string $method, string $type * * This method does not support attributes, as the class name is unknown at registration. * + * @deprecated * @param string $before * The ID of an existing listener. * @param string $service @@ -128,6 +197,7 @@ public function addListenerServiceBefore(string $before, string $service, string * * This method does not support attributes, as the class name is unknown at registration. * + * @deprecated * @param string $after * The ID of an existing listener. * @param string $service @@ -147,17 +217,17 @@ 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 hint 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 string $class + * @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/ProviderBuilder.php b/src/ProviderBuilder.php index ccf90ad..2e03110 100644 --- a/src/ProviderBuilder.php +++ b/src/ProviderBuilder.php @@ -8,27 +8,14 @@ use Crell\Tukio\Entry\ListenerFunctionEntry; use Crell\Tukio\Entry\ListenerServiceEntry; use Crell\Tukio\Entry\ListenerStaticMethodEntry; -use Crell\OrderedCollection\OrderedCollection; -class ProviderBuilder implements OrderedProviderInterface, \IteratorAggregate +class ProviderBuilder extends ProviderCollector implements \IteratorAggregate { - use ProviderUtilities; - - /** - * @var OrderedCollection - */ - protected OrderedCollection $listeners; - /** * @var array */ protected array $optimizedEvents = []; - public function __construct() - { - $this->listeners = new OrderedCollection(); - } - /** * Pre-specify an event class that should have an optimized listener list built. * @@ -47,129 +34,44 @@ public function getOptimizedEvents(): array return $this->optimizedEvents; } - public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - $entry = $this->getListenerEntry($listener, $type); - if ($attrib instanceof ListenerBefore) { - $generatedId = $this->listeners->addItemBefore($attrib->before, $entry, $id); - } elseif ($attrib instanceof ListenerAfter) { - $generatedId = $this->listeners->addItemAfter($attrib->after, $entry, $id); - } elseif ($attrib instanceof ListenerPriority) { - $generatedId = $this->listeners->addItem($entry, $attrib->priority, $id); - } else { - $generatedId = $this->listeners->addItem($entry, $priority ?? 0, $id); - } + public function listenerService( + string $service, + ?string $method = null, + ?string $type = null, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null + ): string { + $method ??= $this->deriveMethod($service); + + if (!$type) { + if (!class_exists($service)) { + throw ServiceRegistrationClassNotExists::create($service); } - // Return the last id only, because that's all we can do. - return $generatedId; + // @phpstan-ignore-next-line + $type = $this->getParameterType([$service, $method]); } - $entry = $this->getListenerEntry($listener, $type ?? $this->getParameterType($listener)); - $id = $id ?? $this->getListenerId($listener); - - return $this->listeners->addItem($entry, $priority ?? 0, $id); - } - - public function addListenerBefore(string $before, callable $listener, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - $entry = $this->getListenerEntry($listener, $type); - // The before-ness of this method takes priority over the attribute. - $generatedId = $this->listeners->addItemBefore($before, $entry, $id); - } - // Return the last id only, because that's all we can do. - return $generatedId; - } + $orderSpecified = !is_null($priority) || !empty($before) || !empty($after); - $id = $id ?? $this->getListenerId($listener); - $entry = $this->getListenerEntry($listener, $type ?? $this->getParameterType($listener)); - return $this->listeners->addItemBefore($before, $entry, $id); - } + // In the special case that the service is the class name, we can + // leverage attributes. + 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); - public function addListenerAfter(string $after, callable $listener, ?string $id = null, ?string $type = null): string - { - if ($attributes = $this->getAttributes($listener)) { - // @todo We can probably do better than this in the next major. - /** @var Listener|ListenerBefore|ListenerAfter|ListenerPriority $attrib */ - foreach ($attributes as $attrib) { - $type = $type ?? $attrib->type ?? $this->getType($listener); - $id = $id ?? $attrib->id ?? $this->getListenerId($listener); - $entry = $this->getListenerEntry($listener, $type); - // The before-ness of this method takes priority over the attribute. - $generatedId = $this->listeners->addItemBefore($after, $entry, $id); - } - // Return the last id only, because that's all we can do. - return $generatedId; + $entry = new ListenerServiceEntry($service, $method, $type); + return $this->listeners->add($entry, $id, priority: $def->priority, before: $def->before, after: $def->after); } - $entry = $this->getListenerEntry($listener, $type ?? $this->getParameterType($listener)); - $id = $id ?? $this->getListenerId($listener); - - return $this->listeners->addItemAfter($after, $entry, $id); - } - - public function addListenerService(string $service, string $method, string $type, ?int $priority = null, ?string $id = null): string - { - $entry = new ListenerServiceEntry($service, $method, $type); - $priority = $priority ?? 0; - - return $this->listeners->addItem($entry, $priority, $id); - } - - public function addListenerServiceBefore(string $before, string $service, string $method, string $type, ?string $id = null): string - { - $entry = new ListenerServiceEntry($service, $method, $type); - - return $this->listeners->addItemBefore($before, $entry, $id); - } - - public function addListenerServiceAfter(string $after, string $service, string $method, string $type, ?string $id = null): string - { $entry = new ListenerServiceEntry($service, $method, $type); + $id ??= $service . '-' . $method; - return $this->listeners->addItemAfter($after, $entry, $id); - } - - public function addSubscriber(string $class, string $service): void - { - // @todo This method is identical to the one in OrderedListenerProvider. Is it worth merging them? - - $proxy = new ListenerProxy($this, $service, $class); - - // Explicit registration is opt-in. - if (in_array(SubscriberInterface::class, class_implements($class))) { - /** @var SubscriberInterface $class */ - $class::registerListeners($proxy); - } - - try { - $rClass = new \ReflectionClass($class); - $methods = $rClass->getMethods(\ReflectionMethod::IS_PUBLIC); - /** @var \ReflectionMethod $rMethod */ - foreach ($methods as $rMethod) { - $methodName = $rMethod->getName(); - if (!in_array($methodName, $proxy->getRegisteredMethods()) && strpos($methodName, 'on') === 0) { - $params = $rMethod->getParameters(); - // getName() is not part of the declared reflection API, but it's there. - // @phpstan-ignore-next-line - $type = $params[0]->getType()->getName(); - $this->addListenerService($service, $rMethod->getName(), $type); - } - } - } catch (\ReflectionException $e) { - throw new \RuntimeException('Type error registering subscriber.', 0, $e); - } + return $this->listeners->add($entry, $id, priority: $priority, before: $before, after: $after); } public function getIterator(): \Traversable diff --git a/src/ProviderCollector.php b/src/ProviderCollector.php new file mode 100644 index 0000000..ab15c5d --- /dev/null +++ b/src/ProviderCollector.php @@ -0,0 +1,329 @@ + + */ + protected MultiOrderedCollection $listeners; + + public function __construct( + protected readonly FunctionAnalyzer $funcAnalyzer = new MemoryCacheFunctionAnalyzer(new FuncAnalyzer()), + protected readonly ClassAnalyzer $classAnalyzer = new MemoryCacheAnalyzer(new Analyzer()), + ) { + $this->listeners = new MultiOrderedCollection(); + } + + public function listener( + callable $listener, + ?int $priority = null, + array $before = [], + array $after = [], + ?string $id = null, + ?string $type = null + ): string { + $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); + + return $this->listeners->add( + item: $entry, + id: $id, + priority: $priority, + before: $before, + after: $after + ); + } + + public function addListener(callable $listener, ?int $priority = null, ?string $id = null, ?string $type = null): string + { + return $this->listener($listener, priority: $priority, id: $id, type: $type); + } + + public function addListenerBefore(string $before, callable $listener, ?string $id = null, ?string $type = null): string + { + return $this->listener($listener, before: [$before], id: $id, type: $type); + } + + public function addListenerAfter(string $after, callable $listener, ?string $id = null, ?string $type = null): string + { + return $this->listener($listener, after: [$after], id: $id, type: $type); + } + + public function addListenerService(string $service, string $method, string $type, ?int $priority = null, ?string $id = null): string + { + return $this->listenerService($service, $method, $type, priority: $priority, id: $id); + } + + public function addListenerServiceBefore(string $before, string $service, string $method, string $type, ?string $id = null): string + { + return $this->listenerService($service, $method, $type, before: [$before], id: $id); + } + + public function addListenerServiceAfter(string $after, string $service, string $method, string $type, ?string $id = null): string + { + return $this->listenerService($service, $method, $type, after: [$after], id: $id); + } + + 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); + + $proxyRegisteredMethods = $proxy->getRegisteredMethods(); + + 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); + } + } + + /** + * @param class-string $class + */ + protected function addSubscribersByProxy(string $class, string $service): ListenerProxy + { + $proxy = new ListenerProxy($this, $service, $class); + + // Explicit registration is opt-in. + if (in_array(SubscriberInterface::class, class_implements($class) ?: [], true)) { + /** @var SubscriberInterface $class */ + $class::registerListeners($proxy); + } + return $proxy; + } + + /** + * @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{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{0: class-string, 1: string} $listener */ + [$class, $method] = $listener; + + $def = $this->classAnalyzer->analyze($class, Listener::class); + return $def->staticMethods[$method]; + } + + return new Listener(); + } + + protected function deriveMethod(string $service): string + { + if (!class_exists($service)) { + throw ServiceRegistrationClassNotExists::create($service); + } + $rClass = new \ReflectionClass($service); + $rMethods = $rClass->getMethods(); + + // If the class has only one method, assume that's the listener. + // Otherwise, use __invoke if not otherwise specified. + // Otherwise, we cannot tell what to do so throw. + return match (true) { + count($rMethods) === 1 => $rMethods[0]->name, + $rClass->hasMethod('__invoke') => '__invoke', + default => throw ServiceRegistrationTooManyMethods::create($service), + }; + } + + /** + * Tries to get the type of a callable listener. + * + * If unable, throws an exception with information about the listener whose type could not be fetched. + * + * @param callable $listener + * The callable from which to extract a type. + * + * @return string + * The type of the first argument. + */ + protected function getType(callable $listener): string + { + try { + $type = $this->getParameterType($listener); + } catch (\InvalidArgumentException $exception) { + if ($this->isClassCallable($listener) || $this->isObjectCallable($listener)) { + /** @var array{0: class-string, 1: string} $listener */ + throw InvalidTypeException::fromClassCallable($listener[0], $listener[1], $exception); + } + if ($this->isFunctionCallable($listener) || $this->isClosureCallable($listener)) { + throw InvalidTypeException::fromFunctionCallable($listener, $exception); + } + throw new InvalidTypeException($exception->getMessage(), $exception->getCode(), $exception); + } + return $type; + } + + /** + * Derives a predictable ID from the listener if possible. + * + * It's OK for this method to return null, as OrderedCollection will + * generate a random ID if necessary. It will also handle duplicates + * for us. This method is just a suggestion. + * + * @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 + * The derived ID if possible or null if no reasonable ID could be derived. + */ + protected function getListenerId(callable|array $listener): ?string + { + if ($this->isFunctionCallable($listener)) { + // Function callables are strings, so use that directly. + /** @var string $listener */ + return $listener; + } + + 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 */ + if ($listener[1] === '__invoke') { + return $listener[0]; + } + return $listener[0] . '::' . $listener[1]; + } + + // Anything else we can't derive an ID for logically. + return null; + } + + /** + * Determines if a callable represents a function. + * + * Or at least a reasonable approximation, since a function name may not be defined yet. + * + * @param callable|array $callable + * @return bool + * True if the callable represents a function, false otherwise. + */ + protected function isFunctionCallable(callable|array $callable): bool + { + // We can't check for function_exists() because it may be included later by the time it matters. + return is_string($callable); + } + + /** + * Determines if a callable represents a method on an object. + * + * @param callable|array $callable + * @return bool + * True if the callable represents a method object, false otherwise. + */ + protected function isObjectCallable(callable|array $callable): bool + { + return is_array($callable) && is_object($callable[0]); + } + + /** + * Determines if a callable represents a closure/anonymous function. + * + * @param callable|array $callable + * @return bool + * True if the callable represents a closure object, false otherwise. + */ + 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/src/ProviderCompiler.php b/src/ProviderCompiler.php index 89e283f..f35b6fe 100644 --- a/src/ProviderCompiler.php +++ b/src/ProviderCompiler.php @@ -4,14 +4,18 @@ namespace Crell\Tukio; -use Crell\Tukio\Entry\CompileableListenerEntryInterface; +use Crell\Tukio\Entry\CompileableListenerEntry; use Crell\Tukio\Entry\ListenerFunctionEntry; use Crell\Tukio\Entry\ListenerServiceEntry; use Crell\Tukio\Entry\ListenerStaticMethodEntry; +use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\ListenerProviderInterface; class ProviderCompiler { /** + * Compiles a provided ProviderBuilder to a named class on disk. + * * @param ProviderBuilder $listeners * The set of listeners to compile. * @param resource $stream @@ -36,6 +40,36 @@ public function compile( fwrite($stream, $this->createClosing()); } + /** + * Compiles a provided ProviderBuilder to an anonymous class on disk. + * + * The generated class requires a container instance in its constructor, which + * because it's anonymous has a pre-defined name of $container. That variable must + * be in scope when the resulting file is require()ed/include()ed. The easiest way + * to do that is to use the loadAnonymous() method of this class, but you may also + * do so manually. + * + * @param ProviderBuilder $listeners + * The set of listeners to compile. + * @param resource $stream + * A writeable stream to which to write the compiled class. + */ + public function compileAnonymous(ProviderBuilder $listeners, $stream): void + { + fwrite($stream, $this->createAnonymousPreamble()); + + $this->writeMainListenersList($listeners, $stream); + + $this->writeOptimizedList($listeners, $stream); + + fwrite($stream, $this->createAnonymousClosing()); + } + + public function loadAnonymous(string $filename, ContainerInterface $container): ListenerProviderInterface + { + return require($filename); + } + /** * @param resource $stream * A writeable stream to which to write the compiled code. @@ -44,7 +78,7 @@ protected function writeMainListenersList(ProviderBuilder $listeners, $stream): { fwrite($stream, $this->startMainListenersList()); - /** @var CompileableListenerEntryInterface $listenerEntry */ + /** @var CompileableListenerEntry $listenerEntry */ foreach ($listeners as $listenerEntry) { $item = $this->createEntry($listenerEntry); fwrite($stream, $item); @@ -69,11 +103,11 @@ protected function writeOptimizedList(ProviderBuilder $listeners, $stream): void fwrite($stream, $this->startOptimizedEntry($event)); $relevantListeners = array_filter($listenerDefs, - static fn(CompileableListenerEntryInterface $entry) + static fn(CompileableListenerEntry $entry) => in_array($entry->getProperties()['type'], $ancestors, true) ); - /** @var CompileableListenerEntryInterface $listenerEntry */ + /** @var CompileableListenerEntry $listenerEntry */ foreach ($relevantListeners as $listenerEntry) { $item = $this->createOptimizedEntry($listenerEntry); fwrite($stream, $item); @@ -99,27 +133,22 @@ protected function endOptimizedEntry(): string END; } - protected function createOptimizedEntry(CompileableListenerEntryInterface $listenerEntry): string + protected function createOptimizedEntry(CompileableListenerEntry $listenerEntry): string { $listener = $listenerEntry->getProperties(); - switch ($listener['entryType']) { - case ListenerFunctionEntry::class: - $ret = "'{$listener['listener']}'"; - break; - case ListenerStaticMethodEntry::class: - $ret = var_export([$listener['class'], $listener['method']], true); - break; - case ListenerServiceEntry::class: - $ret = sprintf('fn(object $event) => $this->container->get(\'%s\')->%s($event)', $listener['serviceName'], $listener['method']); - break; - default: - throw new \RuntimeException(sprintf('No such listener type found in compiled container definition: %s', $listener['entryType'])); - } + $ret = match ($listener['entryType']) { + ListenerFunctionEntry::class => "'{$listener['listener']}'", + ListenerStaticMethodEntry::class => var_export([$listener['class'], $listener['method']], true), + ListenerServiceEntry::class => sprintf('fn(object $event) => $this->container->get(\'%s\')->%s($event)', + $listener['serviceName'], $listener['method']), + default => throw new \RuntimeException(sprintf('No such listener type found in compiled container definition: %s', + $listener['entryType'])), + }; return $ret . ',' . PHP_EOL; } - protected function createEntry(CompileableListenerEntryInterface $listenerEntry): string + protected function createEntry(CompileableListenerEntry $listenerEntry): string { $listener = $listenerEntry->getProperties(); switch ($listener['entryType']) { @@ -168,16 +197,37 @@ public function __construct(ContainerInterface \$container) END; } + protected function createAnonymousPreamble(): string + { + return << */ protected function classAncestors(string $class, bool $includeClass = true): array { // These methods both return associative arrays, making + safe. - $ancestors = class_parents($class) + class_implements($class); + /** @var array $ancestors */ + $ancestors = (class_parents($class) ?: []) + (class_implements($class) ?: []); return $includeClass ? [$class => $class] + $ancestors : $ancestors @@ -223,6 +273,15 @@ protected function createClosing(): string } // Close constructor } // Close class +END; + } + + protected function createAnonymousClosing(): string + { + return <<<'END' + } // Close constructor +}; // Close class + END; } } diff --git a/src/ProviderUtilities.php b/src/ProviderUtilities.php deleted file mode 100644 index 00e97bc..0000000 --- a/src/ProviderUtilities.php +++ /dev/null @@ -1,154 +0,0 @@ - - */ - protected function getAttributes(callable $listener): array - { - // Bail out < PHP 8.0. - if (!class_exists('ReflectionAttribute', false)) { - return []; - } - - $ref = null; - - if ($this->isFunctionCallable($listener)) { - $ref = new \ReflectionFunction($listener); - } 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); - } 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 (!$ref) { - return []; - } - - $attribs = $ref->getAttributes(ListenerAttribute::class, \ReflectionAttribute::IS_INSTANCEOF); - - return array_map(fn(\ReflectionAttribute $attrib) => $attrib->newInstance(), $attribs); - } - - /** - * Tries to get the type of a callable listener. - * - * If unable, throws an exception with information about the listener whose type could not be fetched. - * - * @param callable $listener - * The callable from which to extract a type. - * - * @return string - * The type of the first argument. - */ - protected function getType(callable $listener): string - { - try { - $type = $this->getParameterType($listener); - } catch (\InvalidArgumentException $exception) { - if ($this->isClassCallable($listener) || $this->isObjectCallable($listener)) { - throw InvalidTypeException::fromClassCallable($listener[0], $listener[1], $exception); - } - if ($this->isFunctionCallable($listener) || $this->isClosureCallable($listener)) { - throw InvalidTypeException::fromFunctionCallable($listener, $exception); - } - throw new InvalidTypeException($exception->getMessage(), $exception->getCode(), $exception); - } - return $type; - } - - /** - * Derives a predictable ID from the listener if possible. - * - * It's OK for this method to return null, as OrderedCollection will - * generate a random ID if necessary. It will also handle duplicates - * for us. This method is just a suggestion. - * - * @param callable $listener - * The listener for which to derive an ID. - * - * @return string|null - * The derived ID if possible or null if no reasonable ID could be derived. - */ - protected function getListenerId(callable $listener): ?string - { - // The methods called in this method are from an external trait, and - // its docblock is a bit buggy. Just ignore that on our end until - // it's fixed in the util package. - // @phpstan-ignore-next-line - if ($this->isFunctionCallable($listener)) { - // Function callables are strings, so use that directly. - // @phpstan-ignore-next-line - return (string)$listener; - } - // @phpstan-ignore-next-line - if ($this->isClassCallable($listener)) { - return $listener[0] . '::' . $listener[1]; - } - // @phpstan-ignore-next-line - 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; - } - - /** - * Determines if a callable represents a function. - * - * Or at least a reasonable approximation, since a function name may not be defined yet. - * - * @return bool - * True if the callable represents a function, false otherwise. - */ - protected function isFunctionCallable(callable $callable): bool - { - // We can't check for function_exists() because it may be included later by the time it matters. - return is_string($callable); - } - - /** - * Determines if a callable represents a method on an object. - * - * @return bool - * True if the callable represents a method object, false otherwise. - */ - protected function isObjectCallable(callable $callable): bool - { - return is_array($callable) && is_object($callable[0]); - } - - /** - * Determines if a callable represents a closure/anonymous function. - * - * @return bool - * True if the callable represents a closure object, false otherwise. - */ - protected function isClosureCallable(callable $callable): bool - { - return $callable instanceof \Closure; - } -} diff --git a/src/ServiceRegistrationClassNotExists.php b/src/ServiceRegistrationClassNotExists.php new file mode 100644 index 0000000..05f641e --- /dev/null +++ b/src/ServiceRegistrationClassNotExists.php @@ -0,0 +1,19 @@ +service = $service; + $msg = 'Tukio can auto-detect the type and method for a listener service only if the service ID is a valid class name. The service "%s" does not exist. Please specify the $method and $type parameters explicitly, or check that you are using the right service name.'; + + $new->message = sprintf($msg, $service); + + return $new; + } +} diff --git a/src/ServiceRegistrationTooManyMethods.php b/src/ServiceRegistrationTooManyMethods.php new file mode 100644 index 0000000..65a8f6d --- /dev/null +++ b/src/ServiceRegistrationTooManyMethods.php @@ -0,0 +1,19 @@ +service = $service; + $msg = 'Tukio can auto-detect a single method on a listener service, or use one named __invoke(). The "%s" service has too many methods not named __invoke(). Please check your class or use a subscriber.'; + + $new->message = sprintf($msg, $service); + + return $new; + } +} diff --git a/src/SubscriberInterface.php b/src/SubscriberInterface.php index 99c2bd7..11e7bd7 100644 --- a/src/SubscriberInterface.php +++ b/src/SubscriberInterface.php @@ -4,6 +4,9 @@ namespace Crell\Tukio; +/** + * @deprecated + */ interface SubscriberInterface { public static function registerListeners(ListenerProxy $proxy): void; diff --git a/tests/CallbackProviderTest.php b/tests/CallbackProviderTest.php index 2dec7da..9e55cec 100644 --- a/tests/CallbackProviderTest.php +++ b/tests/CallbackProviderTest.php @@ -5,37 +5,18 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; - -class LifecycleEvent extends CollectingEvent implements CallbackEventInterface -{ - protected FakeEntity $entity; - - public function __construct(FakeEntity $entity) - { - $this->entity = $entity; - } - - public function getSubject(): object - { - return $this->entity; - } -} - -class LoadEvent extends LifecycleEvent {} - -class SaveEvent extends LifecycleEvent {} - class FakeEntity { - - public function load(LoadEvent $event): void + public function load(Events\LoadEvent $event): void { $event->add('A'); } - public function save(SaveEvent $event): void + public function save(Events\SaveEvent $event): void { $event->add('B'); } @@ -47,42 +28,43 @@ public function stuff(StuffEvent $event): void $event->add('C'); } - public function all(LifecycleEvent $event): void + public function all(Events\LifecycleEvent $event): void { $event->add('D'); } } - class CallbackProviderTest extends TestCase { - public function test_callback(): void + #[Test] + public function callback_provider(): void { $p = new CallbackProvider(); $entity = new FakeEntity(); - $p->addCallbackMethod(LoadEvent::class, 'load'); - $p->addCallbackMethod(SaveEvent::class, 'save'); - $p->addCallbackMethod(LifecycleEvent::class, 'all'); + $p->addCallbackMethod(Events\LoadEvent::class, 'load'); + $p->addCallbackMethod(Events\SaveEvent::class, 'save'); + $p->addCallbackMethod(Events\LifecycleEvent::class, 'all'); - $event = new LoadEvent($entity); + $event = new Events\LoadEvent($entity); foreach ($p->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals('AD', implode($event->result())); + self::assertEquals('AD', implode($event->result())); } - public function test_non_callback_event_skips_silently(): void + #[Test] + public function non_callback_event_skips_silently(): void { $p = new CallbackProvider(); - $p->addCallbackMethod(LoadEvent::class, 'load'); - $p->addCallbackMethod(SaveEvent::class, 'save'); - $p->addCallbackMethod(LifecycleEvent::class, 'all'); + $p->addCallbackMethod(Events\LoadEvent::class, 'load'); + $p->addCallbackMethod(Events\SaveEvent::class, 'save'); + $p->addCallbackMethod(Events\LifecycleEvent::class, 'all'); $event = new CollectingEvent(); @@ -90,6 +72,6 @@ public function test_non_callback_event_skips_silently(): void $listener($event); } - $this->assertEquals('', implode($event->result())); + self::assertEquals('', implode($event->result())); } } diff --git a/tests/CompiledListenerProviderAttributeTest.php b/tests/CompiledListenerProviderAttributeTest.php index 19357a6..1da76c7 100644 --- a/tests/CompiledListenerProviderAttributeTest.php +++ b/tests/CompiledListenerProviderAttributeTest.php @@ -4,6 +4,12 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Events\EventOne; +use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\MockAttributedSubscriber; +use Crell\Tukio\Listeners\MockSubscriber; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[ListenerPriority(3, 'A', CollectingEvent::class)] @@ -27,31 +33,12 @@ 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'); - } -} - -/** - * @requires PHP >= 8.0 - */ -class CompiledEventDispatcherAttributeTest extends TestCase +class CompiledListenerProviderAttributeTest extends TestCase { use MakeCompiledProviderTrait; - function test_compiled_provider_triggers_in_order(): void + #[Test] + public function compiled_provider_triggers_in_order(): void { $class = 'AtCompiledProvider'; $namespace = 'Test\\Space'; @@ -59,13 +46,13 @@ function test_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); @@ -75,10 +62,12 @@ function test_compiled_provider_triggers_in_order(): void $listener($event); } - $this->assertEquals('ABC', implode($event->result())); + $result = implode($event->result()); + self::assertTrue(strpos($result, 'B') > strpos($result, 'A')); } - public function test_add_subscriber(): void + #[Test] + public function add_subscriber(): void { // This test is parallel to and uses the same mock subscriber as // RegisterableListenerProviderServiceTest::test_add_subscriber(). @@ -93,7 +82,7 @@ public function test_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); @@ -102,6 +91,12 @@ public function test_add_subscriber(): void $listener($event); } - $this->assertEquals('BCAEDF', implode($event->result())); + // We can't guarantee a stricter order than the instructions provided, so + // just check for those rather than a precise order. + $result = implode($event->result()); + self::assertTrue(strpos($result, 'B') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'C') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'D') > strpos($result, 'A')); + self::assertTrue(strpos($result, 'F') > strpos($result, 'A')); } } diff --git a/tests/CompiledListenerProviderInheritanceTest.php b/tests/CompiledListenerProviderInheritanceTest.php index 971a18b..0292282 100644 --- a/tests/CompiledListenerProviderInheritanceTest.php +++ b/tests/CompiledListenerProviderInheritanceTest.php @@ -4,65 +4,32 @@ namespace Crell\Tukio; +use Crell\Tukio\Fakes\EventParentInterface; +use Crell\Tukio\Fakes\MockContainer; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -interface EventParentInterface -{ - public function add(string $val): void; - public function result(): array; -} - -class ListenedDirectly implements EventParentInterface { - protected array $out = []; - - public function add(string $val): void - { - $this->out[] = $val; - } - - public function result() : array - { - return $this->out; - } -} - -class Subclass extends ListenedDirectly {} - -class NotListenedDirectly implements EventParentInterface { - protected array $out = []; - - public function add(string $val): void - { - $this->out[] = $val; - } - - public function result(): array - { - return $this->out; - } -} - -function inheritanceListenerA(EventParentInterface $event): void +function inheritanceListenerA(Fakes\EventParentInterface $event): void { $event->add('A'); } -function inheritanceListenerB(ListenedDirectly $event): void +function inheritanceListenerB(Fakes\ListenedDirectly $event): void { $event->add('B'); } -function inheritanceListenerC(Subclass $event): void +function inheritanceListenerC(Fakes\Subclass $event): void { $event->add('C'); } - class CompiledListenerProviderInheritanceTest extends TestCase { use MakeCompiledProviderTrait; - public function test_interface_listener_catches_everything(): void + #[Test] + public function interface_listener_catches_everything(): void { $class = __FUNCTION__; $namespace = 'Test\\Space'; @@ -76,22 +43,23 @@ public function test_interface_listener_catches_everything(): void $provider = $this->makeProvider($builder, $container, $class, $namespace); $tests = [ - ListenedDirectly::class => 'A', - Subclass::class => 'A', - NotListenedDirectly::class => 'A', + Fakes\ListenedDirectly::class => 'A', + Fakes\Subclass::class => 'A', + Fakes\NotListenedDirectly::class => 'A', ]; foreach ($tests as $class => $result) { - /** @var EventParentInterface $event */ + /** @var \Crell\Tukio\Fakes\EventParentInterface $event */ $event = new $class(); foreach ($provider->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals($result, implode($event->result())); + self::assertEquals($result, implode($event->result())); } } - public function test_class_listener_catches_subclass(): void + #[Test] + public function class_listener_catches_subclass(): void { $class = __FUNCTION__; $namespace = 'Test\\Space'; @@ -105,22 +73,23 @@ public function test_class_listener_catches_subclass(): void $provider = $this->makeProvider($builder, $container, $class, $namespace); $tests = [ - ListenedDirectly::class => 'B', - Subclass::class => 'B', - NotListenedDirectly::class => '', + Fakes\ListenedDirectly::class => 'B', + Fakes\Subclass::class => 'B', + Fakes\NotListenedDirectly::class => '', ]; foreach ($tests as $class => $result) { - /** @var EventParentInterface $event */ + /** @var \Crell\Tukio\Fakes\EventParentInterface $event */ $event = new $class(); foreach ($provider->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals($result, implode($event->result())); + self::assertEquals($result, implode($event->result())); } } - public function test_subclass_listener_catches_subclass(): void + #[Test] + public function subclass_listener_catches_subclass(): void { $class = __FUNCTION__; $namespace = 'Test\\Space'; @@ -134,18 +103,18 @@ public function test_subclass_listener_catches_subclass(): void $provider = $this->makeProvider($builder, $container, $class, $namespace); $tests = [ - ListenedDirectly::class => '', - Subclass::class => 'C', - NotListenedDirectly::class => '', + Fakes\ListenedDirectly::class => '', + Fakes\Subclass::class => 'C', + Fakes\NotListenedDirectly::class => '', ]; foreach ($tests as $class => $result) { - /** @var EventParentInterface $event */ + /** @var \Crell\Tukio\Fakes\EventParentInterface $event */ $event = new $class(); foreach ($provider->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals($result, implode($event->result())); + self::assertEquals($result, implode($event->result())); } } diff --git a/tests/CompiledListenerProviderTest.php b/tests/CompiledListenerProviderTest.php index d919f62..592f870 100644 --- a/tests/CompiledListenerProviderTest.php +++ b/tests/CompiledListenerProviderTest.php @@ -4,6 +4,20 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Events\EventOne; +use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\ArbitraryListener; +use Crell\Tukio\Listeners\CompoundListener; +use Crell\Tukio\Listeners\InvalidListener; +use Crell\Tukio\Listeners\InvokableListener; +use Crell\Tukio\Listeners\InvokableListenerClassAttribute; +use Crell\Tukio\Listeners\InvokableListenerClassNoId; +use Crell\Tukio\Listeners\InvokableListenerClassNoIdBefore; +use Crell\Tukio\Listeners\MockSubscriber; +use Crell\Tukio\Listeners\TestAttributedListeners; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; function listenerA(CollectingEvent $event): void @@ -24,27 +38,12 @@ function noListen(EventOne $event): void throw new \Exception('This should not be called'); } -class Listen -{ - public static function listen(CollectingEvent $event): void - { - $event->add('C'); - } -} - -class ListenService -{ - public static function listen(CollectingEvent $event): void - { - $event->add('D'); - } -} - -class CompiledEventDispatcherTest extends TestCase +class CompiledListenerProviderTest extends TestCase { use MakeCompiledProviderTrait; - function test_compiled_provider_triggers_in_order(): void + #[Test] + public function compiled_provider_triggers_in_order(): void { $class = 'CompiledProvider'; $namespace = 'Test\\Space'; @@ -52,12 +51,12 @@ function test_compiled_provider_triggers_in_order(): void $builder = new ProviderBuilder(); $container = new MockContainer(); - $container->addService('D', new ListenService()); + $container->addService('D', new Listeners\ListenService()); $builder->addListener('\\Crell\\Tukio\\listenerA'); $builder->addListener('\\Crell\\Tukio\\listenerB'); $builder->addListener('\\Crell\\Tukio\\noListen'); - $builder->addListener([Listen::class, 'listen']); + $builder->addListener([Listeners\Listen::class, 'listen']); $builder->addListenerService('D', 'listen', CollectingEvent::class); $provider = $this->makeProvider($builder, $container, $class, $namespace); @@ -68,15 +67,16 @@ function test_compiled_provider_triggers_in_order(): void } $result = $event->result(); - $this->assertContains('A', $result); - $this->assertContains('B', $result); - $this->assertContains('C', $result); - $this->assertContains('D', $result); + self::assertContains('A', $result); + self::assertContains('B', $result); + self::assertContains('C', $result); + self::assertContains('D', $result); - $this->assertTrue(true); + self::assertTrue(true); } - public function test_add_subscriber(): void + #[Test] + public function add_subscriber(): void { // This test is parallel to and uses the same mock subscriber as // OrderedListenerProviderServiceTest::test_add_subscriber(). @@ -100,10 +100,17 @@ public function test_add_subscriber(): void $listener($event); } - $this->assertEquals('BCAEDF', implode($event->result())); + // We can't guarantee a stricter order than the instructions provided, so + // just check for those rather than a precise order. + $result = implode($event->result()); + self::assertTrue(strpos($result, 'B') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'C') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'D') > strpos($result, 'A')); + self::assertTrue(strpos($result, 'F') > strpos($result, 'A')); } - public function test_natural_id_on_compiled_provider(): void + #[Test] + public function natural_id_on_compiled_provider(): void { $class = 'NaturalIdProvider'; $namespace = 'Test\\Space'; @@ -126,10 +133,11 @@ public function test_natural_id_on_compiled_provider(): void $listener($event); } - $this->assertEquals('BACD', implode($event->result())); + self::assertEquals('BACD', implode($event->result())); } - public function test_explicit_id_on_compiled_provider(): void + #[Test] + public function explicit_id_on_compiled_provider(): void { $class = 'ExplicitIdProvider'; $namespace = 'Test\\Space'; @@ -152,10 +160,11 @@ public function test_explicit_id_on_compiled_provider(): void $listener($event); } - $this->assertEquals('BACD', implode($event->result())); + self::assertEquals('BACD', implode($event->result())); } - public function test_optimize_event(): void + #[Test] + public function optimize_event(): void { $class = 'OptimizedEventProvider'; $namespace = 'Test\\Space'; @@ -180,6 +189,166 @@ public function test_optimize_event(): void $listener($event); } - $this->assertEquals('BACD', implode($event->result())); + self::assertEquals('BACD', implode($event->result())); + } + + #[Test] + public function anonymous_class_compile(): void + { + $builder = new ProviderBuilder(); + + $container = new MockContainer(); + $container->addService('D', new Listeners\ListenService()); + + $builder->addListener('\\Crell\\Tukio\\listenerA'); + $builder->addListener('\\Crell\\Tukio\\listenerB'); + $builder->addListener('\\Crell\\Tukio\\noListen'); + $builder->addListener([Listeners\Listen::class, 'listen']); + $builder->addListenerService('D', 'listen', CollectingEvent::class); + + $provider = $this->makeAnonymousProvider($builder, $container); + + $event = new CollectingEvent(); + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $result = $event->result(); + self::assertContains('A', $result); + self::assertContains('B', $result); + self::assertContains('C', $result); + self::assertContains('D', $result); + + self::assertTrue(true); + } + + #[Test] + public function optimize_event_anonymous_class(): void + { + $builder = new ProviderBuilder(); + $container = new MockContainer(); + + // Just to make the following lines shorter and easier to read. + $ns = '\\Crell\\Tukio\\'; + + $builder->addListener("{$ns}event_listener_one", -4, 'id-1'); + $builder->addListenerBefore('id-1', "{$ns}event_listener_two", 'id-2'); + $builder->addListenerAfter('id-2', "{$ns}event_listener_three", 'id-3'); + $builder->addListenerAfter('id-3', "{$ns}event_listener_four"); + + $builder->optimizeEvents(CollectingEvent::class); + + $provider = $this->makeAnonymousProvider($builder, $container); + + $event = new CollectingEvent(); + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + self::assertEquals('BACD', implode($event->result())); + } + + #[Test, DataProvider('detection_class_examples')] + public function detects_invoke_method_and_type(string $class): void + { + $builder = new ProviderBuilder(); + $container = new MockContainer(); + + $container->addService($class, new $class()); + + $builder->listenerService($class); + + $provider = $this->makeAnonymousProvider($builder, $container); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + self::assertEquals($class, $event->result()[0]); + } + + public static function detection_class_examples(): iterable + { + return [ + [InvokableListener::class], + [ArbitraryListener::class], + [CompoundListener::class], + ]; + } + + #[Test] + public function rejects_multi_method_class_without_invoke(): void + { + $this->expectException(ServiceRegistrationTooManyMethods::class); + $container = new MockContainer(); + + $container->addService(InvalidListener::class, new InvalidListener()); + + $builder = new ProviderBuilder(); + + $builder->listenerService(InvalidListener::class); + } + + #[Test] + public function rejects_missing_auto_detected_service(): void + { + $this->expectException(ServiceRegistrationClassNotExists::class); + $container = new MockContainer(); + + $provider = new OrderedListenerProvider($container); + + $builder = new ProviderBuilder(); + + // @phpstan-ignore-next-line + $builder->listenerService(DoesNotExist::class); + } + + #[Test] + public function add_attribute_based_service_methods(): void + { + $builder = new ProviderBuilder(); + $container = new MockContainer(); + + $container->addService(TestAttributedListeners::class, new TestAttributedListeners()); + + $builder->listenerService(TestAttributedListeners::class, 'listenerC'); + $builder->listenerService(TestAttributedListeners::class, 'listenerD'); + + $provider = $this->makeAnonymousProvider($builder, $container); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + self::assertEquals('DC', implode($event->result())); + } + + #[Test] + public function detects_invoke_method_and_gives_class_id_by_default(): void + { + $builder = new ProviderBuilder(); + $container = new MockContainer(); + + $container->addService(InvokableListenerClassNoId::class, new InvokableListenerClassNoId('beep')); + $container->addService(InvokableListenerClassNoIdBefore::class, new InvokableListenerClassNoIdBefore()); + + $builder->listenerService(InvokableListenerClassNoId::class); + $builder->listenerService(InvokableListenerClassNoIdBefore::class); + + $provider = $this->makeAnonymousProvider($builder, $container); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $results = $event->result(); + self::assertEquals(InvokableListenerClassNoIdBefore::class, $results[0]); + self::assertEquals(InvokableListenerClassNoId::class, $results[1]); } } diff --git a/tests/DebugEventDispatcherTest.php b/tests/DebugEventDispatcherTest.php index 5023c76..56d7814 100644 --- a/tests/DebugEventDispatcherTest.php +++ b/tests/DebugEventDispatcherTest.php @@ -5,10 +5,11 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Fakes\MockLogger; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Log\AbstractLogger; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; class DebugEventDispatcherTest extends TestCase @@ -22,7 +23,8 @@ public function setUp(): void $this->logger = new MockLogger(); } - public function test_event_is_logged() : void + #[Test] + public function event_is_logged() : void { $inner = new class implements EventDispatcherInterface { public function dispatch(object $event) @@ -36,8 +38,8 @@ public function dispatch(object $event) $event = new CollectingEvent(); $p->dispatch($event); - $this->assertCount(1, $this->logger->messages[LogLevel::DEBUG]); - $this->assertEquals('Processing event of type {type}.', $this->logger->messages[LogLevel::DEBUG][0]['message']); - $this->assertEquals($event, $this->logger->messages[LogLevel::DEBUG][0]['context']['event']); + self::assertCount(1, $this->logger->messages[LogLevel::DEBUG]); + self::assertEquals('Processing event of type {type}.', $this->logger->messages[LogLevel::DEBUG][0]['message']); + self::assertEquals($event, $this->logger->messages[LogLevel::DEBUG][0]['context']['event']); } } diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index 1509162..28f016a 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -4,10 +4,12 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Events\StoppableCollectingEvent; +use Crell\Tukio\Fakes\MockLogger; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\ListenerProviderInterface; -use Psr\Log\AbstractLogger; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; class DispatcherTest extends TestCase @@ -21,7 +23,8 @@ public function setUp(): void $this->logger = new MockLogger(); } - public function test_dispatcher_calls_all_listeners() : void + #[Test] + public function dispatcher_calls_all_listeners() : void { $provider = new class implements ListenerProviderInterface { public function getListenersForEvent(object $event): iterable @@ -39,10 +42,11 @@ public function getListenersForEvent(object $event): iterable $event = new CollectingEvent(); $p->dispatch($event); - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_stoppable_events_stop() : void { + #[Test] + public function stoppable_events_stop() : void { $provider = new class implements ListenerProviderInterface { public function getListenersForEvent(object $event): iterable { @@ -59,10 +63,11 @@ public function getListenersForEvent(object $event): iterable $event = new StoppableCollectingEvent(); $p->dispatch($event); - $this->assertEquals('CRE', implode($event->result())); + self::assertEquals('CRE', implode($event->result())); } - public function test_listener_exception_logged() : void + #[Test] + public function listener_exception_logged() : void { $provider = new class implements ListenerProviderInterface { public function getListenersForEvent(object $event): iterable @@ -83,19 +88,20 @@ public function getListenersForEvent(object $event): iterable $this->fail('No exception was bubbled up.'); } catch (\Exception $e) { - $this->assertEquals('Fail!', $e->getMessage()); + self::assertEquals('Fail!', $e->getMessage()); } - $this->assertEquals('CR', implode($event->result())); + self::assertEquals('CR', implode($event->result())); - $this->assertArrayHasKey(LogLevel::WARNING, $this->logger->messages); - $this->assertCount(1, $this->logger->messages[LogLevel::WARNING]); + self::assertArrayHasKey(LogLevel::WARNING, $this->logger->messages); + self::assertCount(1, $this->logger->messages[LogLevel::WARNING]); $entry = $this->logger->messages[LogLevel::WARNING][0]; - $this->assertEquals('Unhandled exception thrown from listener while processing event.', $entry['message']); - $this->assertEquals($event, $entry['context']['event']); + self::assertEquals('Unhandled exception thrown from listener while processing event.', $entry['message']); + self::assertEquals($event, $entry['context']['event']); } - public function test_already_stopped_event_calls_no_listeners() : void + #[Test] + public function already_stopped_event_calls_no_listeners() : void { $provider = new class implements ListenerProviderInterface { public function getListenersForEvent(object $event): iterable @@ -111,6 +117,6 @@ public function getListenersForEvent(object $event): iterable $d->dispatch($event); - $this->assertEquals('', implode($event->result())); + self::assertEquals('', implode($event->result())); } } diff --git a/tests/CollectingEvent.php b/tests/Events/CollectingEvent.php similarity index 89% rename from tests/CollectingEvent.php rename to tests/Events/CollectingEvent.php index 63e8865..da2ea4f 100644 --- a/tests/CollectingEvent.php +++ b/tests/Events/CollectingEvent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Events; class CollectingEvent diff --git a/tests/Events/DoNothingEvent.php b/tests/Events/DoNothingEvent.php new file mode 100644 index 0000000..911c0d1 --- /dev/null +++ b/tests/Events/DoNothingEvent.php @@ -0,0 +1,8 @@ +entity = $entity; + } + + public function getSubject(): object + { + return $this->entity; + } +} diff --git a/tests/Events/LoadEvent.php b/tests/Events/LoadEvent.php new file mode 100644 index 0000000..54d5c00 --- /dev/null +++ b/tests/Events/LoadEvent.php @@ -0,0 +1,9 @@ +out[] = $val; + } + + public function result(): array + { + return $this->out; + } +} diff --git a/tests/MockContainer.php b/tests/Fakes/MockContainer.php similarity index 96% rename from tests/MockContainer.php rename to tests/Fakes/MockContainer.php index 0e6756f..06f61dd 100644 --- a/tests/MockContainer.php +++ b/tests/Fakes/MockContainer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Fakes; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; diff --git a/tests/MockLogger.php b/tests/Fakes/MockLogger.php similarity index 84% rename from tests/MockLogger.php rename to tests/Fakes/MockLogger.php index 5d00cf1..f4ca2b5 100644 --- a/tests/MockLogger.php +++ b/tests/Fakes/MockLogger.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Fakes; use Psr\Log\AbstractLogger; -class MockLogger extends AbstractLogger { +class MockLogger extends AbstractLogger +{ /** * @var array diff --git a/tests/Fakes/NotListenedDirectly.php b/tests/Fakes/NotListenedDirectly.php new file mode 100644 index 0000000..fcabae7 --- /dev/null +++ b/tests/Fakes/NotListenedDirectly.php @@ -0,0 +1,18 @@ +out[] = $val; + } + + public function result(): array + { + return $this->out; + } +} diff --git a/tests/Fakes/Subclass.php b/tests/Fakes/Subclass.php new file mode 100644 index 0000000..63a7870 --- /dev/null +++ b/tests/Fakes/Subclass.php @@ -0,0 +1,9 @@ +add(static::class); + } +} 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/CompoundListener.php b/tests/Listeners/CompoundListener.php new file mode 100644 index 0000000..97023a6 --- /dev/null +++ b/tests/Listeners/CompoundListener.php @@ -0,0 +1,18 @@ +add(static::class); + } + + public function dontUseThis(CollectingEvent $event): void + { + throw new \Exception('This should not get called.'); + } +} diff --git a/tests/Listeners/InvalidListener.php b/tests/Listeners/InvalidListener.php new file mode 100644 index 0000000..bb3e110 --- /dev/null +++ b/tests/Listeners/InvalidListener.php @@ -0,0 +1,18 @@ +add(static::class); + } + + public function dontUseThis(CollectingEvent $event): void + { + throw new \Exception('This should not get called.'); + } +} diff --git a/tests/Listeners/InvokableListener.php b/tests/Listeners/InvokableListener.php new file mode 100644 index 0000000..eefe2e2 --- /dev/null +++ b/tests/Listeners/InvokableListener.php @@ -0,0 +1,13 @@ +add(static::class); + } +} 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/Listeners/InvokableListenerClassNoId.php b/tests/Listeners/InvokableListenerClassNoId.php new file mode 100644 index 0000000..79879c1 --- /dev/null +++ b/tests/Listeners/InvokableListenerClassNoId.php @@ -0,0 +1,17 @@ +add(static::class); + } +} diff --git a/tests/Listeners/InvokableListenerClassNoIdBefore.php b/tests/Listeners/InvokableListenerClassNoIdBefore.php new file mode 100644 index 0000000..3da2544 --- /dev/null +++ b/tests/Listeners/InvokableListenerClassNoIdBefore.php @@ -0,0 +1,16 @@ +add(static::class); + } +} diff --git a/tests/Listeners/Listen.php b/tests/Listeners/Listen.php new file mode 100644 index 0000000..82fcfec --- /dev/null +++ b/tests/Listeners/Listen.php @@ -0,0 +1,13 @@ +add('C'); + } +} diff --git a/tests/Listeners/ListenService.php b/tests/Listeners/ListenService.php new file mode 100644 index 0000000..faecded --- /dev/null +++ b/tests/Listeners/ListenService.php @@ -0,0 +1,13 @@ +add('D'); + } +} diff --git a/tests/MockAttributedSubscriber.php b/tests/Listeners/MockAttributedSubscriber.php similarity index 84% rename from tests/MockAttributedSubscriber.php rename to tests/Listeners/MockAttributedSubscriber.php index 7c96810..edf0527 100644 --- a/tests/MockAttributedSubscriber.php +++ b/tests/Listeners/MockAttributedSubscriber.php @@ -2,9 +2,16 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Listeners; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Listener; +use Crell\Tukio\ListenerAfter; +use Crell\Tukio\ListenerBefore; +use Crell\Tukio\ListenerPriority; +use Crell\Tukio\NoEvent; + class MockAttributedSubscriber { #[Listener(id: 'a')] diff --git a/tests/MockMalformedSubscriber.php b/tests/Listeners/MockMalformedSubscriber.php similarity index 92% rename from tests/MockMalformedSubscriber.php rename to tests/Listeners/MockMalformedSubscriber.php index 569a0a6..a02f669 100644 --- a/tests/MockMalformedSubscriber.php +++ b/tests/Listeners/MockMalformedSubscriber.php @@ -2,7 +2,10 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Listeners; + +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\ListenerProxy; class MockMalformedSubscriber { @@ -13,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. */ @@ -21,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. */ @@ -36,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/MockSubscriber.php b/tests/Listeners/MockSubscriber.php similarity index 90% rename from tests/MockSubscriber.php rename to tests/Listeners/MockSubscriber.php index 3a33100..ab58a20 100644 --- a/tests/MockSubscriber.php +++ b/tests/Listeners/MockSubscriber.php @@ -2,9 +2,14 @@ declare(strict_types=1); -namespace Crell\Tukio; +namespace Crell\Tukio\Listeners; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\ListenerProxy; +use Crell\Tukio\NoEvent; +use Crell\Tukio\SubscriberInterface; + class MockSubscriber implements SubscriberInterface { public function onA(CollectingEvent $event) : void diff --git a/tests/Listeners/TestAttributedListeners.php b/tests/Listeners/TestAttributedListeners.php new file mode 100644 index 0000000..9f6e0d5 --- /dev/null +++ b/tests/Listeners/TestAttributedListeners.php @@ -0,0 +1,34 @@ +add('A'); + } + + #[ListenerBefore(before: 'a')] + public static function listenerB(CollectingEvent $event): void + { + $event->add('B'); + } + + #[ListenerPriority(id: 'c', priority: -4)] + public function listenerC(CollectingEvent $event): void + { + $event->add('C'); + } + + #[ListenerBefore(before: 'c')] + public function listenerD(CollectingEvent $event): void + { + $event->add('D'); + } +} diff --git a/tests/Listeners/TestListeners.php b/tests/Listeners/TestListeners.php new file mode 100644 index 0000000..e48f7ba --- /dev/null +++ b/tests/Listeners/TestListeners.php @@ -0,0 +1,28 @@ +add('A'); + } + + public static function listenerB(CollectingEvent $event): void + { + $event->add('B'); + } + + public function listenerC(CollectingEvent $event): void + { + $event->add('C'); + } + + public function listenerD(CollectingEvent $event): void + { + $event->add('D'); + } +} diff --git a/tests/MakeCompiledProviderTrait.php b/tests/MakeCompiledProviderTrait.php index ae352f9..44f8bdc 100644 --- a/tests/MakeCompiledProviderTrait.php +++ b/tests/MakeCompiledProviderTrait.php @@ -29,12 +29,19 @@ trait MakeCompiledProviderTrait */ protected function makeProvider(ProviderBuilder $builder, ContainerInterface $container, string $class, string $namespace) : ListenerProviderInterface { + // Write the generated compiler out to a temp file. + $filename = tempnam(sys_get_temp_dir(), 'compiled'); + if (!$filename) { + throw new \RuntimeException('Unable to create temp file for compiled provider.'); + } + $out = fopen($filename, 'w'); + if (!$out) { + throw new \RuntimeException('Unable to open file to write compiled provider.'); + } + try { $compiler = new ProviderCompiler(); - // Write the generated compiler out to a temp file. - $filename = tempnam(sys_get_temp_dir(), 'compiled'); - $out = fopen($filename, 'w'); $compiler->compile($builder, $out, $class, $namespace); fclose($out); @@ -46,15 +53,37 @@ protected function makeProvider(ProviderBuilder $builder, ContainerInterface $co $provider = new $compiledClassName($container); } finally { - // This check is not actually needed as no exception could be - // thrown before $filename gets defined, but PHPStan doesn't - // understand that. - if (isset($filename)) { - unlink($filename); - } + unlink($filename); } return $provider; } + protected function makeAnonymousProvider(ProviderBuilder $builder, ContainerInterface $container): ListenerProviderInterface + { + // Write the generated compiler out to a temp file. + $filename = tempnam(sys_get_temp_dir(), 'compiled'); + if (!$filename) { + throw new \RuntimeException('Unable to create temp file for compiled provider.'); + } + $out = fopen($filename, 'w'); + if (!$out) { + throw new \RuntimeException('Unable to open file to write compiled provider.'); + } + + try { + $compiler = new ProviderCompiler(); + + $compiler->compileAnonymous($builder, $out); + fclose($out); + + // Now include it. If there's a parse error PHP will throw a ParseError and PHPUnit will catch it for us. + $provider = $compiler->loadAnonymous($filename, $container); + } + finally { + unlink($filename); + } + + return $provider; + } } diff --git a/tests/OrderedListenerProviderAttributeServiceTest.php b/tests/OrderedListenerProviderAttributeServiceTest.php index 77fc311..4ea75e2 100644 --- a/tests/OrderedListenerProviderAttributeServiceTest.php +++ b/tests/OrderedListenerProviderAttributeServiceTest.php @@ -5,14 +5,16 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\MockAttributedSubscriber; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -/** - * @requires PHP >= 8.0 - */ class OrderedListenerProviderAttributeServiceTest extends TestCase { - public function test_add_subscriber() : void + #[Test] + public function add_subscriber() : void { $container = new MockContainer(); @@ -30,6 +32,12 @@ public function test_add_subscriber() : void $listener($event); } - $this->assertEquals('BCAEDF', implode($event->result())); + // We can't guarantee a stricter order than the instructions provided, so + // just check for those rather than a precise order. + $result = implode($event->result()); + self::assertTrue(strpos($result, 'B') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'C') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'D') > strpos($result, 'A')); + self::assertTrue(strpos($result, 'F') > strpos($result, 'A')); } } diff --git a/tests/OrderedListenerProviderAttributeTest.php b/tests/OrderedListenerProviderAttributeTest.php index 10d9f97..b04e75c 100644 --- a/tests/OrderedListenerProviderAttributeTest.php +++ b/tests/OrderedListenerProviderAttributeTest.php @@ -4,6 +4,9 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Fakes\MockContainer; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[ListenerPriority(0, 'a')] @@ -33,52 +36,10 @@ function at_listener_four($event): void $event->add('D'); } -class DoNothingEvent -{ - public bool $called = false; -} - -class TestAttributedListeners -{ - #[ListenerPriority(id: 'a', priority: -4)] - public static function listenerA(CollectingEvent $event) : void - { - $event->add('A'); - } - - #[ListenerBefore(before: 'a')] - public static function listenerB(CollectingEvent $event) : void - { - $event->add('B'); - } - - #[ListenerPriority(id: 'c', priority: -4)] - public function listenerC(CollectingEvent $event) : void - { - $event->add('C'); - } - - #[ListenerBefore(before: 'c')] - public function listenerD(CollectingEvent $event) : void - { - $event->add('D'); - } -} - -#[Listener('A')] -#[Listener('B')] -#[Listener('C')] -function at_multi_one(CollectingEvent $event): void -{ - $event->add('A'); -} - -/** - * @requires PHP >= 8.0 - */ class OrderedListenerProviderAttributeTest extends TestCase { - public function test_id_from_attribute_is_found() : void + #[Test] + public function id_from_attribute_is_found() : void { $p = new OrderedListenerProvider(); @@ -94,11 +55,12 @@ public function test_id_from_attribute_is_found() : void $listener($event); } - $this->assertEquals('a', $id_one); - $this->assertEquals('BA', implode($event->result())); + self::assertEquals('a', $id_one); + self::assertEquals('BA', implode($event->result())); } - public function test_priority_from_attribute_honored() : void + #[Test] + public function priority_from_attribute_honored() : void { $p = new OrderedListenerProvider(); @@ -114,10 +76,11 @@ public function test_priority_from_attribute_honored() : void $listener($event); } - $this->assertEquals('CA', implode($event->result())); + self::assertEquals('CA', implode($event->result())); } - public function test_type_from_attribute_called_correctly() : void + #[Test] + public function type_from_attribute_called_correctly() : void { $p = new OrderedListenerProvider(); @@ -134,10 +97,11 @@ public function test_type_from_attribute_called_correctly() : void $listener($event); } - $this->assertEquals('CDA', implode($event->result())); + self::assertEquals('CDA', implode($event->result())); } - public function test_type_from_attribute_skips_correctly() : void + #[Test] + public function type_from_attribute_skips_correctly() : void { $p = new OrderedListenerProvider(); @@ -146,7 +110,7 @@ public function test_type_from_attribute_skips_correctly() : void $p->addListener("{$ns}at_listener_four"); - $event = new DoNothingEvent(); + $event = new Events\DoNothingEvent(); // This should explode with an "method not found" error // if the event is passed to the listener. @@ -154,14 +118,15 @@ public function test_type_from_attribute_skips_correctly() : void $listener($event); } - $this->assertEquals(false, $event->called); + self::assertEquals(false, $event->called); } - public function test_attributes_found_on_object_methods() : void + #[Test] + public function attributes_found_on_object_methods() : void { $p = new OrderedListenerProvider(); - $object = new TestAttributedListeners(); + $object = new Listeners\TestAttributedListeners(); $p->addListener([$object, 'listenerC']); $p->addListener([$object, 'listenerD']); @@ -172,10 +137,11 @@ public function test_attributes_found_on_object_methods() : void $listener($event); } - $this->assertEquals('DC', implode($event->result())); + self::assertEquals('DC', implode($event->result())); } - public function test_before_after_methods_win_over_attributes(): void + #[Test] + public function before_after_methods_win_over_attributes(): void { $p = new OrderedListenerProvider(); @@ -192,24 +158,27 @@ public function test_before_after_methods_win_over_attributes(): void $listener($event); } - $this->assertEquals('CAD', implode($event->result())); + self::assertEquals('CAD', implode($event->result())); } - public function test_multiple_attributes_read_separately(): void + #[Test] + public function add_attribute_based_service_methods(): void { - $p = new OrderedListenerProvider(); + $container = new MockContainer(); - // Just to make the following lines shorter and easier to read. - $ns = '\\Crell\\Tukio\\'; + $container->addService(Listeners\TestAttributedListeners::class, new Listeners\TestAttributedListeners()); + + $provider = new OrderedListenerProvider($container); - $idOne = $p->addListener("{$ns}at_multi_one"); + $provider->listenerService(Listeners\TestAttributedListeners::class, 'listenerC'); + $provider->listenerService(Listeners\TestAttributedListeners::class, 'listenerD'); $event = new CollectingEvent(); - foreach ($p->getListenersForEvent($event) as $listener) { + foreach ($provider->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals('AAA', implode($event->result())); + self::assertEquals('DC', implode($event->result())); } } diff --git a/tests/OrderedListenerProviderIdTest.php b/tests/OrderedListenerProviderIdTest.php index 6fda91d..d5d772f 100644 --- a/tests/OrderedListenerProviderIdTest.php +++ b/tests/OrderedListenerProviderIdTest.php @@ -5,6 +5,9 @@ namespace Crell\Tukio; +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Fakes\MockContainer; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; function event_listener_one(CollectingEvent $event): void @@ -28,32 +31,11 @@ function event_listener_four(CollectingEvent $event): void } -class TestListeners -{ - public static function listenerA(CollectingEvent $event): void - { - $event->add('A'); - } - public static function listenerB(CollectingEvent $event): void - { - $event->add('B'); - } - - public function listenerC(CollectingEvent $event): void - { - $event->add('C'); - } - - public function listenerD(CollectingEvent $event): void - { - $event->add('D'); - } -} - class OrderedListenerProviderIdTest extends TestCase { - public function test_natural_id_for_function(): void + #[Test] + public function natural_id_for_function(): void { $p = new OrderedListenerProvider(); @@ -71,15 +53,16 @@ public function test_natural_id_for_function(): void $listener($event); } - $this->assertEquals('BACD', implode($event->result())); + self::assertEquals('BACD', implode($event->result())); } - public function test_natural_id_for_static_method(): void + #[Test] + public function natural_id_for_static_method(): void { $p = new OrderedListenerProvider(); - $p->addListener([TestListeners::class, 'listenerA'], -4); - $p->addListenerBefore(TestListeners::class . '::listenerA', [TestListeners::class, 'listenerB']); + $p->addListener([Listeners\TestListeners::class, 'listenerA'], -4); + $p->addListenerBefore(Listeners\TestListeners::class . '::listenerA', [Listeners\TestListeners::class, 'listenerB']); $event = new CollectingEvent(); @@ -87,17 +70,18 @@ public function test_natural_id_for_static_method(): void $listener($event); } - $this->assertEquals('BA', implode($event->result())); + self::assertEquals('BA', implode($event->result())); } - public function test_natural_id_for_object_method(): void + #[Test] + public function natural_id_for_object_method(): void { $p = new OrderedListenerProvider(); - $l = new TestListeners(); + $l = new Listeners\TestListeners(); $p->addListener([$l, 'listenerC'], -4); - $p->addListenerBefore(TestListeners::class . '::listenerC', [$l, 'listenerD']); + $p->addListenerBefore(Listeners\TestListeners::class . '::listenerC', [$l, 'listenerD']); $event = new CollectingEvent(); @@ -105,10 +89,11 @@ public function test_natural_id_for_object_method(): void $listener($event); } - $this->assertEquals('DC', implode($event->result())); + self::assertEquals('DC', implode($event->result())); } - public function test_explicit_id_for_function(): void + #[Test] + public function explicit_id_for_function(): void { $p = new OrderedListenerProvider(); @@ -126,10 +111,11 @@ public function test_explicit_id_for_function(): void $listener($event); } - $this->assertEquals('BACD', implode($event->result())); + self::assertEquals('BACD', implode($event->result())); } - public function test_natural_id_for_service_listener(): void + #[Test] + public function natural_id_for_service_listener(): void { $container = new MockContainer(); @@ -151,15 +137,15 @@ 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); } - $this->assertEquals('A-listen', $idA); - $this->assertEquals('AB', implode($event->result())); + self::assertEquals('A::listen', $idA); + self::assertEquals('AB', implode($event->result())); } } diff --git a/tests/OrderedListenerProviderMultiAttributeTest.php b/tests/OrderedListenerProviderMultiAttributeTest.php new file mode 100644 index 0000000..02433b0 --- /dev/null +++ b/tests/OrderedListenerProviderMultiAttributeTest.php @@ -0,0 +1,64 @@ +add('A'); +} + +#[Listener('b')] +function listener_b(CollectingEvent $event): void +{ + $event->add('B'); +} + +function listener_c(CollectingEvent $event): void +{ + $event->add('C'); +} + +#[ListenerBefore('\\Crell\\Tukio\\listener_a')] +#[ListenerBefore('b')] +function listener_d(CollectingEvent $event): void +{ + $event->add('D'); +} + +class OrderedListenerProviderMultiAttributeTest extends TestCase +{ + #[Test] + public function ordering_with_multiple_before_after_rules_works(): void + { + $p = new OrderedListenerProvider(); + + // Just to make the following lines shorter and easier to read. + $ns = '\\Crell\\Tukio\\'; + + $p->listener("{$ns}listener_a"); + $p->listener("{$ns}listener_b"); + $p->listener("{$ns}listener_c"); + $p->listener("{$ns}listener_d"); + + $event = new CollectingEvent(); + + foreach ($p->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $result = implode($event->result()); + self::assertTrue(strpos($result, 'A') > strpos($result, 'B')); + self::assertTrue(strpos($result, 'A') > strpos($result, 'C')); + self::assertTrue(strpos($result, 'D') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'D') < strpos($result, 'B')); + } + +} diff --git a/tests/OrderedListenerProviderServiceTest.php b/tests/OrderedListenerProviderServiceTest.php index 101231b..19eddc4 100644 --- a/tests/OrderedListenerProviderServiceTest.php +++ b/tests/OrderedListenerProviderServiceTest.php @@ -4,13 +4,19 @@ namespace Crell\Tukio; - +use Crell\Tukio\Events\CollectingEvent; +use Crell\Tukio\Fakes\MockContainer; +use Crell\Tukio\Listeners\InvokableListenerClassAttribute; +use Crell\Tukio\Listeners\InvokableListenerClassNoId; +use Crell\Tukio\Listeners\MockMalformedSubscriber; +use Crell\Tukio\Listeners\MockSubscriber; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; class OrderedListenerProviderServiceTest extends TestCase { - /** @var MockContainer */ - protected $mockContainer; + protected MockContainer $mockContainer; public function setUp(): void { @@ -64,14 +70,15 @@ public function hear(CollectingEvent $event): void $this->mockContainer = $container; } - public function test_add_listener_service(): void + #[Test] + public function add_listener_service(): void { $p = new OrderedListenerProvider($this->mockContainer); $p->addListenerService('L', 'hear', CollectingEvent::class, 70); $p->addListenerService('E', 'listen', CollectingEvent::class, 80); $p->addListenerService('C', 'listen', CollectingEvent::class, 100); - $p->addListenerService('L', 'hear', CollectingEvent::class); // Defaults to 0 + $p->addListenerService('L', 'hear', CollectingEvent::class, priority: 0); $p->addListenerService('R', 'listen', CollectingEvent::class, 90); $event = new CollectingEvent(); @@ -80,10 +87,11 @@ public function test_add_listener_service(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_add_listener_service_before_another(): void + #[Test] + public function add_listener_service_before_another(): void { $p = new OrderedListenerProvider($this->mockContainer); @@ -99,10 +107,11 @@ public function test_add_listener_service_before_another(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_add_listener_service_after_another(): void + #[Test] + public function add_listener_service_after_another(): void { $p = new OrderedListenerProvider($this->mockContainer); @@ -118,10 +127,11 @@ public function test_add_listener_service_after_another(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_service_registration_fails_without_container(): void + #[Test] + public function service_registration_fails_without_container(): void { $this->expectException(ContainerMissingException::class); @@ -131,7 +141,8 @@ public function test_service_registration_fails_without_container(): void } - public function test_add_subscriber() : void + #[Test] + public function add_subscriber() : void { $container = new MockContainer(); @@ -149,10 +160,17 @@ public function test_add_subscriber() : void $listener($event); } - $this->assertEquals('BCAEDF', implode($event->result())); + // We can't guarantee a stricter order than the instructions provided, so + // just check for those rather than a precise order. + $result = implode($event->result()); + self::assertTrue(strpos($result, 'B') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'C') < strpos($result, 'A')); + self::assertTrue(strpos($result, 'D') > strpos($result, 'A')); + self::assertTrue(strpos($result, 'F') > strpos($result, 'A')); } - public function test_malformed_subscriber_automatic_fails(): void + #[Test] + public function malformed_subscriber_automatic_fails(): void { $this->expectException(InvalidTypeException::class); @@ -160,14 +178,15 @@ public function test_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); } - public function test_malformed_subscriber_manual_fails(): void + #[Test] + public function malformed_subscriber_manual_fails(): void { $this->expectException(InvalidTypeException::class); @@ -184,7 +203,8 @@ public function test_malformed_subscriber_manual_fails(): void MockMalformedSubscriber::registerListenersDirect($proxy); } - public function test_malformed_subscriber_manual_before_fails(): void + #[Test] + public function malformed_subscriber_manual_before_fails(): void { $this->expectException(InvalidTypeException::class); @@ -201,7 +221,8 @@ public function test_malformed_subscriber_manual_before_fails(): void MockMalformedSubscriber::registerListenersBefore($proxy); } - public function test_malformed_subscriber_manual_after_fails(): void + #[Test] + public function malformed_subscriber_manual_after_fails(): void { $this->expectException(InvalidTypeException::class); @@ -217,4 +238,95 @@ public function test_malformed_subscriber_manual_after_fails(): void MockMalformedSubscriber::registerListenersAfter($proxy); } + + #[Test, DataProvider('detection_class_examples')] + public function detects_invoke_method_and_type(string $class): void + { + $container = new MockContainer(); + + $container->addService($class, new $class()); + + $provider = new OrderedListenerProvider($container); + + $provider->listenerService($class); + + $event = new CollectingEvent(); + + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + + self::assertEquals($class, $event->result()[0]); + } + + public static function detection_class_examples(): iterable + { + return [ + [Listeners\InvokableListener::class], + [Listeners\ArbitraryListener::class], + [Listeners\CompoundListener::class], + ]; + } + + #[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 detects_invoke_method_and_gives_class_id_by_default(): void + { + $container = new MockContainer(); + $container->addService(InvokableListenerClassNoId::class, new InvokableListenerClassNoId('beep')); + + $provider = new OrderedListenerProvider($container); + + $id = $provider->listenerService(InvokableListenerClassNoId::class); + + self::assertEquals(InvokableListenerClassNoId::class, $id); + } + + #[Test] + public function rejects_multi_method_class_without_invoke(): void + { + $this->expectException(ServiceRegistrationTooManyMethods::class); + $container = new MockContainer(); + + $container->addService(Listeners\InvalidListener::class, new Listeners\InvalidListener()); + + $provider = new OrderedListenerProvider($container); + + $provider->listenerService(Listeners\InvalidListener::class); + } + + #[Test] + public function rejects_missing_auto_detected_service(): void + { + $this->expectException(ServiceRegistrationClassNotExists::class); + $container = new MockContainer(); + + $provider = new OrderedListenerProvider($container); + + // @phpstan-ignore-next-line + $provider->listenerService(DoesNotExist::class); + } + } diff --git a/tests/OrderedListenerProviderTest.php b/tests/OrderedListenerProviderTest.php index 04e835f..820d114 100644 --- a/tests/OrderedListenerProviderTest.php +++ b/tests/OrderedListenerProviderTest.php @@ -4,26 +4,24 @@ namespace Crell\Tukio; - +use Crell\Tukio\Events\CollectingEvent; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -class EventOne extends CollectingEvent {} - -class EventTwo extends CollectingEvent {} - class OrderedListenerProviderTest extends TestCase { - public function test_only_type_correct_listeners_are_returned(): void + #[Test] + public function only_type_correct_listeners_are_returned(): void { $p = new OrderedListenerProvider(); - $p->addListener(function (EventOne $event) { + $p->addListener(function (Events\EventOne $event) { $event->add('Y'); }); $p->addListener(function (CollectingEvent $event) { $event->add('Y'); }); - $p->addListener(function (EventTwo $event) { + $p->addListener(function (Events\EventTwo $event) { $event->add('N'); }); // This class doesn't exist but should not result in an error. @@ -33,16 +31,17 @@ public function test_only_type_correct_listeners_are_returned(): void $event->add('F'); }); - $event = new EventOne(); + $event = new Events\EventOne(); foreach ($p->getListenersForEvent($event) as $listener) { $listener($event); } - $this->assertEquals('YY', implode($event->result())); + self::assertEquals('YY', implode($event->result())); } - public function test_add_ordered_listeners(): void + #[Test] + public function add_ordered_listeners(): void { $p = new OrderedListenerProvider(); @@ -68,28 +67,19 @@ public function test_add_ordered_listeners(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_add_listener_before(): void + #[Test] + public function add_listener_before(): void { $p = new OrderedListenerProvider(); - $p->addListener(function (CollectingEvent $event) { - $event->add('E'); - }, 0); - $rid = $p->addListener(function (CollectingEvent $event) { - $event->add('R'); - }, 90); - $p->addListener(function (CollectingEvent $event) { - $event->add('L'); - }, 0); - $p->addListenerBefore($rid, function (CollectingEvent $event) { - $event->add('C'); - }); - $p->addListener(function (CollectingEvent $event) { - $event->add('L'); - }, 0); + $p->addListener(fn (CollectingEvent $event) => $event->add('A'), 0, id: 'A'); + $bid = $p->addListener(fn (CollectingEvent $event) => $event->add('B'), 90, id: 'B'); + $p->addListener(fn (CollectingEvent $event) => $event->add('C'), -5, id: 'C'); + $p->addListenerBefore($bid, fn (CollectingEvent $event) => $event->add('D'), id: 'D'); + $p->addListener(fn (CollectingEvent $event) => $event->add('E'), 0, id: 'E'); $event = new CollectingEvent(); @@ -97,10 +87,19 @@ public function test_add_listener_before(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + $result = implode($event->result()); + self::assertTrue(strpos($result, 'A') < strpos($result, 'C')); + self::assertTrue(strpos($result, 'A') > strpos($result, 'B')); + self::assertTrue(strpos($result, 'B') < strpos($result, 'C')); + self::assertTrue(strpos($result, 'D') < strpos($result, 'B')); + self::assertTrue(strpos($result, 'B') < strpos($result, 'E')); + self::assertTrue(strpos($result, 'E') < strpos($result, 'C')); + +// self::assertEquals('CRELL', implode($event->result())); } - public function test_add_listener_after(): void + #[Test] + public function add_listener_after(): void { $p = new OrderedListenerProvider(); @@ -109,7 +108,7 @@ public function test_add_listener_after(): void }, 90); $p->addListener(function (CollectingEvent $event) { $event->add('L'); - }, 0); + }, -5); $p->addListenerBefore($rid, function (CollectingEvent $event) { $event->add('C'); }); @@ -126,10 +125,11 @@ public function test_add_listener_after(): void $listener($event); } - $this->assertEquals('CRELL', implode($event->result())); + self::assertEquals('CRELL', implode($event->result())); } - public function test_add_malformed_listener(): void + #[Test] + public function add_malformed_listener(): void { $this->expectException(InvalidTypeException::class); @@ -140,7 +140,8 @@ public function test_add_malformed_listener(): void }); } - public function test_add_malformed_listener_before(): void + #[Test] + public function add_malformed_listener_before(): void { $this->expectException(InvalidTypeException::class); @@ -154,7 +155,8 @@ public function test_add_malformed_listener_before(): void }); } - public function test_add_malformed_listener_after(): void + #[Test] + public function add_malformed_listener_after(): void { $this->expectException(InvalidTypeException::class);