From 372917d9852fc35d78232e39842f9d948df69d63 Mon Sep 17 00:00:00 2001 From: Olivier Laviale Date: Sun, 11 Jul 2021 21:02:42 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 15 + .github/workflows/code-style.yml | 32 ++ .github/workflows/static-analysis.yml | 32 ++ .github/workflows/test.yml | 48 ++ .gitignore | 4 + CONTRIBUTING.md | 32 ++ Dockerfile | 29 + LICENSE | 30 + Makefile | 31 ++ README.md | 526 ++++++++++++++++++ composer.json | 43 ++ docker-compose.yml | 11 + phpcs.xml | 7 + phpstan.neon | 5 + phpunit.xml | 22 + src/BasicEventDispatcher.php | 52 ++ src/BufferedEventDispatcher.php | 87 +++ src/BufferedEventDispatcherInterface.php | 25 + src/ListenerProviderChain.php | 70 +++ src/ListenerProviderFilter.php | 57 ++ src/ListenerProviderWithContainer.php | 54 ++ src/ListenerProviderWithMap.php | 47 ++ src/MutableListenerProvider.php | 97 ++++ src/MutableListenerProviderInterface.php | 43 ++ src/Symfony/ListenerProviderPass.php | 235 ++++++++ tests/BasicEventDispatcherTest.php | 76 +++ tests/BufferedEventDispatcherTest.php | 43 ++ tests/ListenerProviderChainTest.php | 85 +++ tests/ListenerProviderFilterTest.php | 53 ++ tests/ListenerProviderWithContainerTest.php | 51 ++ tests/ListenerProviderWithMapTest.php | 75 +++ tests/MutableListenerProviderTest.php | 136 +++++ tests/RecorderEventDispatcher.php | 30 + tests/SampleEventA.php | 14 + tests/SampleEventB.php | 14 + tests/SampleEventC.php | 14 + tests/SampleEventInterface.php | 14 + tests/SampleStoppableEvent.php | 30 + tests/Symfony/ListenerProviderPassTest.php | 251 +++++++++ tests/Symfony/SampleListenerA1.php | 19 + tests/Symfony/SampleListenerA2.php | 19 + tests/Symfony/SampleListenerB.php | 19 + tests/Symfony/SampleListenerC.php | 19 + tests/Symfony/SampleListenerM.php | 17 + tests/Symfony/config-with-customization.yml | 20 + tests/Symfony/config-with-defaults.yml | 28 + .../config-with-invalid-event-type.yml | 12 + .../config-with-invalid-priority-1.yml | 12 + .../config-with-invalid-priority-2.yml | 12 + .../config-with-missing-event-type.yml | 12 + .../config-with-missing-listener-class.yml | 12 + .../config-with-multiple-providers.yml | 39 ++ tests/Symfony/config-with-priorities.yml | 88 +++ tests/functions.php | 26 + 54 files changed, 2874 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/code-style.yml create mode 100644 .github/workflows/static-analysis.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 phpcs.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/BasicEventDispatcher.php create mode 100644 src/BufferedEventDispatcher.php create mode 100644 src/BufferedEventDispatcherInterface.php create mode 100644 src/ListenerProviderChain.php create mode 100644 src/ListenerProviderFilter.php create mode 100644 src/ListenerProviderWithContainer.php create mode 100644 src/ListenerProviderWithMap.php create mode 100644 src/MutableListenerProvider.php create mode 100644 src/MutableListenerProviderInterface.php create mode 100644 src/Symfony/ListenerProviderPass.php create mode 100644 tests/BasicEventDispatcherTest.php create mode 100644 tests/BufferedEventDispatcherTest.php create mode 100644 tests/ListenerProviderChainTest.php create mode 100644 tests/ListenerProviderFilterTest.php create mode 100644 tests/ListenerProviderWithContainerTest.php create mode 100644 tests/ListenerProviderWithMapTest.php create mode 100644 tests/MutableListenerProviderTest.php create mode 100644 tests/RecorderEventDispatcher.php create mode 100644 tests/SampleEventA.php create mode 100644 tests/SampleEventB.php create mode 100644 tests/SampleEventC.php create mode 100644 tests/SampleEventInterface.php create mode 100644 tests/SampleStoppableEvent.php create mode 100644 tests/Symfony/ListenerProviderPassTest.php create mode 100644 tests/Symfony/SampleListenerA1.php create mode 100644 tests/Symfony/SampleListenerA2.php create mode 100644 tests/Symfony/SampleListenerB.php create mode 100644 tests/Symfony/SampleListenerC.php create mode 100644 tests/Symfony/SampleListenerM.php create mode 100644 tests/Symfony/config-with-customization.yml create mode 100644 tests/Symfony/config-with-defaults.yml create mode 100644 tests/Symfony/config-with-invalid-event-type.yml create mode 100644 tests/Symfony/config-with-invalid-priority-1.yml create mode 100644 tests/Symfony/config-with-invalid-priority-2.yml create mode 100644 tests/Symfony/config-with-missing-event-type.yml create mode 100644 tests/Symfony/config-with-missing-listener-class.yml create mode 100644 tests/Symfony/config-with-multiple-providers.yml create mode 100644 tests/Symfony/config-with-priorities.yml create mode 100644 tests/functions.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..099e761 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.yml] +indent_size = 2 + +[Dockerfile] +indent_style = tab diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..e878b85 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,32 @@ +name: code-style + +on: +- push +- pull_request + +jobs: + phpstan: + name: PHPCS + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "7.2" + ini-values: memory_limit=-1 + tools: composer:v2, phpcs + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.composer/cache + vendor + key: vendor + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Run PHPCS + run: phpcs diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..2ea0425 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,32 @@ +name: static-analysis + +on: +- push +- pull_request + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "7.2" + ini-values: memory_limit=-1 + tools: composer:v2 + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.composer/cache + vendor + key: phpstan-deps + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b874ddc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: test + +on: +- push +- pull_request + +jobs: + phpunit: + name: phpunit + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: + - "7.2" + - "7.4" + - "8.0" + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + coverage: xdebug + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1 + tools: composer:v2 + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: | + ~/.composer/cache + vendor + key: "php-${{ matrix.php-version }}" + restore-keys: "php-${{ matrix.php-version }}" + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Run PHPUnit + run: make test-coveralls + + - name: Upload code coverage + if: ${{ matrix.php-version == '7.2' }} + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer global require php-coveralls/php-coveralls + php-coveralls --coverage_clover=build/logs/clover.xml -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4edb3d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.phpunit.result.cache +build +composer.lock +vendor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c41acc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/olvlvl/event-dispatcher). + +## Pull Requests + +- **Code style** — We're following [PSR-12 Coding Standard][]. Check the code style with `make lint`. +- **Code health** — We're using [PHPStan][] to analyse the code, with maximum scrutiny. Check the code with `make lint`. +- **Add tests!** — Your contribution won't be accepted if it doesn't have tests. +- **Document any change in behaviour** — Make sure the `README.md` and any other relevant documentation are kept + up-to-date. +- **Consider our release cycle** — We follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not + an option. +- **Create feature branches** — We won't pull from your main branch. +- **One pull request per feature** — If you want to do more than one thing, send multiple pull requests. +- **Send coherent history** — Make sure each individual commit in your pull request is meaningful. If you had to make + multiple intermediate commits while developing, please [squash them][git-squash] before submitting. + +## Running Tests + +We provide a Docker container for local development. Run `make test-container` to create a new session. Inside the +container run `make test` to run the test suite. Alternatively, run `make test-coverage` for a breakdown of the code +coverage. The coverage report is available in `build/coverage/index.html`. + +**Thanks for your contribution**! + + +[PSR-12 Coding Standard]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-coding-style-guide.md +[git-squash]: http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages +[PHPStan]: https://phpstan.org/user-guide/getting-started diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c36f4d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM php:7.2-cli-buster + +RUN apt-get update && \ + apt-get install -y autoconf pkg-config && \ + pecl channel-update pecl.php.net && \ + pecl install xdebug && \ + docker-php-ext-enable opcache xdebug + +RUN echo '\ +xdebug.client_host=host.docker.internal\n\ +xdebug.mode=develop\n\ +xdebug.start_with_request=yes\n\ +' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +RUN echo '\ +display_errors=On\n\ +error_reporting=E_ALL\n\ +date.timezone=UTC\n\ +' >> /usr/local/etc/php/conf.d/php.ini + +ENV COMPOSER_ALLOW_SUPERUSER 1 + +RUN apt-get update && \ + apt-get install unzip && \ + curl -s https://raw.githubusercontent.com/composer/getcomposer.org/76a7060ccb93902cd7576b67264ad91c8a2700e2/web/installer | php -- --quiet && \ + mv composer.phar /usr/local/bin/composer && \ + echo 'export PATH="$HOME/.composer/vendor/bin:$PATH"\n' >> /root/.bashrc + +RUN composer global require squizlabs/php_codesniffer diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5bd18c9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +The olvlvl/event-dispatcher package is free software. +It is released under the terms of the following BSD License. + +Copyright (c) 2021 by Olivier Laviale +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name of Olivier Laviale nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..75a343e --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +PHPUNIT = vendor/bin/phpunit + +vendor: + composer install + +.PHONY: test-dependencies +test-dependencies: vendor + +.PHONY: test +test: test-dependencies + @$(PHPUNIT) + +.PHONY: test-coverage +test-coverage: test-dependencies + @mkdir -p build/coverage + @XDEBUG_MODE=coverage $(PHPUNIT) --coverage-html build/coverage + +.PHONY: test-coveralls +test-coveralls: test-dependencies + @mkdir -p build/logs + @XDEBUG_MODE=coverage $(PHPUNIT) --coverage-clover build/logs/clover.xml + +.PHONY: test-container +test-container: + @-docker-compose run --rm app bash + @docker-compose down -v + +.PHONY: lint +lint: + @phpcs + @vendor/bin/phpstan diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7458be --- /dev/null +++ b/README.md @@ -0,0 +1,526 @@ +# olvlvl/event-dispatcher + +[![Release](https://img.shields.io/packagist/v/olvlvl/event-dispatcher.svg)](https://packagist.org/packages/olvlvl/event-dispatcher) +[![Packagist](https://img.shields.io/packagist/dt/olvlvl/event-dispatcher.svg)](https://packagist.org/packages/olvlvl/event-dispatcher) +[![Code Quality](https://img.shields.io/scrutinizer/g/olvlvl/event-dispatcher.svg)](https://scrutinizer-ci.com/g/olvlvl/event-dispatcher) +[![Code Coverage](https://img.shields.io/coveralls/olvlvl/event-dispatcher.svg)](https://coveralls.io/r/olvlvl/event-dispatcher) + +`olvlvl/event-dispatcher` provides an implementation of [psr/event-dispatcher][], which establishes a common mechanism +for event-based extension and collaboration. + +#### Package highlights + +- Supports Event inheritance, including interfaces. +- Supports stoppable Events. +- Provides a collection of composable Event Dispatchers and Listener Providers. +- Introduces Mutable Listener Providers. +- Provides a compiler pass for [symfony/dependency-injection][]. + +#### Installation + +```bash +composer require olvlvl/event-dispatcher +``` + + + +## Event Dispatcher + +An Event Dispatcher is a service object that is given an Event object by an Emitter. The Dispatcher is responsible for +ensuring that the Event is passed to all relevant Listeners, but MUST defer determining the responsible listeners to a +Listener Provider. + + + +### Basic Event Dispatcher + +`BasicEventDispatcher` is a basic implementation of an Event Dispatcher, that complies +with the [requirements for Dispatchers][dispatcher]. + +```php +dispatch($event); +``` + + + +### Buffered Event Dispatcher + +In some situations, it can be desired to defer the dispatching of events. For instance, an application that's presenting +an API to create recipes, and needs to index created recipes and run additional time-consuming calculations, would want +to defer dispatching the events, to reply as soon as possible to the user. + +`BufferedEventDispatcher` decorates an Event Dispatcher and buffers events that can be dispatched at a later time. The +user can provide a discriminator that decides whether an event should be buffered or dispatched immediately. + +**Careful using this type of Dispatcher!** Because event dispatching is delayed, it will cause issues for users that +expect Events to be modified. + +**Note:** In accordance with [Dispatchers requirements][dispatcher], stopped Events are discarded and not be buffered. + +```php +dispatch($eventA); +// $eventB is dispatched immediately +$dispatcher->dispatch($eventB); +$dispatcher->dispatch($eventC); + +// ... Some code here, maybe reply to a request. + +$dispatchedEvents = $dispatcher->dispatchBufferedEvents(); +``` + + + +## Listener Provider + +A Listener Provider is responsible for determining what Listeners are relevant to and should be called for a given +Event. `olvlvl/event-dispatcher` provides a few Listener Provider implementations, that comply with +the [requirements and recommendations for Listener Providers][listener-provider]. + + + +### Listener Provider with a map + +`ListenerProviderWithMap` is a Listener Provider that uses an array of Event/Listeners pairs. + +```php + [ $callableA ], + MyEventInterfaceA::class => [ $callableB, $callableC ], + +]); +``` + + + +### Listener Provider with a container + +`ListenerProviderWithContainer` is a Listener Provider that uses an array of Event/service id pairs and retrieves +Listeners from a [PSR container][psr/container]. + +**Note:** `olvlvl/event-dispatcher` provides [a compiler pass][#compiler-pass] for [symfony/dependency-injection][] that +is very handy to collect Event Listeners and build Listener Providers. + +```php + [ 'serviceA' ], + SampleEventInterfaceA::class => [ 'serviceA', 'serviceB' ], + +], $container); +``` + + + +### Mutable Listener Provider + +`MutableListenerProvider` is a mutable Listener Provider, that is, listeners can be added and removed. To this effect, +the Provider has no constructor arguments so that any Listener it contains can also be removed. + +The Listener Provider implements `MutableListenerProviderInterface`, which extends `ListenerProviderInterface`. The +interface can be used to distinguish a mutable Listener Provider from a non-mutable one. + +```php +appendListenerForEvent( + SampleEvent::class, + function (SampleEvent $event) use (&$remove): void { + // This is how one can implement a "once" listener. + // The listener is removed when it's called. + $remove(); + // ... do something with the event here. + } +); +``` + + + + +### Listener Provider Chain + +With `ListenerProviderChain`, multiple Listener Providers can be combined to act like one. They are called in succession +to provide Listeners for an Event. + +The chain is mutable, Listener Providers can be added to the end of the chain using the `appendListenerProviders()` +method, or to the beginning of the chain using the `prependListenerProviders()` method. + +**Note:** Since `ListenerProviderChain` is a Provider Listener like any other, creating a chain of chains is a +possibility. + +The following example demonstrates how to create a chain of Listener Providers, and modify that chain by appending and +prepending others. + +```php +appendListenerProviders($providerD, $providerE); + +// Listener Providers can be added to the beginning of the chain. + +/* @var $providerF Psr\EventDispatcher\ListenerProviderInterface */ +/* @var $providerG Psr\EventDispatcher\ListenerProviderInterface */ + +$provider->prependListenerProviders($providerF, $providerG); + +// Obtain the Listeners for an event + +/* @var object $event */ + +foreach ($provider->getListenersForEvent($event) as $listener) { + // ... do something with the listeners +} +``` + + + +### Listener Provider Filter + +`ListenerProviderFilter` decorates a Listener Provider to filter Listeners according to a user specified discriminator. +The filter can be used to implement some form of access control so that certain Listeners will only be called if the +current user has a certain permission. + +The following example demonstrates how the filter can be used to discard `$listener_1` for `SampleEventA` +and `$listener_2` for `SampleEventC`. + +```php + [ $listener_1, $listener_2 ], + SampleEventC::class => [ $listener_1, $listener_2 ], + ]), + function (object $event, callable $listener) use ($listener_1, $listener_2): bool { + if ($event instanceof SampleEventA && $listener === $listener_1) { + return false; + } + + if ($event instanceof SampleEventC && $listener === $listener_2) { + return false; + } + + return true; + } +); +``` + + + +## Compiler pass for symfony/dependency-injection + +The package provides a compiler pass for [symfony/dependency-injection][] that builds one or many Listener Providers +automatically. + +Basically, the compiler pass searches for the tagged services, collect their Event Listeners, creates a mapping with +their events, and overwrite a few attributes to complete the definition of the service. + + + +### Adding the compiler pass + +```php +addCompilerPass(new ListenerProviderPass()); +``` + +By default, the tag used to identify the Listener Providers to build is `listener_provider`, but it can be configured: + +```php +addCompilerPass(new ListenerProviderPass('my_listener_provider_tag')); +``` + + + +### Defining the services + +The following example uses the PSR interface as service identifier, but a name such as `my_listener_provider` can be +used just the same, as we'll see later when building multiple Listener Providers. Also, it is not required to specify +the `synthetic` attribute, but it is recommended to indicate to fellow developers that the service definition is a stub. + +**Note:** To complete the service definition, the compiler pass overwrites the attributes `synthetic`, `class`, +and `arguments`, but leaves intact any other attribute. + +```yaml +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: [ listener_provider ] +``` + +By default, the tag for the Listener services is `event_listener` but it can be configured, which is required when +building multiple Listener Providers. + +```yaml +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: + - { name: listener_provider, listener_tag: event_listener } +``` + +The following example demonstrates own Listener services are attached to a Listener Provider. They are tagged +with `event_listener`, which is the default tag. A listener can listen to multiple events, as is demonstrated +by `ListenerC`. + +```yaml +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: [ listener_provider ] + + Acme\MyApp\ListenerA: + tags: + - { name: event_listener, event: Acme\MyApp\EventA } + + Acme\MyApp\ListenerB: + tags: + - { name: event_listener, event: Acme\MyApp\EventB } + + # ListenerC listens to EventA and EventC + Acme\MyApp\ListenerC: + tags: + - { name: event_listener, event: Acme\MyApp\EventA } + - { name: event_listener, event: Acme\MyApp\EventC } +``` + + + +### Building multiple Listener Providers + +It is possible to build multiple Listener Providers, you just need to specify which Listener tag to use for each of +them: + +```yaml +services: + listener_provider_a: + class: Psr\EventDispatcher\ListenerProviderInterface + synthetic: true + tags: + - { name: listener_provider, listener_tag: event_listener_for_a } + + listener_provider_b: + class: Psr\EventDispatcher\ListenerProviderInterface + synthetic: true + tags: + - { name: listener_provider, listener_tag: event_listener_for_b } + + Acme\MyApp\ListenerA1: + tags: + - { name: event_listener_for_a, event: Acme\MyApp\EventA } + + Acme\MyApp\ListenerA2: + tags: + - { name: event_listener_for_a, event: Acme\MyApp\EventA } + + Acme\MyApp\ListenerB: + tags: + - { name: event_listener_for_b, event: Acme\MyApp\EventB } + + # ListenerM is used by both Providers A and B, + # but it will only receive EventC from Provider B + Acme\MyApp\ListenerM: + tags: + - { name: event_listener_for_a, event: Acme\MyApp\EventA } + - { name: event_listener_for_b, event: Acme\MyApp\EventA } + - { name: event_listener_for_b, event: Acme\MyApp\EventC } +``` + + + +### Specifying priorities + +If Listeners are spread over multiple files, or it's not practical to keep them ordered, priorities for each +Event/Listener pair can be defined instead. + +Valid priorities are integers, positive or negative, or one of the special values `first` and `last`. With these special +values, the Event/Listener pair is placed first or last, no matter the other priorities. Multiple Event/Listeners pairs +can use these special values, in which case, the effect stacks. In the case of equal priorities, the definition order is +preserved. + +**Note:** If not specified, the priority defaults to 0. + +The following example demonstrates how the `priority` attribute can be used to specify the order of Listeners. The final +order will be as follows: + +- For `SampleEventA`: `listener_e`, `listener_d`, `listener_c`, `listener_a`, `listener_b`. +- For `SampleEventB`: `listener_d`, `listener_b`. + +```yaml +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + public: true + tags: [ listener_provider ] + + listener_a: + class: SampleListener + tags: + - name: event_listener + event: SampleEventA + priority: -10 + + listener_b: + class: SampleListener + tags: + - name: event_listener + event: SampleEventA + priority: last + - name: event_listener + event: SampleEventB + + listener_c: + class: SampleListener + tags: + - name: event_listener + event: SampleEventA + + listener_d: + class: SampleListener + tags: + - name: event_listener + event: SampleEventA + priority: first + - name: event_listener + event: SampleEventB + priority: 10 + + listener_e: + class: SampleListener + tags: + - name: event_listener + event: SampleEventA + priority: first +``` + + + +---------- + + + +## Continuous Integration + +The package is continuously tested by [GitHub actions](https://github.com/olvlvl/event-dispatcher/actions). + +[![Tests](https://github.com/olvlvl/event-dispatcher/workflows/test/badge.svg?branch=main)](https://github.com/olvlvl/event-dispatcher/actions?query=workflow%3Atest) +[![Static Analysis](https://github.com/olvlvl/event-dispatcher/workflows/static-analysis/badge.svg?branch=main)](https://github.com/olvlvl/event-dispatcher/actions?query=workflow%3Astatic-analysis) +[![Code Style](https://github.com/olvlvl/event-dispatcher/workflows/code-style/badge.svg?branch=main)](https://github.com/olvlvl/event-dispatcher/actions?query=workflow%3Acode-style) + + + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + + + +## License + +**olvlvl/event-dispatcher** is released under the [BSD-3-Clause](LICENSE). + + + +[#compiler-pass]: #compiler-pass-for-symfonydependency-injection +[listener-provider]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-14-event-dispatcher.md#listener-provider +[dispatcher]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-14-event-dispatcher.md#dispatcher +[psr/container]: https://www.php-fig.org/psr/psr-11/ +[psr/event-dispatcher]: https://www.php-fig.org/psr/psr-14/ +[symfony/dependency-injection]: https://github.com/symfony/dependency-injection diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aeaf585 --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "olvlvl/event-dispatcher", + "description": "PSR-14 Event Dispatcher implementation", + "type": "library", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Olivier Laviale", + "email": "olivier.laviale@gmail.com" + } + ], + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "config": { + "sort-packages": true + }, + "require": { + "php": ">=7.2", + "psr/event-dispatcher": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.91", + "phpunit/phpunit": "^8.5", + "psr/container": "^1.0", + "symfony/config": "^5.3", + "symfony/dependency-injection": "^5.3", + "symfony/yaml": "^5.3" + }, + "autoload": { + "psr-4": { + "olvlvl\\EventDispatcher\\": "src" + } + }, + "autoload-dev": { + "files": [ + "tests/functions.php" + ], + "psr-4": { + "tests\\olvlvl\\EventDispatcher\\": "tests" + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..20a8ebf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +--- +version: "3.2" +services: + app: + build: . + environment: + PHP_IDE_CONFIG: 'serverName=olvlvl-event-dispatcher' + volumes: + - .:/app:delegated + - ~/.composer:/root/.composer:delegated + working_dir: /app diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..c71a71c --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + src + tests + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1494f51 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: max + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ff9d2fa --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/BasicEventDispatcher.php b/src/BasicEventDispatcher.php new file mode 100644 index 0000000..cc189c4 --- /dev/null +++ b/src/BasicEventDispatcher.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\ListenerProviderInterface; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * A basic implementation of an Event Dispatcher. + */ +final class BasicEventDispatcher implements EventDispatcherInterface +{ + /** + * @var ListenerProviderInterface + */ + private $listenerProvider; + + public function __construct(ListenerProviderInterface $listenerProvider) + { + $this->listenerProvider = $listenerProvider; + } + + /** + * @inheritDoc + */ + public function dispatch(object $event): object + { + $stoppable = $event instanceof StoppableEventInterface; + if ($stoppable && $event->isPropagationStopped()) { + return $event; + } + + foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) { + $listener($event); + + // @phpstan-ignore-next-line + if ($stoppable && $event->isPropagationStopped()) { + break; + } + } + + return $event; + } +} diff --git a/src/BufferedEventDispatcher.php b/src/BufferedEventDispatcher.php new file mode 100644 index 0000000..ce00491 --- /dev/null +++ b/src/BufferedEventDispatcher.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\EventDispatcher\StoppableEventInterface; + +/** + * Decorates an Event Dispatcher to buffer events. + * + * Careful using this type of Dispatcher! Because the dispatching is delayed, it will cause issues for users that + * expect Events to be modified. + */ +final class BufferedEventDispatcher implements BufferedEventDispatcherInterface +{ + /** + * @var EventDispatcherInterface + */ + private $decorated; + + /** + * @var callable|null + * @phpstan-var callable(object):bool|null + */ + private $discriminator; + + /** + * @var object[] + */ + private $buffer = []; + + /** + * @param EventDispatcherInterface $decorated + * @param callable|null $discriminator Return `true` if the event should be buffered, `false` otherwise. + * + * @phpstan-param callable(object):bool|null $discriminator + */ + public function __construct( + EventDispatcherInterface $decorated, + callable $discriminator = null + ) { + $this->decorated = $decorated; + $this->discriminator = $discriminator; + } + + /** + * @inheritDoc + */ + public function dispatch(object $event): object + { + if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) { + return $event; + } + + if ($this->discriminator && !($this->discriminator)($event)) { + return $this->decorated->dispatch($event); + } + + $this->buffer[] = $event; + + return $event; + } + + /** + * @inheritDoc + */ + public function dispatchBufferedEvents(): array + { + $buffer = $this->buffer; + $this->buffer = []; + + foreach ($buffer as $event) { + $this->decorated->dispatch($event); + } + + // Since a Dispatcher MUST return the same Event object it was passed after it is done invoking Listeners, + // the buffer can be returned as is. + return $buffer; + } +} diff --git a/src/BufferedEventDispatcherInterface.php b/src/BufferedEventDispatcherInterface.php new file mode 100644 index 0000000..9392024 --- /dev/null +++ b/src/BufferedEventDispatcherInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\EventDispatcherInterface; + +/** + * An interface for Event Dispatchers that buffer Events. + */ +interface BufferedEventDispatcherInterface extends EventDispatcherInterface +{ + /** + * Dispatch the buffered Events. + * + * @return object[] The Events dispatched. + */ + public function dispatchBufferedEvents(): array; +} diff --git a/src/ListenerProviderChain.php b/src/ListenerProviderChain.php new file mode 100644 index 0000000..7000e30 --- /dev/null +++ b/src/ListenerProviderChain.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\ListenerProviderInterface; + +use function array_push; +use function array_unshift; + +/** + * A chain of Listener Providers. + * + * Listener Providers are called in succession to provide Listeners for an Event. + */ +final class ListenerProviderChain implements ListenerProviderInterface +{ + /** + * @var ListenerProviderInterface[] + */ + private $providers = []; + + /** + * @param ListenerProviderInterface[] $providers + */ + public function __construct(iterable $providers = []) + { + $this->appendListenerProviders(...$providers); + } + + /** + * Add Listener Providers to the end of the chain. + * + * Note: Listener Providers are appended to the chain just like `array_push` append values to an array. + */ + public function appendListenerProviders(ListenerProviderInterface ...$providers): void + { + array_push($this->providers, ...$providers); + } + + /** + * Add Listener Providers to the beginning of the chain. + * + * Note: Listener Providers are prepended to the chain just like `array_unshift` prepend values to an array. + */ + public function prependListenerProviders(ListenerProviderInterface ...$providers): void + { + array_unshift($this->providers, ...$providers); + } + + /** + * @inheritDoc + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->providers as $provider) { + foreach ($provider->getListenersForEvent($event) as $listener) { + yield $listener; + } + } + } +} diff --git a/src/ListenerProviderFilter.php b/src/ListenerProviderFilter.php new file mode 100644 index 0000000..6d85619 --- /dev/null +++ b/src/ListenerProviderFilter.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\ListenerProviderInterface; + +/** + * Decorates a Listener Provider to filter Listeners, according to a user specified discriminator. + */ +final class ListenerProviderFilter implements ListenerProviderInterface +{ + /** + * @var ListenerProviderInterface + */ + private $decorated; + + /** + * @var callable + * @phpstan-var callable(object $event, callable $listener):bool + */ + private $discriminator; + + /** + * @param ListenerProviderInterface $decorated + * @param callable $discriminator Return `false` to discarded a listener. + * + * @phpstan-param callable(object $event, callable $listener):bool $discriminator + */ + public function __construct( + ListenerProviderInterface $decorated, + callable $discriminator + ) { + $this->decorated = $decorated; + $this->discriminator = $discriminator; + } + + /** + * @inheritDoc + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->decorated->getListenersForEvent($event) as $listener) { + if (($this->discriminator)($event, $listener)) { + yield $listener; + } + } + } +} diff --git a/src/ListenerProviderWithContainer.php b/src/ListenerProviderWithContainer.php new file mode 100644 index 0000000..0ac5c4b --- /dev/null +++ b/src/ListenerProviderWithContainer.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\Container\ContainerInterface; +use Psr\EventDispatcher\ListenerProviderInterface; + +/** + * A Listener Provider that uses a PSR container to retrieve Listeners. + */ +final class ListenerProviderWithContainer implements ListenerProviderInterface +{ + /** + * @var array + */ + private $listeners; + + /** + * @var ContainerInterface + */ + private $container; + + /** + * @param array $listeners + */ + public function __construct(array $listeners, ContainerInterface $container) + { + $this->listeners = $listeners; + $this->container = $container; + } + + /** + * @inheritDoc + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->listeners as $class => $listeners) { + if ($event instanceof $class) { + foreach ($listeners as $listener) { + yield $this->container->get($listener); + } + } + } + } +} diff --git a/src/ListenerProviderWithMap.php b/src/ListenerProviderWithMap.php new file mode 100644 index 0000000..35ecdeb --- /dev/null +++ b/src/ListenerProviderWithMap.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use Psr\EventDispatcher\ListenerProviderInterface; + +/** + * A simple listener provider, that's using a mapping of event to callables. + */ +final class ListenerProviderWithMap implements ListenerProviderInterface +{ + /** + * @var array + */ + private $listeners; + + /** + * @param array $listeners + */ + public function __construct(array $listeners) + { + $this->listeners = $listeners; + } + + /** + * @inheritDoc + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->listeners as $class => $listeners) { + if ($event instanceof $class) { + foreach ($listeners as $listener) { + yield $listener; + } + } + } + } +} diff --git a/src/MutableListenerProvider.php b/src/MutableListenerProvider.php new file mode 100644 index 0000000..ddc2fb5 --- /dev/null +++ b/src/MutableListenerProvider.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use LogicException; + +use function array_search; +use function array_unshift; +use function in_array; + +final class MutableListenerProvider implements MutableListenerProviderInterface +{ + /** + * @var array> + */ + private $listeners = []; + + /** + * @inheritDoc + * + * @return iterable + */ + public function getListenersForEvent(object $event): iterable + { + foreach ($this->listeners as $class => $listeners) { + if ($event instanceof $class) { + foreach ($listeners as $listener) { + yield $listener; + } + } + } + } + + /** + * @inheritDoc + */ + public function appendListenerForEvent(string $eventType, callable $listener): callable + { + $this->assertUnique($eventType, $listener); + + $this->listeners[$eventType][] = $listener; + + return $this->makeRemoveCallback($eventType, $listener); + } + + /** + * @inheritDoc + */ + public function prependListenerForEvent(string $eventType, callable $listener): callable + { + $this->assertUnique($eventType, $listener); + + if (!isset($this->listeners[$eventType])) { + $this->listeners[$eventType] = []; + } + + array_unshift($this->listeners[$eventType], $listener); + + return $this->makeRemoveCallback($eventType, $listener); + } + + private function assertUnique(string $eventType, callable $listener): void + { + $listeners = $this->listeners[$eventType] ?? null; + + if (!$listeners) { + return; + } + + if (in_array($listener, $listeners, true)) { + throw new LogicException("Listener already defined for event type '$eventType'."); + } + } + + private function makeRemoveCallback(string $eventType, callable $listener): callable + { + return function () use ($eventType, $listener) { + $key = array_search($listener, $this->listeners[$eventType], true); + + if ($key === false) { + // The Listener has already been removed. + // It's not great that the user is removing the listener twice, + // but it's not critical since the result is the same. + return; + } + + unset($this->listeners[$eventType][$key]); + }; + } +} diff --git a/src/MutableListenerProviderInterface.php b/src/MutableListenerProviderInterface.php new file mode 100644 index 0000000..03c821b --- /dev/null +++ b/src/MutableListenerProviderInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher; + +use LogicException; +use Psr\EventDispatcher\ListenerProviderInterface; + +/** + * An interface for a Listener Provider that can be mutated. + */ +interface MutableListenerProviderInterface extends ListenerProviderInterface +{ + /** + * Add a Listener for an Event at the end of the list, + * and return a callable that can be used to remove the Listener. + * + * @param class-string $eventType The class or interface of an Event. + * @param callable(object):void $listener + * + * @return callable():void + * @throws LogicException if the Listener is already defined for that Event. + */ + public function appendListenerForEvent(string $eventType, callable $listener): callable; + + /** + * Add a Listener for an Event at the beginning of the list, + * and return a callable that can be used to remove the Listener. + * + * @param class-string $eventType The class or interface of an Event. + * @param callable(object):void $listener + * + * @return callable():void + * @throws LogicException if the Listener is already defined for that Event. + */ + public function prependListenerForEvent(string $eventType, callable $listener): callable; +} diff --git a/src/Symfony/ListenerProviderPass.php b/src/Symfony/ListenerProviderPass.php new file mode 100644 index 0000000..ed63b86 --- /dev/null +++ b/src/Symfony/ListenerProviderPass.php @@ -0,0 +1,235 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace olvlvl\EventDispatcher\Symfony; + +use olvlvl\EventDispatcher\ListenerProviderWithContainer; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\TypedReference; + +use function array_filter; +use function array_flip; +use function array_values; +use function class_exists; +use function interface_exists; +use function is_int; +use function max; +use function min; +use function usort; + +/** + * A compilation pass for event listeners. + */ +final class ListenerProviderPass implements CompilerPassInterface +{ + public const DEFAULT_PROVIDER_TAG = 'listener_provider'; + public const DEFAULT_LISTENER_TAG = 'event_listener'; + public const DEFAULT_PRIORITY = 0; + public const ATTRIBUTE_LISTENER_TAG = 'listener_tag'; + public const ATTRIBUTE_EVENT = 'event'; + public const ATTRIBUTE_PRIORITY = 'priority'; + public const PRIORITY_FIRST = 'first'; + public const PRIORITY_LAST = 'last'; + + /** + * @var string + */ + private $providerTag; + + /** + * @param string $providerTag Tag identifying listener providers. + */ + public function __construct(string $providerTag = self::DEFAULT_PROVIDER_TAG) + { + $this->providerTag = $providerTag; + } + + /** + * @inheritDoc + */ + public function process(ContainerBuilder $container): void + { + foreach ($this->providerIterator($container) as $id => $listenerTag) { + [ $mapping, $refMap ] = $this->collectListeners($container, $listenerTag); + + $container + ->getDefinition($id) + ->setSynthetic(false) + ->setClass(ListenerProviderWithContainer::class) + ->setArguments( + [ + $mapping, + ServiceLocatorTagPass::register($container, $refMap), + ] + ); + } + } + + /** + * @return iterable + */ + private function providerIterator(ContainerBuilder $container): iterable + { + foreach ($container->findTaggedServiceIds($this->providerTag, true) as $id => $tags) { + $listener_tag = $tags[0][self::ATTRIBUTE_LISTENER_TAG] ?? self::DEFAULT_LISTENER_TAG; + + yield $id => $listener_tag; + } + } + + /** + * @return array{0: array, 1: array} + */ + private function collectListeners(ContainerBuilder $container, string $listenerTag): array + { + $handlers = $container->findTaggedServiceIds($listenerTag, true); + $mapping = []; + $refMap = []; + $prioritiesByEvent = []; + + foreach ($handlers as $id => $tags) { + foreach ($tags as $tag) { + $class = $container->getDefinition($id)->getClass(); + + if (!$class) { + throw new InvalidArgumentException("Missing class for listener '$id'."); + } + + $event = $this->extractEvent($tag, $id); + $mapping[$event][] = $id; + $refMap[$id] = new TypedReference($id, $class); + $prioritiesByEvent[$event][$id] = $this->extractPriority($tag, $id); + } + } + + return [ + $this->sortMapping($mapping, $prioritiesByEvent), + $refMap, + ]; + } + + /** + * @param array $tag + * + * @return class-string + */ + private function extractEvent(array $tag, string $id): string + { + $event = $tag[self::ATTRIBUTE_EVENT] ?? null; + + if (!$event) { + $attribute = self::ATTRIBUTE_EVENT; + + throw new InvalidArgumentException( + "Missing event type for listener '$id'." + . " Try to specify the event using the attribute '$attribute'." + ); + } + + if (!class_exists($event) && !interface_exists($event)) { + throw new InvalidArgumentException( + "Unable to load event class or interface '$event' for listener '$id'." + ); + } + + return $event; + } + + /** + * @param array $tag + * + * @return int|string + */ + private function extractPriority(array $tag, string $id) + { + $priority = $tag[self::ATTRIBUTE_PRIORITY] ?? self::DEFAULT_PRIORITY; + + if ($priority !== self::PRIORITY_FIRST && $priority !== self::PRIORITY_LAST && !is_int($priority)) { + throw new InvalidArgumentException( + "Invalid priority value for listener '$id': $priority." + . " Valid values are 'first', 'last', or an integer." + ); + } + + return $priority; + } + + /** + * @param array $mapping + * @param array> $prioritiesByEvent + * + * @return array + */ + private function sortMapping(array $mapping, array $prioritiesByEvent): array + { + foreach ($mapping as $event => &$ids) { + $ids = $this->sortListeners( + $ids, + $this->resolvePriorities($prioritiesByEvent[$event]) + ); + } + + return $mapping; + } + + /** + * @param array $priorities + * + * @return array + */ + private function resolvePriorities(array $priorities): array + { + $numericPriorities = array_filter($priorities, 'is_int'); + $min = min($numericPriorities); + $max = max($numericPriorities); + + foreach ($priorities as &$priority) { + if ($priority === self::PRIORITY_FIRST) { + $priority = ++$max; + } elseif ($priority === self::PRIORITY_LAST) { + $priority = --$min; + } + } + + // @phpstan-ignore-next-line + return $priorities; + } + + /** + * @param string[] $ids + * @param int[] $priorities + * + * @return string[] + */ + private function sortListeners(array $ids, array $priorities): array + { + $positions = array_flip($ids); + + usort( + $ids, + function (string $a, string $b) use ($positions, $priorities): int { + $pa = $priorities[$a]; + $pb = $priorities[$b]; + + if ($pa === $pb) { + // Same priority. Let's compare the original orders, which are ascending. + return $positions[$a] <=> $positions[$b]; + } + + // Priorities are descending. + return $pb <=> $pa; + } + ); + + return array_values($ids); + } +} diff --git a/tests/BasicEventDispatcherTest.php b/tests/BasicEventDispatcherTest.php new file mode 100644 index 0000000..daec348 --- /dev/null +++ b/tests/BasicEventDispatcherTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\BasicEventDispatcher; +use olvlvl\EventDispatcher\ListenerProviderWithMap; +use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\ListenerProviderInterface; + +final class BasicEventDispatcherTest extends TestCase +{ + public function testDispatch(): void + { + $called = 0; + + $listenerProvider = new ListenerProviderWithMap([ + SampleEventA::class => [ + function () use (&$called): void { + $called++; + }, + function () use (&$called): void { + $called++; + }, + ] + ]); + + $event = new SampleEventA(); + $dispatcher = new BasicEventDispatcher($listenerProvider); + $this->assertSame($event, $dispatcher->dispatch($event)); + $this->assertEquals(2, $called); + } + + public function testDispatchStoppableEvent(): void + { + $called = false; + + $listenerProvider = new ListenerProviderWithMap([ + SampleStoppableEvent::class => [ + function () use (&$called): void { + $called = true; + }, + function (SampleStoppableEvent $event): void { + $event->stopped = true; + }, + function (): void { + $this->fail("should not be called"); + }, + ] + ]); + + $event = new SampleStoppableEvent(); + $dispatcher = new BasicEventDispatcher($listenerProvider); + $this->assertSame($event, $dispatcher->dispatch($event)); + $this->assertTrue($called); + $this->assertTrue($event->stopped); + } + + public function testDispatchStoppableEventAlreadyStopped(): void + { + $event = new SampleStoppableEvent(); + $event->stopped = true; + + $listenerProvider = $this->createMock(ListenerProviderInterface::class); + $listenerProvider->expects($this->never())->method('getListenersForEvent'); + + $dispatcher = new BasicEventDispatcher($listenerProvider); + $this->assertSame($event, $dispatcher->dispatch($event)); + } +} diff --git a/tests/BufferedEventDispatcherTest.php b/tests/BufferedEventDispatcherTest.php new file mode 100644 index 0000000..cae77c5 --- /dev/null +++ b/tests/BufferedEventDispatcherTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\BufferedEventDispatcher; +use PHPUnit\Framework\TestCase; + +final class BufferedEventDispatcherTest extends TestCase +{ + public function testDispatch(): void + { + $recorder = new RecorderEventDispatcher(); + $dispatcher = new BufferedEventDispatcher( + $recorder, + function (object $event): bool { + return !$event instanceof SampleEventB; + } + ); + + $event1 = new SampleEventA(); + $event2 = new SampleEventB(); // should be dispatched immediately + $event3 = new SampleEventC(); + $stoppedEvent = new SampleStoppableEvent(true); // should be discarded + + $this->assertSame($event1, $dispatcher->dispatch($event1)); + $this->assertSame($event2, $dispatcher->dispatch($event2)); + $this->assertSame($event3, $dispatcher->dispatch($event3)); + $this->assertSame($stoppedEvent, $dispatcher->dispatch($stoppedEvent)); + $this->assertSame([ $event2 ], $recorder->events); + + $dispatched = $dispatcher->dispatchBufferedEvents(); + $this->assertSame([ $event1, $event3 ], $dispatched); + $this->assertSame([ $event2, $event1, $event3 ], $recorder->events); + $this->assertCount(0, $dispatcher->dispatchBufferedEvents()); + } +} diff --git a/tests/ListenerProviderChainTest.php b/tests/ListenerProviderChainTest.php new file mode 100644 index 0000000..ee35106 --- /dev/null +++ b/tests/ListenerProviderChainTest.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\ListenerProviderWithMap; +use olvlvl\EventDispatcher\ListenerProviderChain; +use PHPUnit\Framework\TestCase; + +final class ListenerProviderChainTest extends TestCase +{ + public function testGetListenersForEvent(): void + { + $chain = new ListenerProviderChain([ + new ListenerProviderWithMap([ + SampleEventA::class => [ + $f1 = function () { + }, + $f2 = function () { + } + ] + ]), + new ListenerProviderWithMap([ + SampleEventC::class => [ + $f3 = function () { + } + ] + ]) + ]); + + $chain->appendListenerProviders( + new ListenerProviderWithMap([ + SampleEventA::class => [ + $f4 = function () { + } + ] + ]), + new ListenerProviderWithMap([ + SampleEventA::class => [ + $f5 = function () { + } + ] + ]), + new ListenerProviderWithMap([ + SampleEventC::class => [ + $f6 = function () { + } + ] + ]) + ); + + $chain->prependListenerProviders( + new ListenerProviderWithMap([ + SampleEventA::class => [ + $f7 = function () { + }, + $f8 = function () { + } + ] + ]), + new ListenerProviderWithMap([ + SampleEventC::class => [ + $f9 = function () { + } + ] + ]) + ); + + $this->assertSame( + [ $f7, $f8, $f1, $f2, $f4, $f5 ], + iterable_to_array($chain->getListenersForEvent(new SampleEventA())) + ); + + $this->assertSame( + [ $f9, $f3, $f6 ], + iterable_to_array($chain->getListenersForEvent(new SampleEventC())) + ); + } +} diff --git a/tests/ListenerProviderFilterTest.php b/tests/ListenerProviderFilterTest.php new file mode 100644 index 0000000..d7c06ce --- /dev/null +++ b/tests/ListenerProviderFilterTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\ListenerProviderFilter; +use olvlvl\EventDispatcher\ListenerProviderWithMap; +use PHPUnit\Framework\TestCase; + +final class ListenerProviderFilterTest extends TestCase +{ + public function testGetListenersForEvent(): void + { + $listener_1 = function () { + }; + $listener_2 = function () { + }; + + $provider = new ListenerProviderFilter( + new ListenerProviderWithMap([ + SampleEventA::class => [ $listener_1, $listener_2 ], + SampleEventC::class => [ $listener_1, $listener_2 ], + ]), + function (object $event, callable $listener) use ($listener_1, $listener_2): bool { + if ($event instanceof SampleEventA && $listener === $listener_1) { + return false; + } + + if ($event instanceof SampleEventC && $listener === $listener_2) { + return false; + } + + return true; + } + ); + + $this->assertSame( + [ $listener_2 ], + iterable_to_array($provider->getListenersForEvent(new SampleEventA())) + ); + + $this->assertSame( + [ $listener_1 ], + iterable_to_array($provider->getListenersForEvent(new SampleEventC())) + ); + } +} diff --git a/tests/ListenerProviderWithContainerTest.php b/tests/ListenerProviderWithContainerTest.php new file mode 100644 index 0000000..6bb0cdc --- /dev/null +++ b/tests/ListenerProviderWithContainerTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\ListenerProviderWithContainer; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +final class ListenerProviderWithContainerTest extends TestCase +{ + public function testGetListenersForEvent(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get') + ->withConsecutive( + [ 'serviceA1' ], + [ 'serviceA2' ], + [ 'serviceB1' ], + [ 'serviceB2' ] + )->willReturnOnConsecutiveCalls( + $a1 = function () { + }, + $a2 = function () { + }, + $b1 = function () { + }, + $b2 = function () { + } + ); + + $provider = new ListenerProviderWithContainer([ + + SampleEventA::class => [ 'serviceA1', 'serviceA2' ], + SampleEventB::class => [ 'serviceB1', 'serviceB2' ], + SampleEventC::class => [ 'serviceC1', 'serviceC2' ], + + ], $container); + + $this->assertSame( + [ $a1, $a2, $b1, $b2 ], + iterable_to_array($provider->getListenersForEvent(new SampleEventB())) + ); + } +} diff --git a/tests/ListenerProviderWithMapTest.php b/tests/ListenerProviderWithMapTest.php new file mode 100644 index 0000000..ff0bf18 --- /dev/null +++ b/tests/ListenerProviderWithMapTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use olvlvl\EventDispatcher\ListenerProviderWithMap; +use PHPUnit\Framework\TestCase; + +final class ListenerProviderWithMapTest extends TestCase +{ + /** + * @dataProvider provideGetListenersForEvent + */ + public function testGetListenersForEvent(object $event, string $expected): void + { + $rc = ''; + $stu = new ListenerProviderWithMap([ + + SampleEventA::class => [ + function () use (&$rc) { + $rc .= "f1"; + } + ], + SampleEventB::class => [ + function () use (&$rc) { + $rc .= "f2"; + } + ], + SampleEventInterface::class => [ + function () use (&$rc) { + $rc .= "f3"; + } + ], + + ]); + + foreach ($stu->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $this->assertSame($expected, $rc); + } + + /** + * @phpstan-ignore-next-line + */ + public function provideGetListenersForEvent(): array + { + return [ + + "match class" => [ + new SampleEventA(), + "f1" + ], + + "match inheritance" => [ + new SampleEventB(), + "f1f2" + + ], + + "match interface" => [ + new SampleEventC(), + "f3" + ], + + ]; + } +} diff --git a/tests/MutableListenerProviderTest.php b/tests/MutableListenerProviderTest.php new file mode 100644 index 0000000..df9d25b --- /dev/null +++ b/tests/MutableListenerProviderTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use LogicException; +use olvlvl\EventDispatcher\MutableListenerProvider; +use PHPUnit\Framework\TestCase; + +final class MutableListenerProviderTest extends TestCase +{ + public function testAppendListenerForEventFailsOnDuplicateListener(): void + { + $listener = function () { + }; + $stu = new MutableListenerProvider(); + $stu->appendListenerForEvent(SampleEventA::class, $listener); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches("/Listener already defined/"); + $stu->appendListenerForEvent(SampleEventA::class, $listener); + } + + public function testPrependListenerForEventFailsOnDuplicateListener(): void + { + $listener = function () { + }; + $stu = new MutableListenerProvider(); + $stu->prependListenerForEvent(SampleEventA::class, $listener); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches("/Listener already defined/"); + $stu->prependListenerForEvent(SampleEventA::class, $listener); + } + + public function testAppendPrependRemove(): void + { + $stu = new MutableListenerProvider(); + $r1 = $stu->appendListenerForEvent(SampleEventA::class, $l1 = function () { + }); + $r2 = $stu->prependListenerForEvent(SampleEventA::class, $l2 = function () { + }); + $r3 = $stu->prependListenerForEvent(SampleEventA::class, $l3 = function () { + }); + $r4 = $stu->appendListenerForEvent(SampleEventA::class, $l4 = function () { + }); + $stu->appendListenerForEvent(SampleEventC::class, function () { + }); // Trap + $event = new SampleEventA(); + + $this->assertSame( + [ $l3, $l2, $l1, $l4 ], + iterable_to_array($stu->getListenersForEvent($event)) + ); + + $r2(); + $r2(); // called twice, shouldn't matter + $this->assertSame( + [ $l3, $l1, $l4 ], + iterable_to_array($stu->getListenersForEvent($event)) + ); + + $r1(); + $this->assertSame( + [ $l3, $l4 ], + iterable_to_array($stu->getListenersForEvent($event)) + ); + + $r3(); + $this->assertSame( + [ $l4 ], + iterable_to_array($stu->getListenersForEvent($event)) + ); + + $r4(); + $this->assertSame( + [], + iterable_to_array($stu->getListenersForEvent($event)) + ); + } + + /** + * @dataProvider provideGetListenersForEvent + */ + public function testGetListenersForEvent(object $event, string $expected): void + { + $rc = ''; + $stu = new MutableListenerProvider(); + $stu->appendListenerForEvent(SampleEventA::class, function () use (&$rc) { + $rc .= "f1"; + }); + $stu->appendListenerForEvent(SampleEventB::class, function () use (&$rc) { + $rc .= "f2"; + }); + $stu->appendListenerForEvent(SampleEventInterface::class, function () use (&$rc) { + $rc .= "f3"; + }); + + foreach ($stu->getListenersForEvent($event) as $listener) { + $listener($event); + } + + $this->assertSame($expected, $rc); + } + + /** + * @phpstan-ignore-next-line + */ + public function provideGetListenersForEvent(): array + { + return [ + + "match class" => [ + new SampleEventA(), + "f1", + ], + + "match inheritance" => [ + new SampleEventB(), + "f1f2", + ], + + "match interface" => [ + new SampleEventC(), + "f3", + ], + + ]; + } +} diff --git a/tests/RecorderEventDispatcher.php b/tests/RecorderEventDispatcher.php new file mode 100644 index 0000000..09131da --- /dev/null +++ b/tests/RecorderEventDispatcher.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use Psr\EventDispatcher\EventDispatcherInterface; + +final class RecorderEventDispatcher implements EventDispatcherInterface +{ + /** + * @var object[] + */ + public $events = []; + + /** + * @inheritDoc + */ + public function dispatch(object $event): object + { + $this->events[] = $event; + + return $event; + } +} diff --git a/tests/SampleEventA.php b/tests/SampleEventA.php new file mode 100644 index 0000000..eccad25 --- /dev/null +++ b/tests/SampleEventA.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +class SampleEventA +{ +} diff --git a/tests/SampleEventB.php b/tests/SampleEventB.php new file mode 100644 index 0000000..82b6358 --- /dev/null +++ b/tests/SampleEventB.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +class SampleEventB extends SampleEventA +{ +} diff --git a/tests/SampleEventC.php b/tests/SampleEventC.php new file mode 100644 index 0000000..dbfbdda --- /dev/null +++ b/tests/SampleEventC.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +class SampleEventC implements SampleEventInterface +{ +} diff --git a/tests/SampleEventInterface.php b/tests/SampleEventInterface.php new file mode 100644 index 0000000..3bf2142 --- /dev/null +++ b/tests/SampleEventInterface.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +interface SampleEventInterface +{ +} diff --git a/tests/SampleStoppableEvent.php b/tests/SampleStoppableEvent.php new file mode 100644 index 0000000..9ba5739 --- /dev/null +++ b/tests/SampleStoppableEvent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +use Psr\EventDispatcher\StoppableEventInterface; + +class SampleStoppableEvent implements StoppableEventInterface +{ + /** + * @var bool; + */ + public $stopped; + + public function __construct(bool $stopped = false) + { + $this->stopped = $stopped; + } + + public function isPropagationStopped(): bool + { + return $this->stopped; + } +} diff --git a/tests/Symfony/ListenerProviderPassTest.php b/tests/Symfony/ListenerProviderPassTest.php new file mode 100644 index 0000000..f18d3b8 --- /dev/null +++ b/tests/Symfony/ListenerProviderPassTest.php @@ -0,0 +1,251 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +use Exception; +use olvlvl\EventDispatcher\Symfony\ListenerProviderPass; +use PHPUnit\Framework\TestCase; +use Psr\EventDispatcher\ListenerProviderInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use tests\olvlvl\EventDispatcher\SampleEventA; +use tests\olvlvl\EventDispatcher\SampleEventB; +use tests\olvlvl\EventDispatcher\SampleEventC; + +use function assert; + +final class ListenerProviderPassTest extends TestCase +{ + /** + * @throws Exception + */ + public function testDefaults(): void + { + $container = $this->makeContainer('config-with-defaults.yml'); + $container->compile(); + + $provider = $container->get(ListenerProviderInterface::class); + + assert($provider instanceof ListenerProviderInterface); + + $this->assertSame([ + SampleListenerA1::class, + SampleListenerA2::class, + SampleListenerM::class, + ], $this->collectClasses($provider->getListenersForEvent(new SampleEventA()))); + + $this->assertSame([ + SampleListenerA1::class, + SampleListenerA2::class, + SampleListenerM::class, + SampleListenerB::class, + ], $this->collectClasses($provider->getListenersForEvent(new SampleEventB()))); + + $this->assertSame([ + SampleListenerC::class, + SampleListenerM::class, + ], $this->collectClasses($provider->getListenersForEvent(new SampleEventC()))); + } + + /** + * @throws Exception + */ + public function testMultipleListenerProviders(): void + { + $container = $this->makeContainer('config-with-multiple-providers.yml'); + $container->compile(); + + $providerA = $container->get('listener_provider_a'); + + assert($providerA instanceof ListenerProviderInterface); + + $this->assertSame([ + SampleListenerA1::class, + SampleListenerA2::class, + SampleListenerM::class, + ], $this->collectClasses($providerA->getListenersForEvent(new SampleEventA()))); + + $this->assertSame([ + SampleListenerA1::class, + SampleListenerA2::class, + SampleListenerM::class, + ], $this->collectClasses($providerA->getListenersForEvent(new SampleEventB()))); + + $this->assertSame([ + SampleListenerM::class, + ], $this->collectClasses($providerA->getListenersForEvent(new SampleEventC()))); + + $providerB = $container->get('listener_provider_b'); + + assert($providerB instanceof ListenerProviderInterface); + + $this->assertNotSame($providerA, $providerB); + + $this->assertSame([ + SampleListenerM::class, + ], $this->collectClasses($providerB->getListenersForEvent(new SampleEventA()))); + + $this->assertSame([ + SampleListenerB::class, + SampleListenerM::class, + ], $this->collectClasses($providerB->getListenersForEvent(new SampleEventB()))); + + $this->assertSame([ + SampleListenerC::class, + SampleListenerM::class, + ], $this->collectClasses($providerB->getListenersForEvent(new SampleEventC()))); + } + + /** + * @throws Exception + */ + public function testCustomized(): void + { + $container = $this->makeContainer('config-with-customization.yml', new ListenerProviderPass( + 'my_listener_provider' + )); + $container->compile(); + + $provider = $container->get('lp'); + + assert($provider instanceof ListenerProviderInterface); + + $this->assertSame([ + SampleListenerA1::class, + SampleListenerB::class, + ], $this->collectClasses($provider->getListenersForEvent(new SampleEventB()))); + } + + /** + * @throws Exception + * + * @dataProvider provideInvalidPriority + */ + public function testInvalidPriority(string $config): void + { + $container = $this->makeContainer($config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid priority value for listener/'); + $container->compile(); + } + + /** + * @phpstan-ignore-next-line + */ + public function provideInvalidPriority(): array + { + return [ + + [ 'config-with-invalid-priority-1.yml' ], + [ 'config-with-invalid-priority-2.yml' ], + + ]; + } + + /** + * @throws Exception + */ + public function testPriorities(): void + { + $container = $this->makeContainer('config-with-priorities.yml'); + $container->compile(); + + $this->assertSame([ + SampleEventA::class => [ + 'listener_i', // first 2nd + 'listener_e', // first 1st + 'listener_a', // 10 1st + 'listener_b', // 0 1st + 'listener_c', // 0 2nd + 'listener_j', // 0 3rd + 'listener_g', // -10 1st + 'listener_h', // -10 2nd + 'listener_d', // last 1st + 'listener_f', // last 2nd + ], + SampleEventB::class => [ + 'listener_l', + 'listener_j', + 'listener_k', + ] + ], $container->getDefinition(ListenerProviderInterface::class)->getArgument(0)); + } + + /** + * @throws Exception + */ + public function testMissingListenerClass(): void + { + $container = $this->makeContainer('config-with-missing-listener-class.yml'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/Missing class for listener/"); + + $container->compile(); + } + + /** + * @throws Exception + */ + public function testInvalidEventType(): void + { + $container = $this->makeContainer('config-with-invalid-event-type.yml'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/Unable to load event class or interface/"); + + $container->compile(); + } + + /** + * @throws Exception + */ + public function testMissingEventType(): void + { + $container = $this->makeContainer('config-with-missing-event-type.yml'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/Missing event type for listener/"); + + $container->compile(); + } + + /** + * @throws Exception + */ + private function makeContainer(string $config, ListenerProviderPass $pass = null): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->addCompilerPass($pass ?? new ListenerProviderPass()); + $loader = new YamlFileLoader($container, new FileLocator(__DIR__)); + $loader->load($config); + + return $container; + } + + /** + * @param object[] $objects + * + * @return class-string[] + */ + private function collectClasses(iterable $objects): array + { + $ar = []; + + foreach ($objects as $object) { + $ar[] = get_class($object); + } + + return $ar; + } +} diff --git a/tests/Symfony/SampleListenerA1.php b/tests/Symfony/SampleListenerA1.php new file mode 100644 index 0000000..3da7a34 --- /dev/null +++ b/tests/Symfony/SampleListenerA1.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +use tests\olvlvl\EventDispatcher\SampleEventA; + +final class SampleListenerA1 +{ + public function __invoke(SampleEventA $event): void + { + } +} diff --git a/tests/Symfony/SampleListenerA2.php b/tests/Symfony/SampleListenerA2.php new file mode 100644 index 0000000..fae051e --- /dev/null +++ b/tests/Symfony/SampleListenerA2.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +use tests\olvlvl\EventDispatcher\SampleEventA; + +final class SampleListenerA2 +{ + public function __invoke(SampleEventA $event): void + { + } +} diff --git a/tests/Symfony/SampleListenerB.php b/tests/Symfony/SampleListenerB.php new file mode 100644 index 0000000..c4042bc --- /dev/null +++ b/tests/Symfony/SampleListenerB.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +use tests\olvlvl\EventDispatcher\SampleEventB; + +final class SampleListenerB +{ + public function __invoke(SampleEventB $event): void + { + } +} diff --git a/tests/Symfony/SampleListenerC.php b/tests/Symfony/SampleListenerC.php new file mode 100644 index 0000000..9e4a5a7 --- /dev/null +++ b/tests/Symfony/SampleListenerC.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +use tests\olvlvl\EventDispatcher\SampleEventInterface; + +final class SampleListenerC +{ + public function __invoke(SampleEventInterface $event): void + { + } +} diff --git a/tests/Symfony/SampleListenerM.php b/tests/Symfony/SampleListenerM.php new file mode 100644 index 0000000..cc31f05 --- /dev/null +++ b/tests/Symfony/SampleListenerM.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher\Symfony; + +final class SampleListenerM +{ + public function __invoke(object $event): void + { + } +} diff --git a/tests/Symfony/config-with-customization.yml b/tests/Symfony/config-with-customization.yml new file mode 100644 index 0000000..70abe5a --- /dev/null +++ b/tests/Symfony/config-with-customization.yml @@ -0,0 +1,20 @@ +services: + lp: + synthetic: true + public: true + tags: + - { name: my_listener_provider, listener_tag: listener } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerB: + tags: + - { name: listener, event: tests\olvlvl\EventDispatcher\SampleEventB } + + listener.multi: # It's a trap! We're looking for 'listener' + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerM + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventC } diff --git a/tests/Symfony/config-with-defaults.yml b/tests/Symfony/config-with-defaults.yml new file mode 100644 index 0000000..14ac2f0 --- /dev/null +++ b/tests/Symfony/config-with-defaults.yml @@ -0,0 +1,28 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + public: true + tags: [ listener_provider ] + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA2: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerB: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventB } + + listener.c: # to check that the listener's class is resolved properly + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerC + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventInterface } + + listener.multi: # one listener for multiple events + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerM + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventC } diff --git a/tests/Symfony/config-with-invalid-event-type.yml b/tests/Symfony/config-with-invalid-event-type.yml new file mode 100644 index 0000000..8a1fc6b --- /dev/null +++ b/tests/Symfony/config-with-invalid-event-type.yml @@ -0,0 +1,12 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: [ listener_provider ] + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA2: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\UndefinedSampleEvent } diff --git a/tests/Symfony/config-with-invalid-priority-1.yml b/tests/Symfony/config-with-invalid-priority-1.yml new file mode 100644 index 0000000..8ea74c8 --- /dev/null +++ b/tests/Symfony/config-with-invalid-priority-1.yml @@ -0,0 +1,12 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + public: true + tags: [ listener_provider ] + + listener_a: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: madonna diff --git a/tests/Symfony/config-with-invalid-priority-2.yml b/tests/Symfony/config-with-invalid-priority-2.yml new file mode 100644 index 0000000..8e49c9b --- /dev/null +++ b/tests/Symfony/config-with-invalid-priority-2.yml @@ -0,0 +1,12 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + public: true + tags: [ listener_provider ] + + listener_a: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: 3.5 diff --git a/tests/Symfony/config-with-missing-event-type.yml b/tests/Symfony/config-with-missing-event-type.yml new file mode 100644 index 0000000..a38406a --- /dev/null +++ b/tests/Symfony/config-with-missing-event-type.yml @@ -0,0 +1,12 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: [ listener_provider ] + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA2: + tags: + - { name: event_listener } diff --git a/tests/Symfony/config-with-missing-listener-class.yml b/tests/Symfony/config-with-missing-listener-class.yml new file mode 100644 index 0000000..d4aed92 --- /dev/null +++ b/tests/Symfony/config-with-missing-listener-class.yml @@ -0,0 +1,12 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + tags: [ listener_provider ] + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: event_listener, event: tests\olvlvl\EventDispatcher\SampleEventA } + + listener.a2: # Missing listener class + tags: + - { name: event_listener } diff --git a/tests/Symfony/config-with-multiple-providers.yml b/tests/Symfony/config-with-multiple-providers.yml new file mode 100644 index 0000000..3cf23e5 --- /dev/null +++ b/tests/Symfony/config-with-multiple-providers.yml @@ -0,0 +1,39 @@ +services: + listener_provider_a: + class: Psr\EventDispatcher\ListenerProviderInterface + synthetic: true + public: true + tags: + - { name: listener_provider, listener_tag: event_listener_for_a } + + listener_provider_b: + class: Psr\EventDispatcher\ListenerProviderInterface + synthetic: true + public: true + tags: + - { name: listener_provider, listener_tag: event_listener_for_b } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1: + tags: + - { name: event_listener_for_a, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerA2: + tags: + - { name: event_listener_for_a, event: tests\olvlvl\EventDispatcher\SampleEventA } + + tests\olvlvl\EventDispatcher\Symfony\SampleListenerB: + tags: + - { name: event_listener_for_b, event: tests\olvlvl\EventDispatcher\SampleEventB } + + listener.c: # to check that the listener's class is resolved properly + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerC + tags: + - { name: event_listener_for_b, event: tests\olvlvl\EventDispatcher\SampleEventInterface } + + listener.multi: # one listener for multiple events, used by multiple listener providers + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerM + tags: + - { name: event_listener_for_a, event: tests\olvlvl\EventDispatcher\SampleEventA } + - { name: event_listener_for_b, event: tests\olvlvl\EventDispatcher\SampleEventA } + - { name: event_listener_for_a, event: tests\olvlvl\EventDispatcher\SampleEventC } + - { name: event_listener_for_b, event: tests\olvlvl\EventDispatcher\SampleEventC } diff --git a/tests/Symfony/config-with-priorities.yml b/tests/Symfony/config-with-priorities.yml new file mode 100644 index 0000000..a614a4e --- /dev/null +++ b/tests/Symfony/config-with-priorities.yml @@ -0,0 +1,88 @@ +services: + Psr\EventDispatcher\ListenerProviderInterface: + synthetic: true + public: true + tags: [ listener_provider ] + + listener_a: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: 10 + + listener_b: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + + listener_c: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + + listener_d: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: last + + listener_e: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: first + + listener_f: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: last + + listener_g: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: -10 + + listener_h: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: -10 + + listener_i: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerA1 + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + priority: first + + listener_j: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerB + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventA + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventB + + listener_k: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerB + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventB + priority: last + + listener_l: + class: tests\olvlvl\EventDispatcher\Symfony\SampleListenerB + tags: + - name: event_listener + event: tests\olvlvl\EventDispatcher\SampleEventB + priority: first diff --git a/tests/functions.php b/tests/functions.php new file mode 100644 index 0000000..261dae1 --- /dev/null +++ b/tests/functions.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace tests\olvlvl\EventDispatcher; + +/** + * @param mixed[] $it + * + * @return mixed[] + */ +function iterable_to_array(iterable $it): array +{ + $ar = []; + + foreach ($it as $v) { + $ar[] = $v; + } + + return $ar; +}