diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0a88d..f003183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,123 @@ All notable changes to this project will be documented in this file, in reverse - [#72](https://github.com/zendframework/zend-eventmanager/pull/72) adds support for PHP 7.3. +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) adds interfaces to allow duck-typing the EventManager as a [PSR-14](https://www.php-fig.org/psr/psr-14/) event + dispatcher. Full support is not provided yet as this version still supports + PHP 5.6. The new interfaces include: + + - `Zend\EventManager\EventDispatcherInterface` + - `Zend\EventManager\ListenerProvider\ListenerProviderInterface` + - `Zend\EventManager\StoppableEventInterface` + + These interfaces will be removed in version 4.0, in favor of the official + PSR-14 interfaces. + +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) adds the following interfaces: + - `Zend\EventManager\EventDispatchingInterface`, for indicating a class + composes an `EventDispatcherInterface` instance. This interface will replace + the `Zend\EventManager\EventsCapableInterface` in version 4.0. + - `Zend\Expressive\ListenerProvider\PrioritizedListenerProviderInterface`, + which extends the `ListenerProviderInterface`, and adds the method + `getListenersForEventByPriority($event, $identifiers = [])`. This method + will return a list of integer priority keys mapping to lists of callable + listeners. + - `Zend\Expressive\ListenerProvider\PrioritizedListenerAttachmentInterface`, + which provides methods for attaching and detaching listeners with optional + priority values. This interface largely replaces the various related methods + in the current `EventManagerInterface`, and is for use with listener + providers. + - `Zend\Expressive\ListenerProvider\ListenerSubscriberInterface`, for + indicating that a class can attach multiple listeners to a + `PrioritizedListenerAttachmentInterface` instance. This largely replaces the + current `ListenerAggregateInterface` functionality. Users should likely use + the PSR-14 utility package's `DelegatingProvider` instead, however. + +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) adds the following listener provider classes and utilities: + - `AbstractListenerSubscriber` and `ListenerSubscriberTrait` can be used to + provide a generic way to detach subscribers. In most cases, + `ListenerSubscriberInterface` implementations should define their own logic + for doing so. + - `PrioritizedListenerProvider` implements `PrioritizedListenerProviderInterface` + and `PrioritizedListenerAttachmentInterface` in order to provide the various + listener attachment and retrieval capabilities in previous versions of the + `EventManager` class. + - `PrioritizedIdentifierListenerProvider` implements `PrioritizedListenerProviderInterface` + and `SharedEventManagerInterface`, and provides all features of the + `SharedEventManager` class from previous versions of the package. + - `PrioritizedAggregateListenerProvider` implements `PrioritizedListenerProviderInterface` + and accepts a list of `PrioritizedListenerProviderInterface` instances and + optionally a generic `ListenerProviderInterface` instance to its + constructor. When retrieving listeners, it will loop through the + `PrioritizedListenerProviderInterface` instance in order, yielding from + each, and then, if present, yield from the generic + `ListenerProviderInterface` instance. This approach essentially replaces the + listener and shared listener aggregation in previous versions of the + `EventManager`. + - `LazyListener` combines the functionalities of `Zend\EventManager\LazyListener` + and `Zend\EventManager\LazyEventListener`. If no event or priority are + provided to the constructor, than the `getEvent()` and `getPriority()` + methods will each return `null`. When invoked, the listener will pull the + specified service from the provided DI container, and then invoke it. + - `LazyListenerSubscriber` implements `ListenerSubscriberInterface` and + accepts a list of `LazyListener` instances to its constructor; any + non-`LazyListener` instances or any that do not define an event will cause + th constructor to raise an exception. When its `attach()` method is called, + it attaches the lazy listeners based on the event an priority values it + pulls from them. + +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) adds the static method `createUsingListenerProvider()` to the `EventManager` + class. This method takes a `ListenerProviderInterface`, and will then pull + directly from it when triggering events. If the provider also implements + `PrioritizedListenerAttachmentInterface`, the various listener attachment + methods defined in `EventManager` will proxy to it. + +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) adds the static method `createUsingListenerProvider()` to the `EventManager` + ### Changed -- Nothing. +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) modifies the `SharedEventManager` class to extend the new + `Zend\EventManager\ListenerProvider\PrioritizedIdentifierListenerProvider` class. + +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) modifies the `EventManager` class as follows: + - It now implements each of `ListenerProviderInterface` and + `PrioritizedListenerAttachmentInterface`. + - If constructed normally, it will create a `PrioritizedListenerProvider` + instance, and use that for all listener attachment. If a + `SharedEventManagerInterface` is provided, it will create a + `PrioritizedAggregateListenerProvider` using its own + `PrioritizedListenerProvider` and the shared manager, and use that for + fetching listeners. + - Adds a `dispatch()` method as an alternative to the various `trigger*()` methods. ### Deprecated -- Nothing. +- [#73](https://github.com/zendframework/zend-eventmanager/pull/73) deprecates the following interfaces and classes: + - `Zend\EventManager\EventInterface`. Users should start using vanilla PHP + objects that encapsulate all expected behavior for setting and retrieving + values and otherwise mutating state, including how and when propagation of the + event should stop. + - `Zend\EventManager\EventManagerInterface`; start typehinting against the + PSR-14 `EventDispatcherInterface` (or, in the meantime, the package-specific + variant). + - `Zend\EventManager\EventManagerAwareInterface` + - `Zend\EventManager\EventManagerAwareTrait` + - `Zend\EventManager\EventsCapableInterface`; start using `EventDispatchingInterface` instead. + - `Zend\EventManager\SharedEventManager`; start using listener providers + instead, attaching to identifiers based on event types. + - `Zend\EventManager\SharedEventManagerInterface` + - `Zend\EventManager\SharedEventsCapableInterface` + - `Zend\EventManager\ListenerAggregateInterface`; use the new `ListenerSubscriberInterface` instead. + - `Zend\EventManager\ListenerAggregateTrait`; use the new + `ListenerSubscriberTrait`, or define your own detachment logic. + - `Zend\EventManager\AbstractListenerAggregate`; use the new + `AbstractListenerSubscriber`, or define your own detachment logic. + - `Zend\EventManager\ResponseCollection`; aggregate state in the event itself, + and have the event determine when propagation needs to stop. + - `Zend\EventManager\LazyListener`; use `Zend\EventManager\ListenerProvider\LazyListener` instead. + - `Zend\EventManager\LazyEventListener`; use `Zend\EventManager\ListenerProvider\LazyListener` instead. + - `Zend\EventManager\LazyListenerAggregate`; use `Zend\EventManager\ListenerProvider\LazyListenerSubscriber` instead. + - `Zend\EventManager\FilterChain` and the `Filter` subnamespace; these will + move to a separate package in the future. ### Removed diff --git a/TODO-PSR-14.md b/TODO-PSR-14.md new file mode 100644 index 0000000..7685835 --- /dev/null +++ b/TODO-PSR-14.md @@ -0,0 +1,194 @@ +# TODO for PSR-14 implementation + +## 3.3.0 forwards-compatibility release + +- [x] `StoppableEventInterface` implementation + - [x] Create a `StoppableEventInterface` + - [x] Make `Event` implement it + - [x] Deprecate `propagationIsStopped()` in both `EventInterface` and `Event` + - [x] Have `Event::propagationIsStopped()` proxy to `Event::isPropagationStopped()` + - [x] Modify `EventManager` internals to use the PSR-14 method if available + - [x] Mark `StoppableEventInterface` as deprecated +- [ ] Listener provider implementation + - [x] Create a `ListenerProvider` subnamespace + - [x] Create a `ListenerProviderInterface` shim + - [x] Create a `PrioritizedListenerProvider` interface extending the + `ListenerProviderInterface` and defining a + `getListenersForEventByPriority($event, array $identifiers = []) : array` method. + - [x] Create a `PrioritizedListenerAttachmentInterface`, defining: + - [x] `attach($event, callable $listener, $priority = 1)` (where `$event` + can be an object or string name) + - [x] `detach(callable $listener, $event = null, $force = false)` (where `$event` + can be an object or string name and `$force` is boolean) + - [x] `attachWildcardListener(callable $listener, $priority = 1)` + (`attach('*', $listener, $priority)` will proxy to this method) + - [x] `detachWildcardListener(callable $listener, $force = false)` + (`detach($listener, '*', $force)` will proxy to this method) + - [x] `clearListeners($event)` + - [x] Create a `PrioritizedListenerProvider` implementation of the above based + on the internals of `EventManager` + - [x] attachment/detachment + - [x] getListenersForEvent should take into account event name if an EventInterface + - [x] getListenersForEvent should also pull wildcard listeners + - [x] getListenersForEvent should accept an optional second argument, an + array of identifiers. This method will return all listeners in prioritized + order. + - [x] implement `getListenersForEventByPriority` + - [x] Create a `PrioritizedIdentifierListenerProvider` that implements + both the `PrioritizedListenerProvider` interface and the + `SharedEventManagerInterface` + - [x] implement `getListenersForEventByPriority` + - [x] `SharedEventManager` will extend this class + - [x] mark as deprecated (will not use this in v4) + - [x] Create a `PrioritizedAggregateListenerProvider` implementation + - [x] Accepts a list of `PrioritizedListenerProvider` instances + - [x] `getListenersByEvent()` will loop through each, in order, calling the + `getListenersForEventByPriority()` method of each, returning the + aggregated listeners in priority order. + - [x] Make `SharedEventManager` an extension of `PrioritizedIdentifierListenerProvider` + - [x] Create `ListenerSubscriberInterface` + - [x] `attach(PrioritizedListenerAttachmentInterface $provider, $priority = 1)` + - [x] `detach(PrioritizedListenerAttachmentInterface $provider)` + - [x] Create `AbstractListenerSubscriber` and/or `ListenerSubscriberTrait` + - [x] define a default `detach()` implementation + - [x] Create `LazyListenerSubscriber` based on `LazyListenerAggregate` + - [x] Define an alternate LazyListener: + - [x] `__construct(ContainerInterface $container, string $event = null, int $priority = 1)` + - [x] implements functionality from both `LazyListener` and `LazyEventListener`, minus passing env to container + - [x] without an event, can be attached to any provider + - [x] with an event, can be attached to `LazyListenerSubscriber` + - [x] Constructor aggregates `LazyListener` _instances_ only + - [x] raises exception when `getEvent()` returns null +- [x] Adapter for SharedEventManagerInterface + Since we type-hint on SharedEventManagerInterface, we need to adapt generic + implementations to work as ListenerProviders. + - [x] Class that adapts SharedEventManagerInterface instances to ListenerProviders +- [x] Event Dispatcher implementation + - [x] Implement `PrioritizedListenerAttachmentInterface` (if BC) + - [x] Implement `ListenerProviderInterface` (if BC) + - [x] Create a `PrioritizedListenerProvider` instance in the `EventManger` + constructor + - [x] Decorate it in a `PrioritizedAggregateListenerProvider` + - [x] Have the various `attach()`, `detach()`, etc. methods proxy to it. + - [x] Adapt any provided `SharedEventManagerInterface` instance, and add it + to the `PrioritizedAggregateListenerProvider` + - [x] Create a named constructor that accepts a listener provider and which + then uses it internally. + - [x] If the instance is a `PrioritizedListenerAttachmentInterface` + instance, allow the attach/detach/clear methods to proxy to it. + - [x] When triggering listeners, create a `PrioritizedAggregateListenerProvider` + with the composed `PrioritizedListenerProvider` and `SharedListenerProvider` / + `PrioritizedIdentifierListenerProvider` implementations, in that order. + - [x] Replace logic of `triggerListeners()` to just call + `getListenersForEvent()` on the provider. It can continue to aggregate the + responses in a `ResponseCollection` + - [x] `triggerListeners()` no longer needs to type-hint its first argument + - [x] Create a `dispatch()` method + - [x] Method will act like `triggerEvent()`, except + - [x] it will return the event itself + - [x] it will need to validate that it received an object before calling + `triggerListeners` +- [x] Additional utilities + - [x] `EventDispatchingInterface` with a `getEventDispatcher()` method +- [x] Deprecations + - [x] `EventInterface` + - [x] `EventManagerInterface` + - [x] `EventManagerAwareInterface` + - [x] `EventManagerAwareTrait` + - [x] `EventsCapableInterface` (point people to `EventDispatchingInterface`) + - [x] `SharedEventManager` + - [x] `SharedEventManagerInterface` + - [x] `SharedEventsCapableInterface` + - [x] `ListenerAggregateInterface` (point people to the `PrioritizedListenerAttachmentInterface`) + - [x] `ListenerAggregateTrait` (point people to `ListenerSubscriberTrait`) + - [x] `AbstractListenerAggregate` (point people to `AbstractListenerSubscriber` and/or `ListenerSubscriberTrait`) + - [x] `ResponseCollection` (tell people to aggregate state/results in the event itself) + - [x] `LazyListener` (point people to `ListenerProvider\LazyListener`) + - [x] `LazyEventListener` (point people to `ListenerProvider\LazyListener`) + - [x] `LazyListenerAggregate` (point people to `ListenerProvider\LazyListenerSubscriber`) + - [x] `FilterChain` and `Filter` subnamespace (this should be done in a separate component) + +## 4.0.0 full release + +- [ ] Removals + - [ ] `EventInterface` + - [ ] `EventManagerInterface` + - [ ] `EventManagerAwareInterface` + - [ ] `EventManagerAwareTrait` + - [ ] `EventsCapableInterface` + - [ ] `SharedEventManager` + - [ ] `SharedEventManagerInterface` + - [ ] `SharedEventsCapableInterface` + - [ ] `ListenerAggregateInterface` + - [ ] `ListenerAggregateTrait` + - [ ] `AbstractListenerAggregate` + - [ ] `ResponseCollection` + - [ ] `LazyListener` + - [ ] `LazyEventListener` + - [ ] `LazyListenerAggregate` + - [ ] `FilterChain` and `Filter` subnamespace + - [ ] `StoppableEventInterface` (will use PSR-14 version) + - [ ] `ListenerProviderInterface` (will use PSR-14 version) + - [ ] `PrioritizedIdentifierListenerProvider` +- Changes + - [ ] `PrioritizedListenerAttachmentInterface` (and implementations) + - [ ] extend PSR-14 `ListenerProviderInterface` + - [ ] add `string` typehint to `$event` in `attach()` and `detach()` + - [ ] add `bool` typehint to `$force` argument of `detach()` + - [ ] `PrioritizedListenerProvider` interface (and implementations) + - [ ] Fulfill PSR-14 `ListenerProviderInterface` + - [ ] remove `$identifiers` argument to getListenersForEventByPriority and getListenersForEvent + - [ ] add `object` typehint to `getListenersForEventByPriority` + - [ ] `EventDispatcher` + - [ ] implement PSR-14 `EventDispatcherInterface` + +## Concerns + +### MVC + +Currently, the MVC relies heavily on: + +- event names (vs types) +- event targets +- event params +- `stopPropagation($flag)` (vs custom stop conditions in events) +- `triggerEventUntil()` (vs custom stop conditions in events) + +We would need to draw attention to usage of methods that are not specific to an +event implementation, and recommend usage of other methods where available. +(We would likely keep the params implementation, however, to allow passing +messages via the event instance(s).) + +Additionally, we will need to have some sort of event hierarchy: + +- a base MVC event from which all others derive. This will be necessary to + ensure that existing code continues to work. +- a BootstrapEvent +- a RouteEvent +- a DispatchEvent + - a DispatchControllerEvent +- a DispatchErrorEvent + - Potentially broken into a RouteUnmatchedEvent, DispatchExceptionEvent, + MiddlewareExceptionEvent, ControllerNotFoundEvent, InvalidControllerEvent, + and InvalidMiddlewareEvent +- a RenderEvent +- a RenderErrorEvent +- a FinishEvent +- a SendResponseEvent (this one is not an MvcEvent, however) + +The event names associated with each would be based on existing event names, +allowing the ability to attach using legacy names OR the class name. + +We can allow using `stopPropagation()`, but have it trigger a deprecation +notice, asking users to use more specific methods of the event to stop +propagation, or, in the case of errors, raising exceptions. + +- `setError()` would cause `isPropagationStopped()` to return true. +- A new method, `setFinalResponse()` would both set the response instance, as + well as cause `isPropagationStopped()` to return true. +- The `RouteEvent` would also halt propagation when `setRouteResult()` is + called. + +Internally, we will also stop using the `*Until()` methods, and instead rely on +the events to handle this for us. If we need a return value, we will instead +pull it from the event on completion. diff --git a/composer.json b/composer.json index 2d39cf9..1ab62f2 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "phpbench/phpbench": "^0.13", "phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.2", "zendframework/zend-stdlib": "^2.7.3 || ^3.0", - "container-interop/container-interop": "^1.1.0", + "container-interop/container-interop": "^1.2.0", "zendframework/zend-coding-standard": "~1.0.0" }, "suggest": { diff --git a/composer.lock b/composer.lock index c12fb5b..6d74179 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "071997918aabea3d2d5493df994ee3aa", + "content-hash": "9cb47a2e1dda7467ed4d3b6099b726e6", "packages": [], "packages-dev": [ { diff --git a/src/AbstractListenerAggregate.php b/src/AbstractListenerAggregate.php index 5cb0f80..e9fd2d0 100644 --- a/src/AbstractListenerAggregate.php +++ b/src/AbstractListenerAggregate.php @@ -11,6 +11,11 @@ /** * Abstract aggregate listener + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0, in + * favor of the ListenerProvider\AbstractListenerSubscriber. In most cases, + * subscribers should fully implement ListenerSubscriberInterface on their + * own, however. */ abstract class AbstractListenerAggregate implements ListenerAggregateInterface { diff --git a/src/Event.php b/src/Event.php index e92865f..0291bf3 100644 --- a/src/Event.php +++ b/src/Event.php @@ -1,10 +1,8 @@ isPropagationStopped(); + } + + /** + * {@inheritDoc} + */ + public function isPropagationStopped() { return $this->stopPropagation; } diff --git a/src/EventDispatcherInterface.php b/src/EventDispatcherInterface.php new file mode 100644 index 0000000..ff4956a --- /dev/null +++ b/src/EventDispatcherInterface.php @@ -0,0 +1,28 @@ +provider = $provider; + if ($provider instanceof ListenerProvider\PrioritizedListenerAttachmentInterface) { + $instance->prioritizedProvider = $provider; + } + return $instance; + } + /** * Constructor * @@ -69,19 +114,37 @@ class EventManager implements EventManagerInterface * * @param SharedEventManagerInterface $sharedEventManager * @param array $identifiers + * @param bool $skipProviderCreation Internal; used by + * createUsingListenerProvider to ensure that no provider is created during + * instantiation. */ - public function __construct(SharedEventManagerInterface $sharedEventManager = null, array $identifiers = []) - { + public function __construct( + SharedEventManagerInterface $sharedEventManager = null, + array $identifiers = [], + $skipProviderCreation = false + ) { + $this->eventPrototype = new Event(); + + if ($skipProviderCreation) { + // Nothing else to do. + return; + } + if ($sharedEventManager) { - $this->sharedManager = $sharedEventManager; + $this->sharedManager = $sharedEventManager instanceof SharedEventManager + ? $sharedEventManager + : new SharedEventManager\SharedEventManagerDecorator($sharedEventManager); $this->setIdentifiers($identifiers); } - $this->eventPrototype = new Event(); + $this->prioritizedProvider = new ListenerProvider\PrioritizedListenerProvider(); + + $this->provider = $this->createProvider($this->prioritizedProvider, $this->sharedManager); } /** - * @inheritDoc + * @deprecated Will be removed in version 4; use event instances when triggering + * events instead. */ public function setEventPrototype(EventInterface $prototype) { @@ -91,6 +154,8 @@ public function setEventPrototype(EventInterface $prototype) /** * Retrieve the shared event manager, if composed. * + * @deprecated Will be removed in version 4; use a listener provider and + * lazy listeners instead. * @return null|SharedEventManagerInterface $sharedEventManager */ public function getSharedManager() @@ -99,7 +164,9 @@ public function getSharedManager() } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use fully qualified event names + * and the object inheritance hierarchy instead. */ public function getIdentifiers() { @@ -107,7 +174,9 @@ public function getIdentifiers() } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use fully qualified event names + * and the object inheritance hierarchy instead. */ public function setIdentifiers(array $identifiers) { @@ -115,7 +184,9 @@ public function setIdentifiers(array $identifiers) } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use fully qualified event names + * and the object inheritance hierarchy instead. */ public function addIdentifiers(array $identifiers) { @@ -126,7 +197,9 @@ public function addIdentifiers(array $identifiers) } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use dispatch() with an event + * instance instead. */ public function trigger($eventName, $target = null, $argv = []) { @@ -145,7 +218,10 @@ public function trigger($eventName, $target = null, $argv = []) } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use dispatch() with an event + * instance instead, and encapsulate logic for stopping propagation + * within the event itself. */ public function triggerUntil(callable $callback, $eventName, $target = null, $argv = []) { @@ -164,7 +240,8 @@ public function triggerUntil(callable $callback, $eventName, $target = null, $ar } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use dispatch() instead. */ public function triggerEvent(EventInterface $event) { @@ -172,7 +249,9 @@ public function triggerEvent(EventInterface $event) } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated Will be removed in version 4; use dispatch() instead, and + * encapsulate logic for stopping propagation within the event itself. */ public function triggerEventUntil(callable $callback, EventInterface $event) { @@ -180,79 +259,140 @@ public function triggerEventUntil(callable $callback, EventInterface $event) } /** - * @inheritDoc + * {@inheritDoc} */ - public function attach($eventName, callable $listener, $priority = 1) + public function dispatch($event) { - if (! is_string($eventName)) { + if (! is_object($event)) { throw new Exception\InvalidArgumentException(sprintf( - '%s expects a string for the event; received %s', - __METHOD__, - (is_object($eventName) ? get_class($eventName) : gettype($eventName)) + '%s expects an object; received "%s"', + __CLASS__, + gettype($event) )); } - $this->events[$eventName][(int) $priority][0][] = $listener; - return $listener; + $this->triggerListeners($event); + return $event; } /** - * @inheritDoc - * @throws Exception\InvalidArgumentException for invalid event types. + * {@inheritDoc} + * @deprecated This method will be removed in version 4.0; use listener + * providers and the createUsingListenerProvider method instead. + * @throws Exception\RuntimeException if no prioritized provider is composed. */ - public function detach(callable $listener, $eventName = null, $force = false) + public function attach($eventName, callable $listener, $priority = 1) { - - // If event is wildcard, we need to iterate through each listeners - if (null === $eventName || ('*' === $eventName && ! $force)) { - foreach (array_keys($this->events) as $eventName) { - $this->detach($listener, $eventName, true); - } - return; + if (! $this->prioritizedProvider) { + throw new Exception\RuntimeException(sprintf( + 'The provider composed into this %s instance is not of type %s (received %s);' + . ' attach listeners to it directly using its API before passing to the %s constructor', + get_class($this), + ListenerProvider\PrioritizedListenerAttachmentInterface::class, + gettype($this->provider), + get_class($this) + )); } - if (! is_string($eventName)) { - throw new Exception\InvalidArgumentException(sprintf( - '%s expects a string for the event; received %s', - __METHOD__, - (is_object($eventName) ? get_class($eventName) : gettype($eventName)) + $this->prioritizedProvider->attach($eventName, $listener, $priority); + return $listener; + } + + /** + * {@inheritDoc} + * @deprecated This method will be removed in version 4.0; use listener + * providers and the createUsingListenerProvider method instead. + */ + public function attachWildcardListener(callable $listener, $priority = 1) + { + if (! $this->prioritizedProvider) { + throw new Exception\RuntimeException(sprintf( + 'The provider composed into this %s instance is not of type %s (received %s);' + . ' attach wildcared listeners to it directly using its API before passing to the %s constructor', + get_class($this), + ListenerProvider\PrioritizedListenerAttachmentInterface::class, + gettype($this->provider), + get_class($this) )); } - if (! isset($this->events[$eventName])) { - return; - } + $this->prioritizedProvider->attachWildcardListener($listener, $priority); + return $listener; + } - foreach ($this->events[$eventName] as $priority => $listeners) { - foreach ($listeners[0] as $index => $evaluatedListener) { - if ($evaluatedListener !== $listener) { - continue; - } + /** + * {@inheritDoc} + * @deprecated This method will be removed in version 4.0; use listener + * providers and the createUsingListenerProvider method instead. + * @throws Exception\InvalidArgumentException for invalid event types. + */ + public function detach(callable $listener, $eventName = null, $force = false) + { + if (! $this->prioritizedProvider) { + throw new Exception\RuntimeException(sprintf( + 'The provider composed into this %s instance is not of type %s (received %s);' + . ' detach listeners from it directly using its API', + get_class($this), + ListenerProvider\PrioritizedListenerAttachmentInterface::class, + gettype($this->provider) + )); + } - // Found the listener; remove it. - unset($this->events[$eventName][$priority][0][$index]); + $this->prioritizedProvider->detach($listener, $eventName); + } - // If the queue for the given priority is empty, remove it. - if (empty($this->events[$eventName][$priority][0])) { - unset($this->events[$eventName][$priority]); - break; - } - } + /** + * {@inheritDoc} + * @deprecated This method will be removed in version 4.0; use listener + * providers and the createUsingListenerProvider method instead. + */ + public function detachWildcardListener(callable $listener) + { + if (! $this->prioritizedProvider) { + throw new Exception\RuntimeException(sprintf( + 'The provider composed into this %s instance is not of type %s (received %s);' + . ' detach wildcard listeners from it directly using its API', + get_class($this), + ListenerProvider\PrioritizedListenerAttachmentInterface::class, + gettype($this->provider) + )); } - // If the queue for the given event is empty, remove it. - if (empty($this->events[$eventName])) { - unset($this->events[$eventName]); - } + $this->prioritizedProvider->detachWildcardListener($listener); } /** - * @inheritDoc + * {@inheritDoc} + * @deprecated This method will be removed in version 4.0; use listener + * providers and the createUsingListenerProvider method instead. */ public function clearListeners($eventName) { - if (isset($this->events[$eventName])) { - unset($this->events[$eventName]); + if (! $this->prioritizedProvider) { + throw new Exception\RuntimeException(sprintf( + 'The provider composed into this %s instance is not of type %s (received %s);' + . ' clear wildcard listeners from it directly using its API', + get_class($this), + ListenerProvider\PrioritizedListenerAttachmentInterface::class, + gettype($this->provider) + )); + } + + $this->prioritizedProvider->clearListeners($eventName); + } + + /** + * {@inheritDoc} + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @deprecated This method will be removed in version 4.0, and EventManager + * will no longer be its own listener provider; use external listener + * providers and the createUsingListenerProvider method instead. + */ + public function getListenersForEvent($event) + { + // @todo Use `yield from $this->provider->getListenersForEvent(...) + foreach ($this->provider->getListenersForEvent($event, $this->identifiers) as $listener) { + yield $listener; } } @@ -263,6 +403,8 @@ public function clearListeners($eventName) * listener. It returns an ArrayObject of the arguments, which may then be * passed to trigger(). * + * @deprecated This method will be removed in version 4.0; always use context + * specific events with their own mutation methods. * @param array $args * @return ArrayObject */ @@ -276,68 +418,69 @@ public function prepareArgs(array $args) * * Actual functionality for triggering listeners, to which trigger() delegate. * - * @param EventInterface $event + * @param object $event * @param null|callable $callback * @return ResponseCollection */ - protected function triggerListeners(EventInterface $event, callable $callback = null) + protected function triggerListeners($event, callable $callback = null) { - $name = $event->getName(); - - if (empty($name)) { - throw new Exception\RuntimeException('Event is missing a name; cannot trigger!'); + // Initial value of stop propagation flag should be false + if ($event instanceof EventInterface) { + $event->stopPropagation(false); } - if (isset($this->events[$name])) { - $listOfListenersByPriority = $this->events[$name]; + $stopMethod = $event instanceof StoppableEventInterface ? 'isPropagationStopped' : 'propagationIsStopped'; + + // Execute listeners + $responses = new ResponseCollection(); + + foreach ($this->provider->getListenersForEvent($event, $this->identifiers) as $listener) { + $response = $listener($event); + $responses->push($response); - if (isset($this->events['*'])) { - foreach ($this->events['*'] as $priority => $listOfListeners) { - $listOfListenersByPriority[$priority][] = $listOfListeners[0]; - } + // If the event was asked to stop propagating, do so + if ($event->{$stopMethod}()) { + $responses->setStopped(true); + return $responses; } - } elseif (isset($this->events['*'])) { - $listOfListenersByPriority = $this->events['*']; - } else { - $listOfListenersByPriority = []; - } - if ($this->sharedManager) { - foreach ($this->sharedManager->getListeners($this->identifiers, $name) as $priority => $listeners) { - $listOfListenersByPriority[$priority][] = $listeners; + // If the result causes our validation callback to return true, + // stop propagation + if ($callback && $callback($response)) { + $responses->setStopped(true); + return $responses; } } - // Sort by priority in reverse order - krsort($listOfListenersByPriority); + return $responses; + } - // Initial value of stop propagation flag should be false - $event->stopPropagation(false); + /** + * Creates the value for the $provider property, based on the + * $sharedEventManager argument. + * + * @param ListenerProvider\PrioritizedListenerProvider $prioritizedProvider + * @param null|SharedEventManagerInterface $sharedEventManager + * @return ListenerProvider\ListenerProviderInterface + */ + private function createProvider( + ListenerProvider\PrioritizedListenerProvider $prioritizedProvider, + SharedEventManagerInterface $sharedEventManager = null + ) { + if (! $sharedEventManager) { + return $prioritizedProvider; + } - // Execute listeners - $responses = new ResponseCollection(); - foreach ($listOfListenersByPriority as $listOfListeners) { - foreach ($listOfListeners as $listeners) { - foreach ($listeners as $listener) { - $response = $listener($event); - $responses->push($response); - - // If the event was asked to stop propagating, do so - if ($event->propagationIsStopped()) { - $responses->setStopped(true); - return $responses; - } - - // If the result causes our validation callback to return true, - // stop propagation - if ($callback && $callback($response)) { - $responses->setStopped(true); - return $responses; - } - } - } + if ($sharedEventManager instanceof ListenerProvider\PrioritizedListenerProviderInterface) { + return new ListenerProvider\PrioritizedAggregateListenerProvider([ + $prioritizedProvider, + $sharedEventManager, + ]); } - return $responses; + return new ListenerProvider\PrioritizedAggregateListenerProvider( + [$prioritizedProvider], + $sharedEventManager + ); } } diff --git a/src/EventManagerAwareInterface.php b/src/EventManagerAwareInterface.php index 42ccbcf..00a1b40 100644 --- a/src/EventManagerAwareInterface.php +++ b/src/EventManagerAwareInterface.php @@ -11,6 +11,8 @@ /** * Interface to automate setter injection for an EventManager instance + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0. */ interface EventManagerAwareInterface extends EventsCapableInterface { diff --git a/src/EventManagerAwareTrait.php b/src/EventManagerAwareTrait.php index fff7cf8..ff2d631 100644 --- a/src/EventManagerAwareTrait.php +++ b/src/EventManagerAwareTrait.php @@ -20,6 +20,7 @@ * EventManager into your object when it is pulled from the ServiceManager. * * @see Zend\Mvc\Service\ServiceManagerConfig + * @deprecated since 3.3.0. This trait will be removed in version 4.0. */ trait EventManagerAwareTrait { diff --git a/src/EventManagerInterface.php b/src/EventManagerInterface.php index 49b1ec0..4beac78 100644 --- a/src/EventManagerInterface.php +++ b/src/EventManagerInterface.php @@ -11,6 +11,9 @@ /** * Interface for messengers + * + * @deprecated since 3.3.0; this interface will be removed in version 4.0, in + * favor of the PSR-14 EventDispatcherInterface and ListenerProviderInterface. */ interface EventManagerInterface extends SharedEventsCapableInterface { diff --git a/src/EventsCapableInterface.php b/src/EventsCapableInterface.php index 503ec97..636302d 100644 --- a/src/EventsCapableInterface.php +++ b/src/EventsCapableInterface.php @@ -11,6 +11,9 @@ /** * Interface indicating that an object composes an EventManagerInterface instance. + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0, in + * favor of the EventDispatcherInterface introduced in version 3.3.0. */ interface EventsCapableInterface { diff --git a/src/Filter/FilterInterface.php b/src/Filter/FilterInterface.php index f8c239a..9d93fa2 100644 --- a/src/Filter/FilterInterface.php +++ b/src/Filter/FilterInterface.php @@ -13,6 +13,9 @@ /** * Interface for intercepting filter chains + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0.0. No + * replacement is provided. */ interface FilterInterface { diff --git a/src/Filter/FilterIterator.php b/src/Filter/FilterIterator.php index e1cf173..a2f50da 100644 --- a/src/Filter/FilterIterator.php +++ b/src/Filter/FilterIterator.php @@ -16,7 +16,10 @@ * Specialized priority queue implementation for use with an intercepting * filter chain. * - * Allows removal + * Allows removal. + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0. No + * replacement is provided. */ class FilterIterator extends FastPriorityQueue { diff --git a/src/FilterChain.php b/src/FilterChain.php index 85e0423..49047c3 100644 --- a/src/FilterChain.php +++ b/src/FilterChain.php @@ -11,6 +11,9 @@ /** * FilterChain: intercepting filter manager + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0. No + * replacement is provided. */ class FilterChain implements Filter\FilterInterface { diff --git a/src/LazyEventListener.php b/src/LazyEventListener.php index be5cd7c..9f4de0f 100644 --- a/src/LazyEventListener.php +++ b/src/LazyEventListener.php @@ -22,6 +22,9 @@ * * - event: the event name to attach to. * - priority: the priority at which to attach the listener, if not the default. + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0, in + * favor of the ListenerProvider\LazyListener implementation. */ class LazyEventListener extends LazyListener { diff --git a/src/LazyListener.php b/src/LazyListener.php index b1e7d92..ef6a788 100644 --- a/src/LazyListener.php +++ b/src/LazyListener.php @@ -28,6 +28,9 @@ * * Pass instances directly to the event manager's `attach()` method as the * listener argument. + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0, in + * favor of the ListenerProvider\LazyListener implementation. */ class LazyListener { diff --git a/src/LazyListenerAggregate.php b/src/LazyListenerAggregate.php index 1462870..32f653a 100644 --- a/src/LazyListenerAggregate.php +++ b/src/LazyListenerAggregate.php @@ -26,6 +26,9 @@ * $container * )); * + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0 in + * favor of the ListenerProvider\LazyListenerSubscriber implementation. */ class LazyListenerAggregate implements ListenerAggregateInterface { diff --git a/src/ListenerAggregateInterface.php b/src/ListenerAggregateInterface.php index 910fbb5..40e352f 100644 --- a/src/ListenerAggregateInterface.php +++ b/src/ListenerAggregateInterface.php @@ -16,6 +16,9 @@ * with an EventManager, without an event name. The {@link attach()} method will * then be called with the current EventManager instance, allowing the class to * wire up one or more listeners. + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0.0, in + * favor of the ListenerProvider\ListenerSubscriberInterface. */ interface ListenerAggregateInterface { diff --git a/src/ListenerAggregateTrait.php b/src/ListenerAggregateTrait.php index 7480422..99bc125 100644 --- a/src/ListenerAggregateTrait.php +++ b/src/ListenerAggregateTrait.php @@ -12,6 +12,11 @@ /** * Provides logic to easily create aggregate listeners, without worrying about * manually detaching events + * + * @deprecated since 3.3.0. This trait will be removed in version 4.0.0, in + * favor of the ListenerProvider\ListenerSubscriberTrait. In most cases, + * subscribers should fully implement ListenerSubscriberInterface on their + * own, however. */ trait ListenerAggregateTrait { diff --git a/src/ListenerProvider/AbstractListenerSubscriber.php b/src/ListenerProvider/AbstractListenerSubscriber.php new file mode 100644 index 0000000..1b8637b --- /dev/null +++ b/src/ListenerProvider/AbstractListenerSubscriber.php @@ -0,0 +1,27 @@ +listeners as $index => $callback) { + $provider->detach($callback); + unset($this->listeners[$index]); + } + } +} diff --git a/src/ListenerProvider/LazyListener.php b/src/ListenerProvider/LazyListener.php new file mode 100644 index 0000000..45acf8b --- /dev/null +++ b/src/ListenerProvider/LazyListener.php @@ -0,0 +1,156 @@ +container = $container; + $this->service = $listener; + $this->method = $method; + $this->event = $event; + $this->priority = $priority; + } + + /** + * Use the listener as an invokable, allowing direct attachment to an event manager. + * + * @param object $event + * @return void + */ + public function __invoke($event) + { + $listener = $this->fetchListener(); + $method = $this->method; + $listener->{$method}($event); + } + + /** + * @return null|string + */ + public function getEvent() + { + return $this->event; + } + + /** + * Return the priority, or, if not set, the default provided. + * + * @param int $default + * @return int + */ + public function getPriority($default = 1) + { + return null !== $this->priority ? (int) $this->priority : (int) $default; + } + + /** + * @return callable + */ + private function fetchListener() + { + if ($this->listener) { + return $this->listener; + } + + $this->listener = $this->container->get($this->service); + + return $this->listener; + } +} diff --git a/src/ListenerProvider/LazyListenerSubscriber.php b/src/ListenerProvider/LazyListenerSubscriber.php new file mode 100644 index 0000000..bf8c6f6 --- /dev/null +++ b/src/ListenerProvider/LazyListenerSubscriber.php @@ -0,0 +1,102 @@ + + * $subscriber = new LazyListenerSubscriber($listOfLazyListeners); + * $subscriber->attach($provider, $priority); + * )); + * + */ +class LazyListenerSubscriber implements ListenerSubscriberInterface +{ + /** + * LazyListener instances. + * + * @var LazyListener[] + */ + private $listeners = []; + + /** + * @throws Exception\InvalidArgumentException if any member of $listeners + * is not a LazyListener instance. + * @throws Exception\InvalidArgumentException if any member of $listeners + * does not have a defined event to which to attach. + */ + public function __construct(array $listeners) + { + $this->validateListeners($listeners); + $this->listeners = $listeners; + } + + /** + * Subscribe listeners to the provider. + * + * Loops through all composed lazy listeners, and attaches them to the + * provider. + */ + public function attach(PrioritizedListenerAttachmentInterface $provider, $priority = 1) + { + foreach ($this->listeners as $listener) { + $provider->attach( + $listener->getEvent(), + $listener, + $listener->getPriority($priority) + ); + } + } + + public function detach(PrioritizedListenerAttachmentInterface $provider) + { + foreach ($this->listeners as $listener) { + $provider->detach($listener, $listener->getEvent()); + } + } + + /** + * @throws Exception\InvalidArgumentException if any member of $listeners + * is not a LazyListener instance. + * @throws Exception\InvalidArgumentException if any member of $listeners + * does not have a defined event to which to attach. + */ + private function validateListeners(array $listeners) + { + foreach ($listeners as $index => $listener) { + if (! $listener instanceof LazyListener) { + throw new Exception\InvalidArgumentException(sprintf( + '%s only accepts %s instances; received listener of type %s at index %s', + __CLASS__, + LazyListener::class, + gettype($listener), + $index + )); + } + + if (null === $listener->getEvent()) { + throw new Exception\InvalidArgumentException(sprintf( + '%s requires that all %s instances compose a non-empty string event to which to attach;' + . ' none provided for listener at index %s', + __CLASS__, + LazyListener::class, + $index + )); + } + } + } +} diff --git a/src/ListenerProvider/ListenerProviderInterface.php b/src/ListenerProvider/ListenerProviderInterface.php new file mode 100644 index 0000000..20b4e70 --- /dev/null +++ b/src/ListenerProvider/ListenerProviderInterface.php @@ -0,0 +1,21 @@ +listeners as $index => $callback) { + $provider->detach($callback); + unset($this->listeners[$index]); + } + } +} diff --git a/src/ListenerProvider/PrioritizedAggregateListenerProvider.php b/src/ListenerProvider/PrioritizedAggregateListenerProvider.php new file mode 100644 index 0000000..42d0575 --- /dev/null +++ b/src/ListenerProvider/PrioritizedAggregateListenerProvider.php @@ -0,0 +1,103 @@ +validateProviders($providers); + $this->providers = $providers; + $this->default = $default; + } + + /** + * {@inheritDoc} + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @param string[] $identifiers Any identifiers to use when retrieving + * listeners from child providers. + */ + public function getListenersForEvent($event, array $identifiers = []) + { + // @todo `yield from $this->iterateByPriority(...)` + foreach ($this->iterateByPriority($this->getListenersForEventByPriority($event, $identifiers)) as $listener) { + yield $listener; + } + + if (! $this->default) { + return; + } + + // @todo `yield from $this->default->getListenersForEvent(...)` + foreach ($this->default->getListenersForEvent($event, $identifiers) as $listener) { + yield $listener; + } + } + + public function getListenersForEventByPriority($event, array $identifiers = []) + { + $prioritizedListeners = []; + + foreach ($this->providers as $provider) { + foreach ($provider->getListenersForEventByPriority($event, $identifiers) as $priority => $listeners) { + $prioritizedListeners[$priority] = isset($prioritizedListeners[$priority]) + ? array_merge($prioritizedListeners[$priority], $listeners) + : $listeners; + } + } + + return $prioritizedListeners; + } + + /** + * @throws Exception\InvalidArgumentException if any provider is not a + * PrioritizedListenerProviderInterface instance + */ + private function validateProviders(array $providers) + { + foreach ($providers as $index => $provider) { + if (! $provider instanceof PrioritizedListenerProviderInterface) { + throw new Exception\InvalidArgumentException(sprintf( + '%s requires all providers be instances of %s; received provider of type "%s" at index %d', + __CLASS__, + PrioritizedListenerProviderInterface::class, + gettype($provider), + $index + )); + } + } + } + + /** + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @param array $prioritizedListeners + * @return iterable + */ + private function iterateByPriority($prioritizedListeners) + { + krsort($prioritizedListeners); + foreach ($prioritizedListeners as $listeners) { + // @todo `yield from $listeners` + foreach ($listeners as $listener) { + yield $listener; + } + } + } +} diff --git a/src/ListenerProvider/PrioritizedIdentifierListenerProvider.php b/src/ListenerProvider/PrioritizedIdentifierListenerProvider.php new file mode 100644 index 0000000..0a42afe --- /dev/null +++ b/src/ListenerProvider/PrioritizedIdentifierListenerProvider.php @@ -0,0 +1,284 @@ +>> + */ + protected $identifiers = []; + + /** + * {@inheritDoc} + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @param array $identifiers Identifiers from which to match event listeners. + * @throws Exception\InvalidArgumentException for invalid event types + * @throws Exception\InvalidArgumentException for invalid identifier types + */ + public function getListenersForEvent($event, array $identifiers = []) + { + // @todo `yield from $this->iterateByPriority(...)` + foreach ($this->iterateByPriority($this->getListenersForEventByPriority($event, $identifiers)) as $listener) { + yield $listener; + } + } + + /** + * {@inheritDoc} + * @throws Exception\InvalidArgumentException for invalid event types + * @throws Exception\InvalidArgumentException for invalid identifier types + */ + public function getListenersForEventByPriority($event, array $identifiers = []) + { + $this->validateEventForListenerRetrieval($event, __METHOD__); + + $prioritizedListeners = []; + $identifiers = $this->normalizeIdentifierList($identifiers); + $eventList = $this->getEventList($event); + + foreach ($identifiers as $identifier) { + if (! is_string($identifier) || empty($identifier)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Identifier names passed to %s must be non-empty', + __METHOD__ + )); + } + + if (! isset($this->identifiers[$identifier])) { + continue; + } + + $listenersByIdentifier = $this->identifiers[$identifier]; + + foreach ($eventList as $eventName) { + if (! isset($listenersByIdentifier[$eventName])) { + continue; + } + + foreach ($listenersByIdentifier[$eventName] as $priority => $listOfListeners) { + $prioritizedListeners[$priority] = isset($prioritizedListeners[$priority]) + ? array_merge($prioritizedListeners[$priority], $listOfListeners[0]) + : $listOfListeners[0]; + } + } + } + + return $prioritizedListeners; + } + + /** + * {@inheritDoc} + * @throws Exception\InvalidArgumentException for invalid identifier types + * @throws Exception\InvalidArgumentException for invalid event types + */ + public function attach($identifier, $eventName, callable $listener, $priority = 1) + { + if (! is_string($identifier) || empty($identifier)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Invalid identifier provided; must be a string; received "%s"', + gettype($identifier) + )); + } + + if (! is_string($eventName) || empty($eventName)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Invalid event provided; must be a non-empty string; received "%s"', + gettype($eventName) + )); + } + + $this->identifiers[$identifier][$eventName][(int) $priority][0][] = $listener; + } + + /** + * {@inheritDoc} + * @param bool $force Internal; allows recursing when detaching wildcard listeners + * @throws Exception\InvalidArgumentException for invalid identifier types + * @throws Exception\InvalidArgumentException for invalid event name types + */ + public function detach(callable $listener, $identifier = null, $eventName = null, $force = false) + { + // No identifier or wildcard identifier: loop through all identifiers and detach + if (null === $identifier || ('*' === $identifier && ! $force)) { + foreach (array_keys($this->identifiers) as $identifier) { + $this->detach($listener, $identifier, $eventName, true); + } + return; + } + + if (! is_string($identifier) || empty($identifier)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Invalid identifier provided; must be a string, received %s', + gettype($identifier) + )); + } + + // Do we have any listeners on the provided identifier? + if (! isset($this->identifiers[$identifier])) { + return; + } + + if (null === $eventName || ('*' === $eventName && ! $force)) { + foreach (array_keys($this->identifiers[$identifier]) as $eventName) { + $this->detach($listener, $identifier, $eventName, true); + } + return; + } + + if (! is_string($eventName) || empty($eventName)) { + throw new Exception\InvalidArgumentException(sprintf( + 'Invalid event name provided; must be a string, received %s', + gettype($eventName) + )); + } + + if (! isset($this->identifiers[$identifier][$eventName])) { + return; + } + + foreach ($this->identifiers[$identifier][$eventName] as $priority => $listOfListeners) { + foreach ($listOfListeners[0] as $index => $evaluatedListener) { + if ($evaluatedListener !== $listener) { + continue; + } + + // Found the listener; remove it. + unset($this->identifiers[$identifier][$eventName][$priority][0][$index]); + + // Is the priority queue empty? + if (empty($this->identifiers[$identifier][$eventName][$priority][0])) { + unset($this->identifiers[$identifier][$eventName][$priority]); + break; + } + } + + // Is the event queue empty? + if (empty($this->identifiers[$identifier][$eventName])) { + unset($this->identifiers[$identifier][$eventName]); + break; + } + } + + // Is the identifier queue now empty? Remove it. + if (empty($this->identifiers[$identifier])) { + unset($this->identifiers[$identifier]); + } + } + + /** + * {@inheritDoc} + */ + public function getListeners(array $identifiers, $eventName) + { + return $this->getListenersForEventByPriority($eventName, $identifiers); + } + + /** + * {@inheritDoc} + */ + public function clearListeners($identifier, $eventName = null) + { + if (! isset($this->identifiers[$identifier])) { + return false; + } + + if (null === $eventName) { + unset($this->identifiers[$identifier]); + return; + } + + if (! isset($this->identifiers[$identifier][$eventName])) { + return; + } + + unset($this->identifiers[$identifier][$eventName]); + } + + /** + * @param mixed $event Event to validate + * @param string $method Method name invoking this one + * @return void + * @throws Exception\InvalidArgumentException for invalid event types + */ + private function validateEventForListenerRetrieval($event, $method) + { + if (is_object($event)) { + return; + } + + if (is_string($event) && '*' !== $event && ! empty($event)) { + return; + } + + throw new Exception\InvalidArgumentException(sprintf( + 'Event name passed to %s must be a non-empty, non-wildcard string or an object', + $method + )); + } + + /** + * Deduplicate identifiers, and ensure wildcard identifier is last. + * + * @return string[] + */ + private function normalizeIdentifierList(array $identifiers) + { + $identifiers = array_unique($identifiers); + if (false !== ($index = array_search('*', $identifiers, true))) { + unset($identifiers[$index]); + } + array_push($identifiers, '*'); + return $identifiers; + } + + /** + * @param string|object $event + * @return string[] + */ + private function getEventList($event) + { + if (is_string($event)) { + return [$event, '*']; + } + + return is_callable([$event, 'getName']) + ? [$event->getName(), get_class($event), '*'] + : [get_class($event), '*']; + } + + /** + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @param array $prioritizedListeners + * @return iterable + */ + private function iterateByPriority($prioritizedListeners) + { + krsort($prioritizedListeners); + foreach ($prioritizedListeners as $listeners) { + // @todo `yield from $listeners` + foreach ($listeners as $listener) { + yield $listener; + } + } + } +} diff --git a/src/ListenerProvider/PrioritizedListenerAttachmentInterface.php b/src/ListenerProvider/PrioritizedListenerAttachmentInterface.php new file mode 100644 index 0000000..e6e3f9f --- /dev/null +++ b/src/ListenerProvider/PrioritizedListenerAttachmentInterface.php @@ -0,0 +1,77 @@ + + * attach('*', $listener, $priority) + * + * + * The above will actually invoke this method instead. + * + * @param callable $listener The listener to attach. + * @param int $priority The priority at which to attach the listener. + * High priorities respond earlier; negative priorities respond later. + * @return callable The listener attached, to allow subscribers to track + * which listeners were attached, and thus detach them. This return + * value will be changed to `void` in version 4; we recommend + * subscribers write their own logic for tracking what has and hasn't + * been attached. + */ + public function attachWildcardListener(callable $listener, $priority = 1); + + /** + * Detaches a wildcard listener. + * + * Analagous to: + * + * + * detach($listener, '*', $force) + * + * + * The above will actually invoke this method instead. + * + * @param callable $listener The listener to detach. + * @return void + */ + public function detachWildcardListener(callable $listener); + + /** + * @param string $event The event for which to remove listeners. + * @return void + */ + public function clearListeners($event); +} diff --git a/src/ListenerProvider/PrioritizedListenerProvider.php b/src/ListenerProvider/PrioritizedListenerProvider.php new file mode 100644 index 0000000..c6e50be --- /dev/null +++ b/src/ListenerProvider/PrioritizedListenerProvider.php @@ -0,0 +1,206 @@ + => [ + * => [ + * 0 => [, ...] + * ], + * ... + * ], + * ... + * ] + * + * NOTE: + * This structure helps us to reuse the list of listeners + * instead of first iterating over it and generating a new one + * -> In result it improves performance by up to 25% even if it looks a bit strange + * + * @var array> + */ + protected $events = []; + + /** + * {@inheritDoc} + */ + public function getListenersForEvent($event) + { + // @todo Use `yield from $this->iterateByPriority(...)` + return $this->iterateByPriority( + $this->getListenersForEventByPriority($event) + ); + } + + /** + * {@inheritDoc} + * @param string[] $identifiers Ignored in this implementation. + * @throws Exception\InvalidArgumentException for invalid $event types. + */ + public function getListenersForEventByPriority($event, array $identifiers = []) + { + if (! is_object($event)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s expects the $event argument to be an object; received %s', + __METHOD__, + gettype($event) + )); + } + + $identifiers = is_callable([$event, 'getName']) + ? [$event->getName()] + : []; + $identifiers = array_merge($identifiers, [get_class($event), '*']); + + $prioritizedListeners = []; + foreach ($identifiers as $name) { + if (! isset($this->events[$name])) { + continue; + } + + foreach ($this->events[$name] as $priority => $listOfListeners) { + $prioritizedListeners[$priority] = isset($prioritizedListeners[$priority]) + ? array_merge($prioritizedListeners[$priority], $listOfListeners[0]) + : $listOfListeners[0]; + } + } + + return $prioritizedListeners; + } + + /** + * {@inheritDoc} + * @throws Exception\InvalidArgumentException for invalid $event types. + */ + public function attach($event, callable $listener, $priority = 1) + { + if (! is_string($event)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s expects a string for the event; received %s', + __METHOD__, + gettype($event) + )); + } + + $this->events[$event][(int) $priority][0][] = $listener; + + return $listener; + } + + /** + * {@inheritDoc} + * @param bool $force Internal; used by attachWildcardListener to force + * removal of the '*' event. + * @throws Exception\InvalidArgumentException for invalid event types. + */ + public function detach(callable $listener, $event = null, $force = false) + { + if (null === $event || ('*' === $event && ! $force)) { + $this->detachWildcardListener($listener); + return; + } + + if (! is_string($event)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s expects a string for the event; received %s', + __METHOD__, + gettype($event) + )); + } + + if (! isset($this->events[$event])) { + return; + } + + foreach ($this->events[$event] as $priority => $listeners) { + foreach ($listeners[0] as $index => $evaluatedListener) { + if ($evaluatedListener !== $listener) { + continue; + } + + // Found the listener; remove it. + unset($this->events[$event][$priority][0][$index]); + + // If the queue for the given priority is empty, remove it. + if (empty($this->events[$event][$priority][0])) { + unset($this->events[$event][$priority]); + break; + } + } + } + + // If the queue for the given event is empty, remove it. + if (empty($this->events[$event])) { + unset($this->events[$event]); + } + } + + /** + * {@inheritDoc} + */ + public function attachWildcardListener(callable $listener, $priority = 1) + { + $this->events['*'][(int) $priority][0][] = $listener; + return $listener; + } + + /** + * {@inheritDoc} + */ + public function detachWildcardListener(callable $listener) + { + foreach (array_keys($this->events) as $event) { + $this->detach($listener, $event, true); + } + } + + /** + * {@inheritDoc} + * @throws Exception\InvalidArgumentException for invalid event types. + */ + public function clearListeners($event) + { + if (! is_string($event)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s expects a string for the event; received %s', + __METHOD__, + gettype($event) + )); + } + + if (isset($this->events[$event])) { + unset($this->events[$event]); + } + } + + /** + * @todo Use `yield from` once we bump the minimum supported PHP version to 7+. + * @param array $prioritizedListeners + * @return iterable + */ + private function iterateByPriority($prioritizedListeners) + { + krsort($prioritizedListeners); + foreach ($prioritizedListeners as $listeners) { + // @todo `yield from $listeners` + foreach ($listeners as $listener) { + yield $listener; + } + } + } +} diff --git a/src/ListenerProvider/PrioritizedListenerProviderInterface.php b/src/ListenerProvider/PrioritizedListenerProviderInterface.php new file mode 100644 index 0000000..82ad87e --- /dev/null +++ b/src/ListenerProvider/PrioritizedListenerProviderInterface.php @@ -0,0 +1,20 @@ + Returns a hash table of priorities with + * the associated listeners for that priority. + */ + public function getListenersForEventByPriority($event, array $identifiers = []); +} diff --git a/src/ResponseCollection.php b/src/ResponseCollection.php index 57c5bd3..f4411f8 100644 --- a/src/ResponseCollection.php +++ b/src/ResponseCollection.php @@ -13,6 +13,11 @@ /** * Collection of signal handler return values + * + * @deprecated since 3.3.0. This class will be removed in version 4.0.0. + * Listeners should not return values, and any values that should be + * aggregated or used to stop propagation should be injected directly into + * the event instance itself, via its own published API. */ class ResponseCollection extends SplStack { diff --git a/src/SharedEventManager.php b/src/SharedEventManager.php index 59e2690..359467f 100644 --- a/src/SharedEventManager.php +++ b/src/SharedEventManager.php @@ -15,220 +15,10 @@ * Allows attaching to EMs composed by other classes without having an instance first. * The assumption is that the SharedEventManager will be injected into EventManager * instances, and then queried for additional listeners when triggering an event. + * + * @deprecated since 3.3.0. This class will be removed in version 4.0; use + * listener providers instead. */ -class SharedEventManager implements SharedEventManagerInterface +class SharedEventManager extends ListenerProvider\PrioritizedIdentifierListenerProvider { - /** - * Identifiers with event connections - * @var array - */ - protected $identifiers = []; - - /** - * Attach a listener to an event emitted by components with specific identifiers. - * - * As an example, the following connects to the "getAll" event of both an - * AbstractResource and EntityResource: - * - * - * $sharedEventManager = new SharedEventManager(); - * foreach (['My\Resource\AbstractResource', 'My\Resource\EntityResource'] as $identifier) { - * $sharedEventManager->attach( - * $identifier, - * 'getAll', - * function ($e) use ($cache) { - * if (!$id = $e->getParam('id', false)) { - * return; - * } - * if (!$data = $cache->load(get_class($resource) . '::getOne::' . $id )) { - * return; - * } - * return $data; - * } - * ); - * } - * - * - * @param string $identifier Identifier for event emitting component. - * @param string $event - * @param callable $listener Listener that will handle the event. - * @param int $priority Priority at which listener should execute - * @return void - * @throws Exception\InvalidArgumentException for invalid identifier arguments. - * @throws Exception\InvalidArgumentException for invalid event arguments. - */ - public function attach($identifier, $event, callable $listener, $priority = 1) - { - if (! is_string($identifier) || empty($identifier)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Invalid identifier provided; must be a string; received "%s"', - (is_object($identifier) ? get_class($identifier) : gettype($identifier)) - )); - } - - if (! is_string($event) || empty($event)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Invalid event provided; must be a non-empty string; received "%s"', - (is_object($event) ? get_class($event) : gettype($event)) - )); - } - - $this->identifiers[$identifier][$event][(int) $priority][] = $listener; - } - - /** - * @inheritDoc - */ - public function detach(callable $listener, $identifier = null, $eventName = null, $force = false) - { - // No identifier or wildcard identifier: loop through all identifiers and detach - if (null === $identifier || ('*' === $identifier && ! $force)) { - foreach (array_keys($this->identifiers) as $identifier) { - $this->detach($listener, $identifier, $eventName, true); - } - return; - } - - if (! is_string($identifier) || empty($identifier)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Invalid identifier provided; must be a string, received %s', - (is_object($identifier) ? get_class($identifier) : gettype($identifier)) - )); - } - - // Do we have any listeners on the provided identifier? - if (! isset($this->identifiers[$identifier])) { - return; - } - - if (null === $eventName || ('*' === $eventName && ! $force)) { - foreach (array_keys($this->identifiers[$identifier]) as $eventName) { - $this->detach($listener, $identifier, $eventName, true); - } - return; - } - - if (! is_string($eventName) || empty($eventName)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Invalid event name provided; must be a string, received %s', - (is_object($eventName) ? get_class($eventName) : gettype($eventName)) - )); - } - - if (! isset($this->identifiers[$identifier][$eventName])) { - return; - } - - foreach ($this->identifiers[$identifier][$eventName] as $priority => $listeners) { - foreach ($listeners as $index => $evaluatedListener) { - if ($evaluatedListener !== $listener) { - continue; - } - - // Found the listener; remove it. - unset($this->identifiers[$identifier][$eventName][$priority][$index]); - - // Is the priority queue empty? - if (empty($this->identifiers[$identifier][$eventName][$priority])) { - unset($this->identifiers[$identifier][$eventName][$priority]); - break; - } - } - - // Is the event queue empty? - if (empty($this->identifiers[$identifier][$eventName])) { - unset($this->identifiers[$identifier][$eventName]); - break; - } - } - - // Is the identifier queue now empty? Remove it. - if (empty($this->identifiers[$identifier])) { - unset($this->identifiers[$identifier]); - } - } - - /** - * Retrieve all listeners for a given identifier and event - * - * @param string[] $identifiers - * @param string $eventName - * @return array[] - * @throws Exception\InvalidArgumentException - */ - public function getListeners(array $identifiers, $eventName) - { - if ('*' === $eventName || ! is_string($eventName) || empty($eventName)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Event name passed to %s must be a non-empty, non-wildcard string', - __METHOD__ - )); - } - - $returnListeners = []; - - foreach ($identifiers as $identifier) { - if ('*' === $identifier || ! is_string($identifier) || empty($identifier)) { - throw new Exception\InvalidArgumentException(sprintf( - 'Identifier names passed to %s must be non-empty, non-wildcard strings', - __METHOD__ - )); - } - - if (isset($this->identifiers[$identifier])) { - $listenersByIdentifier = $this->identifiers[$identifier]; - if (isset($listenersByIdentifier[$eventName])) { - foreach ($listenersByIdentifier[$eventName] as $priority => $listeners) { - $returnListeners[$priority][] = $listeners; - } - } - if (isset($listenersByIdentifier['*'])) { - foreach ($listenersByIdentifier['*'] as $priority => $listeners) { - $returnListeners[$priority][] = $listeners; - } - } - } - } - - if (isset($this->identifiers['*'])) { - $wildcardIdentifier = $this->identifiers['*']; - if (isset($wildcardIdentifier[$eventName])) { - foreach ($wildcardIdentifier[$eventName] as $priority => $listeners) { - $returnListeners[$priority][] = $listeners; - } - } - if (isset($wildcardIdentifier['*'])) { - foreach ($wildcardIdentifier['*'] as $priority => $listeners) { - $returnListeners[$priority][] = $listeners; - } - } - } - - foreach ($returnListeners as $priority => $listOfListeners) { - $returnListeners[$priority] = array_merge(...$listOfListeners); - } - - return $returnListeners; - } - - /** - * @inheritDoc - */ - public function clearListeners($identifier, $eventName = null) - { - if (! isset($this->identifiers[$identifier])) { - return false; - } - - if (null === $eventName) { - unset($this->identifiers[$identifier]); - return; - } - - if (! isset($this->identifiers[$identifier][$eventName])) { - return; - } - - unset($this->identifiers[$identifier][$eventName]); - } } diff --git a/src/SharedEventManager/SharedEventManagerDecorator.php b/src/SharedEventManager/SharedEventManagerDecorator.php new file mode 100644 index 0000000..0d05b5e --- /dev/null +++ b/src/SharedEventManager/SharedEventManagerDecorator.php @@ -0,0 +1,95 @@ +proxy = $proxy; + } + + /** + * {@inheritDoc} + * @var iterable $identifiers Identifiers provided by dispatcher, if any. + * This argument is deprecated, and will be removed in version 4. + */ + public function getListenersForEvent($event, array $identifiers = []) + { + return $this->getListeners($identifiers, $this->getEventName($event, __METHOD__)); + } + + /** + * {@inheritDoc} + */ + public function attach($identifier, $eventName, callable $listener, $priority = 1) + { + return $this->proxy->attach($identifier, $eventName, $listener, $priority); + } + + /** + * {@inheritDoc} + */ + public function detach(callable $listener, $identifier = null, $eventName = null) + { + return $this->proxy->detach($listener, $identifier, $eventName); + } + + /** + * {@inheritDoc} + */ + public function getListeners(array $identifiers, $eventName) + { + return $this->proxy->getListeners($identifiers, $this->getEventName($eventName)); + } + + /** + * {@inheritDoc} + */ + public function clearListeners($identifier, $eventName = null) + { + return $this->proxy->clearListeners($identifier, $eventName); + } + + /** + * @param mixed $event + * @param string $method Method that called this one + * @return string + */ + private function getEventName($event, $method) + { + if (is_string($event) && ! empty($event)) { + return $event; + } + + if (! is_object($event)) { + throw new Exception\InvalidArgumentException(sprintf( + '%s expects an object or non-empty string $event argument; received %s', + $method, + gettype($event) + )); + } + + if (is_callable([$event, 'getName'])) { + return $event->getName() ?: get_class($event); + } + + return get_class($event); + } +} diff --git a/src/SharedEventManagerInterface.php b/src/SharedEventManagerInterface.php index c245704..b9b1a28 100644 --- a/src/SharedEventManagerInterface.php +++ b/src/SharedEventManagerInterface.php @@ -11,6 +11,9 @@ /** * Interface for shared event listener collections + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0; use + * listener providers instead. */ interface SharedEventManagerInterface { diff --git a/src/SharedEventsCapableInterface.php b/src/SharedEventsCapableInterface.php index f367acb..e2f080f 100644 --- a/src/SharedEventsCapableInterface.php +++ b/src/SharedEventsCapableInterface.php @@ -12,6 +12,8 @@ /** * Interface indicating that an object composes or can compose a * SharedEventManagerInterface instance. + * + * @deprecated since 3.3.0. This interface will be removed in version 4.0. */ interface SharedEventsCapableInterface { diff --git a/src/StoppableEventInterface.php b/src/StoppableEventInterface.php new file mode 100644 index 0000000..daa9ca2 --- /dev/null +++ b/src/StoppableEventInterface.php @@ -0,0 +1,25 @@ +setAccessible(true); + $provider = $r->getValue($manager); + + $r = new ReflectionProperty($provider, 'events'); $r->setAccessible(true); - $listeners = $r->getValue($events); - return array_keys($listeners); + $events = $r->getValue($provider); + + return array_keys($events); } /** @@ -64,18 +70,19 @@ private function getEventsFromEventManager(EventManager $events) */ private function getListenersForEvent($event, EventManager $events, $withPriority = false) { - $r = new ReflectionProperty($events, 'events'); - $r->setAccessible(true); - $internal = $r->getValue($events); + $event = new Event($event); - $listeners = []; - foreach (isset($internal[$event]) ? $internal[$event] : [] as $p => $listOfListeners) { - foreach ($listOfListeners as $l) { - $listeners[$p] = isset($listeners[$p]) ? array_merge($listeners[$p], $l) : $l; - } + if (! $withPriority) { + $listeners = $events->getListenersForEvent($event); + return iterator_to_array($listeners, false); } - return $this->traverseListeners($listeners, $withPriority); + $r = new ReflectionProperty($events, 'provider'); + $r->setAccessible(true); + $provider = $r->getValue($events); + + $listeners = $this->traverseListeners($provider->getListenersForEventByPriority($event), true); + return iterator_to_array($listeners); } /** @@ -125,7 +132,7 @@ private function assertListenerAtPriority( */ private function getArrayOfListenersForEvent($event, EventManager $events) { - return iterator_to_array($this->getListenersForEvent($event, $events)); + return $this->getListenersForEvent($event, $events); } /** diff --git a/test/EventManagerTest.php b/test/EventManagerTest.php index 96a5bf4..e4d4208 100644 --- a/test/EventManagerTest.php +++ b/test/EventManagerTest.php @@ -18,6 +18,7 @@ use Zend\EventManager\EventManager; use Zend\EventManager\Exception; use Zend\EventManager\ListenerAggregateInterface; +use Zend\EventManager\ListenerProvider; use Zend\EventManager\ResponseCollection; use Zend\EventManager\SharedEventManager; use Zend\EventManager\SharedEventManagerInterface; @@ -29,7 +30,7 @@ public function setUp() if (isset($this->message)) { unset($this->message); } - $this->events = new EventManager; + $this->events = new EventManager(); } /** @@ -54,16 +55,8 @@ public function getEventListFromManager(EventManager $manager) */ public function getListenersForEvent($event, EventManager $manager) { - $r = new ReflectionProperty($manager, 'events'); - $r->setAccessible(true); - $events = $r->getValue($manager); - - $listenersByPriority = isset($events[$event]) ? $events[$event] : []; - foreach ($listenersByPriority as $priority => & $listeners) { - $listeners = $listeners[0]; - } - - return $listenersByPriority; + $listeners = $manager->getListenersForEvent(new Event($event)); + return iterator_to_array($listeners, false); } public function testAttachShouldAddListenerToEvent() @@ -71,8 +64,6 @@ public function testAttachShouldAddListenerToEvent() $listener = [$this, __METHOD__]; $this->events->attach('test', $listener); $listeners = $this->getListenersForEvent('test', $this->events); - // Get first (and only) priority queue of listeners for event - $listeners = array_shift($listeners); $this->assertCount(1, $listeners); $this->assertContains($listener, $listeners); return [ @@ -99,15 +90,6 @@ public function testAttachShouldAddReturnTheListener($event) $this->assertSame($listener, $this->events->attach($event, $listener)); } - public function testAttachShouldAddEventIfItDoesNotExist() - { - $this->assertAttributeEmpty('events', $this->events); - $listener = $this->events->attach('test', [$this, __METHOD__]); - $events = $this->getEventListFromManager($this->events); - $this->assertNotEmpty($events); - $this->assertContains('test', $events); - } - public function testTriggerShouldTriggerAttachedListeners() { $listener = $this->events->attach('test', [$this, 'handleTestEvent']); @@ -456,7 +438,24 @@ public function testCanInjectSharedManagerDuringConstruction() { $shared = $this->prophesize(SharedEventManagerInterface::class)->reveal(); $events = new EventManager($shared); - $this->assertSame($shared, $events->getSharedManager()); + + $r = new ReflectionProperty($events, 'provider'); + $r->setAccessible(true); + $provider = $r->getValue($events); + + $this->assertInstanceOf(ListenerProvider\PrioritizedAggregateListenerProvider::class, $provider); + + $r = new ReflectionProperty($provider, 'default'); + $r->setAccessible(true); + $decorator = $r->getValue($provider); + + $this->assertInstanceOf(SharedEventManager\SharedEventManagerDecorator::class, $decorator); + + $r = new ReflectionProperty($decorator, 'proxy'); + $r->setAccessible(true); + $test = $r->getValue($decorator); + + $this->assertSame($shared, $test); } public function invalidEventsForAttach() @@ -495,8 +494,8 @@ public function testCanClearAllListenersForAnEvent() $this->events->attach($event, $listener); } - $this->assertEquals($events, $this->getEventListFromManager($this->events)); $this->events->clearListeners('foo'); + $this->assertCount( 0, $this->getListenersForEvent('foo', $this->events), @@ -572,8 +571,6 @@ public function testDetachDoesNothingIfEventIsNotPresentInManager() $this->events->attach('foo', $callback); $this->events->detach($callback, 'bar'); $listeners = $this->getListenersForEvent('foo', $this->events); - // get first (and only) priority queue from listeners - $listeners = array_shift($listeners); $this->assertContains($callback, $listeners); } @@ -604,8 +601,6 @@ public function testCanDetachWildcardListeners() // Next, verify it's not in any of the specific event queues foreach ($events as $event) { $listeners = $this->getListenersForEvent($event, $this->events); - // Get listeners for first and only priority queue - $listeners = array_shift($listeners); $this->assertCount(1, $listeners); $this->assertNotContains($wildcardListener, $listeners); } @@ -660,8 +655,6 @@ public function testCanDetachASingleListenerFromAnEventWithMultipleListeners() $this->events->attach('foo', $alternateListener); $listeners = $this->getListenersForEvent('foo', $this->events); - // Get the listeners for the first priority queue - $listeners = array_shift($listeners); $this->assertCount( 2, $listeners, @@ -677,8 +670,6 @@ public function testCanDetachASingleListenerFromAnEventWithMultipleListeners() $this->events->detach($listener, 'foo'); $listeners = $this->getListenersForEvent('foo', $this->events); - // Get the listeners for the first priority queue - $listeners = array_shift($listeners); $this->assertCount( 1, $listeners, @@ -722,7 +713,7 @@ public function testDetachRemovesAllOccurrencesOfListenerForEvent() } $listeners = $this->getListenersForEvent('foo', $this->events); - $this->assertCount(5, $listeners); + $this->assertCount(5, $listeners, var_export($listeners, true)); $this->events->detach($listener, 'foo'); @@ -731,38 +722,6 @@ public function testDetachRemovesAllOccurrencesOfListenerForEvent() $this->assertNotContains($listener, $listeners); } - public function eventsMissingNames() - { - $event = $this->prophesize(EventInterface::class); - $event->getName()->willReturn(''); - $callback = function ($result) { - }; - - // @codingStandardsIgnoreStart - // [ event, method to trigger, callback ] - return [ - 'trigger-empty-string' => ['', 'trigger', null], - 'trigger-until-empty-string' => ['', 'triggerUntil', $callback], - 'trigger-event-empty-name' => [$event->reveal(), 'triggerEvent', null], - 'trigger-event-until-empty-name' => [$event->reveal(), 'triggerEventUntil', $callback], - ]; - // @codingStandardsIgnoreEnd - } - - /** - * @dataProvider eventsMissingNames - */ - public function testTriggeringAnEventWithAnEmptyNameRaisesAnException($event, $method, $callback) - { - $this->expectException(Exception\RuntimeException::class); - $this->expectExceptionMessage('missing a name'); - if ($callback) { - $this->events->$method($callback, $event); - } else { - $this->events->$method($event); - } - } - public function testTriggerEventAcceptsEventInstanceAndTriggersListeners() { $event = $this->prophesize(EventInterface::class); diff --git a/test/EventManagerWithProviderTest.php b/test/EventManagerWithProviderTest.php new file mode 100644 index 0000000..a46031a --- /dev/null +++ b/test/EventManagerWithProviderTest.php @@ -0,0 +1,117 @@ +prophesize(ListenerProviderInterface::class)->reveal(); + + $manager = EventManager::createUsingListenerProvider($provider); + + $this->assertInstanceOf(EventManager::class, $manager); + $this->assertAttributeSame($provider, 'provider', $manager); + $this->assertAttributeEmpty('prioritizedProvider', $manager); + + return $manager; + } + + public function testCanCreateInstanceWithPrioritizedListenerProvider() + { + $provider = $this->prophesize(ListenerProviderInterface::class); + $provider->willImplement(PrioritizedListenerAttachmentInterface::class); + + $manager = EventManager::createUsingListenerProvider($provider->reveal()); + + $this->assertInstanceOf(EventManager::class, $manager); + $this->assertAttributeSame($provider->reveal(), 'provider', $manager); + $this->assertAttributeSame($provider->reveal(), 'prioritizedProvider', $manager); + } + + public function attachableProviderMethods() + { + $listener = function ($e) { + }; + return [ + 'attach' => ['attach', ['foo', $listener, 100]], + 'attachWildcardListener' => ['attachWildcardListener', [$listener, 100]], + 'detach' => ['detach', [$listener, 'foo']], + 'detachWildcardListener' => ['detachWildcardListener', [$listener]], + 'clearListeners' => ['clearListeners', ['foo']], + ]; + } + + /** + * @dataProvider attachableProviderMethods + * @depends testCanCreateInstanceWithListenerProvider + * @param string $method Method to call on manager + * @param array $arguments Arguments to pass to $method + * @param EventManager $manager Event manager on which to call $method + */ + public function testAttachmentMethodsRaiseExceptionForNonAttachableProvider( + $method, + array $arguments, + EventManager $manager + ) { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('instance is not of type ' . PrioritizedListenerAttachmentInterface::class); + $manager->{$method}(...$arguments); + } + + /** + * @dataProvider attachableProviderMethods + * @depends testCanCreateInstanceWithPrioritizedListenerProvider + * @param string $method Method to call on manager + * @param array $arguments Arguments to pass to $method + */ + public function testAttachmentMethodsProxyToAttachableProvider($method, array $arguments) + { + // Creating instances here, because prophecies cannot be passed as dependencies + $provider = $this->prophesize(ListenerProviderInterface::class); + $provider->willImplement(PrioritizedListenerAttachmentInterface::class); + $manager = EventManager::createUsingListenerProvider($provider->reveal()); + + $manager->{$method}(...$arguments); + + $provider->{$method}(...$arguments)->shouldHaveBeenCalledTimes(1); + } + + public function testGetListenersForEventProxiesToProvider() + { + $event = (object) ['name' => 'test']; + $listener = function ($e) { + }; + + $listeners = [ + clone $listener, + clone $listener, + clone $listener, + ]; + + $provider = $this->prophesize(ListenerProviderInterface::class); + $provider + ->getListenersForEvent($event, []) + ->willReturn($listeners); + + $manager = EventManager::createUsingListenerProvider($provider->reveal()); + + $test = $manager->getListenersForEvent($event); + + $this->assertSame($listeners, iterator_to_array($test, false)); + } +} diff --git a/test/ListenerProvider/AbstractListenerSubscriberTest.php b/test/ListenerProvider/AbstractListenerSubscriberTest.php new file mode 100644 index 0000000..46b9482 --- /dev/null +++ b/test/ListenerProvider/AbstractListenerSubscriberTest.php @@ -0,0 +1,19 @@ +container = $this->prophesize(ContainerInterface::class); + } + + public function invalidListenerTypes() + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'string' => ['listener'], + 'array' => [['listener']], + 'object' => [(object) ['event' => 'event', 'listener' => 'listener', 'method' => 'method']], + ]; + } + + /** + * @dataProvider invalidListenerTypes + */ + public function testPassingInvalidListenerTypesAtInstantiationRaisesException($listener) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('only accepts ' . LazyListener::class . ' instances'); + new LazyListenerSubscriber([$listener]); + } + + public function testPassingLazyListenersMissingAnEventAtInstantiationRaisesException() + { + $listener = $this->prophesize(LazyListener::class); + $listener->getEvent()->willReturn(null); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('compose a non-empty string event'); + new LazyListenerSubscriber([$listener->reveal()]); + } + + public function testAttachesLazyListenersToProviderUsingEventAndPriority() + { + $listener = $this->prophesize(LazyListener::class); + $listener->getEvent()->willReturn('test'); + $listener->getPriority(1000)->willReturn(100); + + $subscriber = new LazyListenerSubscriber([$listener->reveal()]); + + $provider = $this->prophesize(PrioritizedListenerAttachmentInterface::class); + $provider->attach('test', $listener->reveal(), 100)->shouldBeCalledTimes(1); + + $this->assertNull($subscriber->attach($provider->reveal(), 1000)); + + return [ + 'listener' => $listener, + 'subscriber' => $subscriber, + 'provider' => $provider, + ]; + } + + /** + * @depends testAttachesLazyListenersToProviderUsingEventAndPriority + */ + public function testDetachesLazyListenersFromProviderUsingEvent(array $dependencies) + { + $listener = $dependencies['listener']; + $subscriber = $dependencies['subscriber']; + $provider = $dependencies['provider']; + + $provider->detach($listener->reveal(), 'test')->shouldBeCalledTimes(1); + $this->assertNull($subscriber->detach($provider->reveal())); + } +} diff --git a/test/ListenerProvider/LazyListenerTest.php b/test/ListenerProvider/LazyListenerTest.php new file mode 100644 index 0000000..17e11b6 --- /dev/null +++ b/test/ListenerProvider/LazyListenerTest.php @@ -0,0 +1,151 @@ +container = $this->prophesize(ContainerInterface::class); + } + + public function invalidListenerTypes() + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'empty' => [''], + 'array' => [['event']], + 'object' => [(object) ['event' => 'event']], + ]; + } + + /** + * @dataProvider invalidListenerTypes + * @param mixed $listener + */ + public function testConstructorRaisesExceptionForInvalidListenerType($listener) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires a non-empty string $listener argument'); + new LazyListener($this->container->reveal(), $listener); + } + + public function invalidMethodArguments() + { + return array_merge($this->invalidListenerTypes(), [ + 'digit-first' => ['0invalid'], + 'with-whitespace' => ['also invalid'], + 'with-dash' => ['also-invalid'], + 'with-symbols' => ['alsoInv@l!d'], + ]); + } + + /** + * @dataProvider invalidMethodArguments + * @param mixed $method + */ + public function testConstructorRaisesExceptionForInvalidMethodArgument($method) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires a valid string $method argument'); + new LazyListener($this->container->reveal(), 'valid-listener-name', $method); + } + + public function invalidEventArguments() + { + $types = $this->invalidListenerTypes(); + unset($types['null']); + return $types; + } + + /** + * @dataProvider invalidEventArguments + * @param mixed $event + */ + public function testConstructorRaisesExceptionForInvalidEventArgument($event) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires a null or non-empty string $event argument'); + new LazyListener($this->container->reveal(), 'valid-listener-name', '__invoke', $event); + } + + public function testGetEventReturnsNullWhenNoEventProvidedToConstructor() + { + $listener = new LazyListener($this->container->reveal(), 'valid-listener-name'); + $this->assertNull($listener->getEvent()); + } + + public function testGetEventReturnsEventNameWhenEventProvidedToConstructor() + { + $listener = new LazyListener($this->container->reveal(), 'valid-listener-name', '__invoke', 'test'); + $this->assertEquals('test', $listener->getEvent()); + } + + public function testGetPriorityReturnsPriorityDefaultWhenNoPriorityProvidedToConstructor() + { + $listener = new LazyListener($this->container->reveal(), 'valid-listener-name'); + $this->assertEquals(100, $listener->getPriority(100)); + } + + public function testGetPriorityReturnsIntegerPriorityValueWhenPriorityProvidedToConstructor() + { + $listener = new LazyListener($this->container->reveal(), 'valid-listener-name', '__invoke', 'test', 100); + $this->assertEquals(100, $listener->getPriority()); + } + + public function testGetPriorityReturnsIntegerPriorityValueWhenPriorityProvidedToConstructorAndToMethod() + { + $listener = new LazyListener($this->container->reveal(), 'valid-listener-name', '__invoke', 'test', 100); + $this->assertEquals(100, $listener->getPriority(1000)); + } + + public function methodsToInvoke() + { + return [ + '__invoke' => ['__invoke', '__invoke'], + 'run' => ['run', 'run'], + 'onEvent' => ['onEvent', 'onEvent'], + ]; + } + + /** + * @dataProvider methodsToInvoke + * @param string $method + * @param string $expected + */ + public function testInvocationInvokesMethodDefinedInListener($method, $expected) + { + $listener = new TestAsset\MultipleListener(); + + $this->container + ->get('listener') + ->willReturn($listener) + ->shouldBeCalledTimes(1); + + $event = (object) ['value' => null]; + + $lazyListener = new LazyListener($this->container->reveal(), 'listener', $method); + + $lazyListener($event); + + $this->assertEquals($expected, $event->value); + } +} diff --git a/test/ListenerProvider/ListenerSubscriberTraitTest.php b/test/ListenerProvider/ListenerSubscriberTraitTest.php new file mode 100644 index 0000000..2a1d938 --- /dev/null +++ b/test/ListenerProvider/ListenerSubscriberTraitTest.php @@ -0,0 +1,73 @@ +prophesize(PrioritizedListenerAttachmentInterface::class); + $provider->attach('foo.bar', $listener1, 100)->will(function ($args) { + return $args[1]; + }); + $provider->attach('foo.baz', $listener2, 100)->will(function ($args) { + return $args[1]; + }); + + $subscriber = $this->createProvider(function ($provider, $priority) use ($listener1, $listener2) { + $this->listeners[] = $provider->attach('foo.bar', $listener1, $priority); + $this->listeners[] = $provider->attach('foo.baz', $listener2, $priority); + }); + + $subscriber->attach($provider->reveal(), 100); + + $this->assertAttributeSame([$listener1, $listener2], 'listeners', $subscriber); + + return [ + 'subscriber' => $subscriber, + 'provider' => $provider, + 'listener1' => $listener1, + 'listener2' => $listener2, + ]; + } + + /** + * @depends testSubscriberAttachesListeners + * @param array $dependencies + */ + public function testDetachRemovesAttachedListeners(array $dependencies) + { + $subscriber = $dependencies['subscriber']; + $provider = $dependencies['provider']; + + $provider->detach($dependencies['listener1'])->shouldBeCalledTimes(1); + $provider->detach($dependencies['listener2'])->shouldBeCalledTimes(1); + + $subscriber->detach($provider->reveal()); + $this->assertAttributeSame([], 'listeners', $subscriber); + } +} diff --git a/test/ListenerProvider/PrioritizedAggregateListenerProviderTest.php b/test/ListenerProvider/PrioritizedAggregateListenerProviderTest.php new file mode 100644 index 0000000..922d4af --- /dev/null +++ b/test/ListenerProvider/PrioritizedAggregateListenerProviderTest.php @@ -0,0 +1,108 @@ +prophesize(ListenerProviderInterface::class)->reveal(); + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'string' => ['invalid'], + 'array' => [['invalid']], + 'object' => [(object) ['value' => 'invalid']], + 'non-prioritized-provider' => [$genericProvider], + ]; + } + + /** + * @dataProvider invalidProviders + * @param mixed $provider + */ + public function testConstructorRaisesExceptionForInvalidProviders($provider) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage(PrioritizedListenerProviderInterface::class); + new PrioritizedAggregateListenerProvider([$provider]); + } + + public function testIteratesProvidersInOrderExpected() + { + $event = new Event(); + $event->setName('test'); + + $baseListener = function () { + }; + + $first = clone $baseListener; + $second = clone $baseListener; + $third = clone $baseListener; + $fourth = clone $baseListener; + $fifth = clone $baseListener; + $sixth = clone $baseListener; + $seventh = clone $baseListener; + $eighth = clone $baseListener; + $ninth = clone $baseListener; + + $provider = new PrioritizedListenerProvider(); + $provider->attachWildcardListener($first); + $provider->attach(Event::class, $second); + $provider->attach('test', $third); + + $identifiedProvider = new PrioritizedIdentifierListenerProvider(); + $identifiedProvider->attach(Event::class, '*', $fourth); + $identifiedProvider->attach(Event::class, Event::class, $fifth); + $identifiedProvider->attach(Event::class, 'test', $sixth); + $identifiedProvider->attach('*', '*', $seventh); + $identifiedProvider->attach('*', Event::class, $eighth); + $identifiedProvider->attach('*', 'test', $ninth); + + $aggregateProvider = new PrioritizedAggregateListenerProvider([ + $provider, + $identifiedProvider, + ]); + + $prioritizedListeners = []; + $index = 1; + + foreach ($aggregateProvider->getListenersForEvent($event, [Event::class]) as $listener) { + $prioritizedListeners[$index] = spl_object_hash($listener); + $index += 1; + } + + $expected = [ + 1 => spl_object_hash($third), + 2 => spl_object_hash($second), + 3 => spl_object_hash($first), + 4 => spl_object_hash($sixth), + 5 => spl_object_hash($fifth), + 6 => spl_object_hash($fourth), + 7 => spl_object_hash($ninth), + 8 => spl_object_hash($eighth), + 9 => spl_object_hash($seventh), + ]; + + $this->assertSame($expected, $prioritizedListeners); + } +} diff --git a/test/ListenerProvider/PrioritizedIdentifierListenerProviderTest.php b/test/ListenerProvider/PrioritizedIdentifierListenerProviderTest.php new file mode 100644 index 0000000..5a6da2e --- /dev/null +++ b/test/ListenerProvider/PrioritizedIdentifierListenerProviderTest.php @@ -0,0 +1,322 @@ +callback = function ($e) { + }; + $this->provider = new PrioritizedIdentifierListenerProvider(); + } + + /** + * @param string[] $identifiers + * @param string|object $event + * @param int $priority + * @return iterable + */ + public function getListeners( + PrioritizedIdentifierListenerProvider $provider, + array $identifiers, + $event, + $priority = 1 + ) { + $priority = (int) $priority; + $listeners = $provider->getListenersForEventByPriority($event, $identifiers); + if (! isset($listeners[$priority])) { + return []; + } + return $listeners[$priority]; + } + + public function invalidIdentifiers() + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'empty-string' => [''], + 'array' => [['test', 'foo']], + 'non-traversable-object' => [(object) ['foo' => 'bar']], + ]; + } + + /** + * @dataProvider invalidIdentifiers + */ + public function testAttachRaisesExceptionForInvalidIdentifer($identifier) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('identifier'); + $this->provider->attach($identifier, 'foo', $this->callback); + } + + public function invalidEventNames() + { + return [ + 'null' => [null], + 'true' => [true], + 'false' => [false], + 'zero' => [0], + 'int' => [1], + 'zero-float' => [0.0], + 'float' => [1.1], + 'empty-string' => [''], + 'array' => [['foo', 'bar']], + 'non-traversable-object' => [(object) ['foo' => 'bar']], + ]; + } + + /** + * @dataProvider invalidEventNames + */ + public function testAttachRaisesExceptionForInvalidEvent($event) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('event'); + $this->provider->attach('foo', $event, $this->callback); + } + + public function testCanAttachListeners() + { + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertSame([$this->callback], $listeners); + } + + public function detachIdentifierAndEvent() + { + return [ + 'null-identifier-and-null-event' => [null, null], + 'same-identifier-and-null-event' => ['IDENTIFIER', null], + 'null-identifier-and-same-event' => [null, 'EVENT'], + 'same-identifier-and-same-event' => ['IDENTIFIER', 'EVENT'], + ]; + } + + /** + * @dataProvider detachIdentifierAndEvent + */ + public function testCanDetachListenersUsingIdentifierAndEvent($identifier, $event) + { + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->detach($this->callback, $identifier, $event); + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertSame([], $listeners); + } + + public function testDetachDoesNothingIfIdentifierNotInProvider() + { + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->detach($this->callback, 'DIFFERENT-IDENTIFIER'); + + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertSame([$this->callback], $listeners); + } + + public function testDetachDoesNothingIfIdentifierDoesNotContainEvent() + { + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->detach($this->callback, 'IDENTIFIER', 'DIFFERENT-EVENT'); + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertSame([$this->callback], $listeners); + } + + public function testProviderReturnsEmptyListWhenNoListenersAttachedForEventAndIdentifier() + { + $test = $this->provider->getListenersForEvent('EVENT', ['IDENTIFIER']); + // instead of assertInternalType('iterable'), which requires PHP 7.1+: + $this->assertTrue(is_array($test) || $test instanceof Traversable); + $this->assertCount(0, $test); + } + + public function testProviderReturnsAllListenersIncludingWildcardListenersForEvent() + { + $callback1 = clone $this->callback; + $callback2 = clone $this->callback; + $callback3 = clone $this->callback; + $callback4 = clone $this->callback; + + $this->provider->attach('IDENTIFIER', 'EVENT', $callback1); + $this->provider->attach('IDENTIFIER', '*', $callback2); + $this->provider->attach('*', 'EVENT', $callback3); + $this->provider->attach('IDENTIFIER', 'EVENT', $callback4); + + $test = $this->getListeners($this->provider, [ 'IDENTIFIER' ], 'EVENT'); + $this->assertEquals([ + $callback1, + $callback4, + $callback2, + $callback3, + ], $test); + } + + public function testClearListenersWhenNoEventIsProvidedRemovesAllListenersForTheIdentifier() + { + $wildcardIdentifier = clone $this->callback; + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->attach('IDENTIFIER', '*', $this->callback); + $this->provider->attach('*', 'EVENT', $wildcardIdentifier); + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + + $this->provider->clearListeners('IDENTIFIER'); + + $listeners = $this->getListeners($this->provider, [ 'IDENTIFIER' ], 'EVENT'); + $this->assertSame( + [$wildcardIdentifier], + $listeners, + sprintf( + 'Listener list should contain only wildcard identifier listener; received: %s', + var_export($listeners, 1) + ) + ); + } + + public function testClearListenersRemovesAllExplicitListenersForGivenIdentifierAndEvent() + { + $alternate = clone $this->callback; + $wildcard = clone $this->callback; + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->attach('IDENTIFIER', 'ALTERNATE', $alternate); + $this->provider->attach('*', 'EVENT', $wildcard); + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + + $this->provider->clearListeners('IDENTIFIER', 'EVENT'); + + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertInternalType('array', $listeners, 'Unexpected return value from getListeners() for event EVENT'); + $this->assertCount(1, $listeners); + $listener = array_shift($listeners); + $this->assertSame($wildcard, $listener, sprintf( + 'Expected only wildcard listener on event EVENT after clearListener operation; received: %s', + var_export($listener, 1) + )); + + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'ALTERNATE'); + $this->assertInternalType( + 'array', + $listeners, + 'Unexpected return value from getListeners() for event ALTERNATE' + ); + $this->assertCount(1, $listeners); + $listener = array_shift($listeners); + $this->assertSame($alternate, $listener, 'Unexpected listener list for event ALTERNATE'); + } + + public function testClearListenersDoesNotRemoveWildcardListenersWhenEventIsProvided() + { + $wildcardEventListener = clone $this->callback; + $wildcardIdentifierListener = clone $this->callback; + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + $this->provider->attach('IDENTIFIER', '*', $wildcardEventListener); + $this->provider->attach('*', 'EVENT', $wildcardIdentifierListener); + $this->provider->attach('IDENTIFIER', 'EVENT', $this->callback); + + // REMOVE + $this->provider->getListenersForEventByPriority('EVENT', ['IDENTIFIER']); + + $this->provider->clearListeners('IDENTIFIER', 'EVENT'); + + $listeners = $this->getListeners($this->provider, ['IDENTIFIER'], 'EVENT'); + $this->assertContains( + $wildcardEventListener, + $listeners, + 'Event listener list after clear operation does not include wildcard event listener' + ); + $this->assertContains( + $wildcardIdentifierListener, + $listeners, + 'Event listener list after clear operation does not include wildcard identifier listener' + ); + $this->assertNotContains( + $this->callback, + $listeners, + 'Event listener list after clear operation includes explicitly attached listener and should not' + ); + } + + public function testClearListenersDoesNothingIfNoEventsRegisteredForIdentifier() + { + $callback = clone $this->callback; + $this->provider->attach('IDENTIFIER', 'NOTEVENT', $this->callback); + $this->provider->attach('*', 'EVENT', $this->callback); + + $this->provider->clearListeners('IDENTIFIER', 'EVENT'); + + // getListeners() always pulls in wildcard listeners + $this->assertEquals([$this->callback], $this->getListeners($this->provider, [ 'IDENTIFIER' ], 'EVENT')); + } + + public function invalidIdentifiersAndEvents() + { + $types = $this->invalidIdentifiers(); + unset($types['null']); + return $types; + } + + /** + * @dataProvider invalidIdentifiersAndEvents + */ + public function testDetachingWithInvalidIdentifierTypeRaisesException($identifier) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid identifier'); + $this->provider->detach($this->callback, $identifier, 'test'); + } + + /** + * @dataProvider invalidIdentifiersAndEvents + */ + public function testDetachingWithInvalidEventTypeRaisesException($eventName) + { + $this->provider->attach('IDENTIFIER', '*', $this->callback); + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid event name'); + $this->provider->detach($this->callback, 'IDENTIFIER', $eventName); + } + + public function invalidEventNamesForFetchingListeners() + { + $types = $this->invalidEventNames(); + unset($types['non-traversable-object']); + return $types; + } + + /** + * @dataProvider invalidEventNamesForFetchingListeners + */ + public function testRetrievingListenersRaisesExceptionForInvalidEventName($eventName) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('must be a non-empty'); + $this->provider->getListenersForEventByPriority($eventName, ['IDENTIFIER']); + } + + /** + * @dataProvider invalidIdentifiers + */ + public function testRetrievingListenersRaisesExceptionForInvalidIdentifier($identifier) + { + $this->expectException(Exception\InvalidArgumentException::class); + $this->expectExceptionMessage('must be non-empty'); + $this->provider->getListenersForEventByPriority('EVENT', [$identifier]); + } +} diff --git a/test/ListenerProvider/PrioritizedListenerProviderTest.php b/test/ListenerProvider/PrioritizedListenerProviderTest.php new file mode 100644 index 0000000..1964fdc --- /dev/null +++ b/test/ListenerProvider/PrioritizedListenerProviderTest.php @@ -0,0 +1,241 @@ +provider = new PrioritizedListenerProvider(); + } + + public function createEvent() + { + $accumulator = new SplQueue(); + $event = new Event(); + $event->setName('test'); + $event->setTarget($this); + $event->setParams(compact('accumulator')); + return $event; + } + + public function createListener($return) + { + return function ($event) use ($return) { + $event->getParam('accumulator')->enqueue($return); + }; + } + + /** + * @param object $event + */ + public function triggerListeners(PrioritizedListenerProvider $provider, $event) + { + foreach ($provider->getListenersForEvent($event) as $listener) { + $listener($event); + } + } + + /** + * @param iterable $listeners + * @return array + */ + public function flattenListeners($listeners) + { + $flattened = []; + foreach ($listeners as $listener) { + $flattened[] = $listener; + } + return $flattened; + } + + public function testIteratesListenersOfDifferentPrioritiesInPriorityOrder() + { + for ($i = -1; $i < 5; $i += 1) { + $this->provider->attach('test', $this->createListener($i), $i); + } + + $event = $this->createEvent(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event->getParam('accumulator')); + $this->assertEquals( + [4, 3, 2, 1, 0, -1], + $values, + sprintf("Did not receive values in priority order: %s\n", var_export($values, 1)) + ); + } + + public function testIteratesListenersOfSamePriorityInAttachmentOrder() + { + for ($i = -1; $i < 5; $i += 1) { + $this->provider->attach('test', $this->createListener($i)); + } + + $event = $this->createEvent(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event->getParam('accumulator')); + $this->assertEquals( + [-1, 0, 1, 2, 3, 4], + $values, + sprintf("Did not receive values in attachment order: %s\n", var_export($values, 1)) + ); + } + + public function testIteratesWildcardListenersAfterExplicitListenersOfSamePriority() + { + $this->provider->attachWildcardListener($this->createListener(2), 5); + $this->provider->attach('test', $this->createListener(1), 5); + $this->provider->attachWildcardListener($this->createListener(3), 5); + + $event = $this->createEvent(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event->getParam('accumulator')); + $this->assertEquals( + [1, 2, 3], + $values, + sprintf("Did not receive wildcard values after explicit listeners: %s\n", var_export($values, 1)) + ); + } + + public function testIteratesListenersAttachedToClassNameAfterThoseByNameWhenOfSamePriority() + { + $this->provider->attach(Event::class, $this->createListener(2), 5); + $this->provider->attach('test', $this->createListener(1), 5); + $this->provider->attach(Event::class, $this->createListener(3), 5); + + $event = $this->createEvent(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event->getParam('accumulator')); + $this->assertEquals( + [1, 2, 3], + $values, + sprintf("Did not receive class-name values after event-name values: %s\n", var_export($values, 1)) + ); + } + + public function testIteratesListenersAttachedToClassNameBeforeWildcardsWhenOfSamePriority() + { + $this->provider->attachWildcardListener($this->createListener(2), 5); + $this->provider->attach(Event::class, $this->createListener(1), 5); + $this->provider->attachWildcardListener($this->createListener(3), 5); + + $event = $this->createEvent(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event->getParam('accumulator')); + $this->assertEquals( + [1, 2, 3], + $values, + sprintf("Did not receive class-name values before wildcard values: %s\n", var_export($values, 1)) + ); + } + + public function testCanAttachAndIterateUsingOnlyEventClass() + { + $expected = ['value']; + $this->provider->attach(SplQueue::class, function (SplQueue $event) { + $event->enqueue('value'); + }); + + $event = new SplQueue(); + $this->triggerListeners($this->provider, $event); + + $values = iterator_to_array($event); + $this->assertSame($values, $expected); + } + + public function testCanDetachPreviouslyAttachedListenerFromEvent() + { + $listener = function ($event) { + }; + $this->provider->attach('test', $listener); + + $event = $this->createEvent(); + $listeners = iterator_to_array($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener], $listeners, 'Expected one listener for event; none found?'); + + $this->provider->detach($listener, 'test'); + $listeners = iterator_to_array($this->provider->getListenersForEvent($event)); + $this->assertSame([], $listeners, 'Listener found after detachment, and should not be'); + } + + public function testCanDetachListenerFromAllEventsUsingNullEventToDetach() + { + $listener = function ($event) { + }; + $this->provider->attach('test', $listener); + $this->provider->attach(Event::class, $listener); + + $event = $this->createEvent(); + $listeners = $this->flattenListeners($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener, $listener], $listeners); + + $this->provider->detach($listener); + $listeners = iterator_to_array($this->provider->getListenersForEvent($event)); + $this->assertSame([], $listeners, 'Listener found after detachment, and should not be'); + } + + public function testCanDetachListenerFromAllEventsViaDetachWildcardListener() + { + $listener = function ($event) { + }; + $this->provider->attach('test', $listener); + $this->provider->attach(Event::class, $listener); + + $event = $this->createEvent(); + $listeners = $this->flattenListeners($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener, $listener], $listeners); + + $this->provider->detachWildcardListener($listener); + $listeners = iterator_to_array($this->provider->getListenersForEvent($event)); + $this->assertSame([], $listeners, 'Listeners found after detachment, and should not be'); + } + + public function testCanDetachWildcardListenerFromAllEvents() + { + $listener = function ($event) { + }; + $this->provider->attachWildcardListener($listener); + $this->provider->attach('test', $listener); + $this->provider->attach(Event::class, $listener); + + $event = $this->createEvent(); + $listeners = $this->flattenListeners($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener, $listener, $listener], $listeners); + + $this->provider->detachWildcardListener($listener); + $listeners = iterator_to_array($this->provider->getListenersForEvent($event)); + $this->assertSame([], $listeners, 'Listeners found after detachment, and should not be'); + } + + public function testCanClearListenersForASingleEventName() + { + $listener = function ($event) { + }; + $this->provider->attachWildcardListener($listener); + $this->provider->attach('test', $listener); + $this->provider->attach(Event::class, $listener); + + $event = $this->createEvent(); + $listeners = $this->flattenListeners($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener, $listener, $listener], $listeners); + + $this->provider->clearListeners('test'); + $listeners = $this->flattenListeners($this->provider->getListenersForEvent($event)); + $this->assertSame([$listener, $listener], $listeners); + } +} diff --git a/test/ListenerProvider/TestAsset/CallbackSubscriber.php b/test/ListenerProvider/TestAsset/CallbackSubscriber.php new file mode 100644 index 0000000..ec4a904 --- /dev/null +++ b/test/ListenerProvider/TestAsset/CallbackSubscriber.php @@ -0,0 +1,31 @@ +attachmentCallback = $attachmentCallback; + } + + public function attach(PrioritizedListenerAttachmentInterface $provider, $priority = 1) + { + $attachmentCallback = $this->attachmentCallback->bindTo($this, $this); + $attachmentCallback($provider, $priority); + } +} diff --git a/test/ListenerProvider/TestAsset/ExtendedCallbackSubscriber.php b/test/ListenerProvider/TestAsset/ExtendedCallbackSubscriber.php new file mode 100644 index 0000000..0d3bdaa --- /dev/null +++ b/test/ListenerProvider/TestAsset/ExtendedCallbackSubscriber.php @@ -0,0 +1,28 @@ +attachmentCallback = $attachmentCallback; + } + + public function attach(PrioritizedListenerAttachmentInterface $provider, $priority = 1) + { + $attachmentCallback = $this->attachmentCallback->bindTo($this, $this); + $attachmentCallback($provider, $priority); + } +} diff --git a/test/ListenerProvider/TestAsset/MultipleListener.php b/test/ListenerProvider/TestAsset/MultipleListener.php new file mode 100644 index 0000000..4334adb --- /dev/null +++ b/test/ListenerProvider/TestAsset/MultipleListener.php @@ -0,0 +1,26 @@ +value = __FUNCTION__; + } + + public function run($e) + { + $e->value = __FUNCTION__; + } + + public function onEvent($e) + { + $e->value = __FUNCTION__; + } +} diff --git a/test/SharedEventManagerTest.php b/test/SharedEventManagerTest.php index 9ec783e..a68639a 100644 --- a/test/SharedEventManagerTest.php +++ b/test/SharedEventManagerTest.php @@ -284,15 +284,16 @@ public function testDetachingWithInvalidEventTypeRaisesException($eventName) $this->manager->detach($this->callback, 'IDENTIFIER', $eventName); } - public function invalidListenersAndEventNamesForFetchingListeners() + public function invalidEventNamesForFetchingListeners() { $events = $this->invalidIdentifiers(); $events['wildcard'] = ['*']; + unset($events['non-traversable-object']); return $events; } /** - * @dataProvider invalidListenersAndEventNamesForFetchingListeners + * @dataProvider invalidEventNamesForFetchingListeners */ public function testGetListenersRaisesExceptionForInvalidEventName($eventName) { @@ -302,12 +303,12 @@ public function testGetListenersRaisesExceptionForInvalidEventName($eventName) } /** - * @dataProvider invalidListenersAndEventNamesForFetchingListeners + * @dataProvider invalidIdentifiers */ public function testGetListenersRaisesExceptionForInvalidIdentifier($identifier) { $this->expectException(Exception\InvalidArgumentException::class); - $this->expectExceptionMessage('non-empty, non-wildcard'); + $this->expectExceptionMessage('non-empty'); $this->manager->getListeners([$identifier], 'EVENT'); } } diff --git a/test/Test/EventListenerIntrospectionTraitTest.php b/test/Test/EventListenerIntrospectionTraitTest.php index 0629b86..47996bc 100644 --- a/test/Test/EventListenerIntrospectionTraitTest.php +++ b/test/Test/EventListenerIntrospectionTraitTest.php @@ -52,8 +52,6 @@ public function testGetListenersForEventReturnsIteratorOfListenersForEventInPrio $this->events->attach('foo', $callback2, 5); $listeners = $this->getListenersForEvent('foo', $this->events); - $this->assertInstanceOf(Traversable::class, $listeners); - $listeners = iterator_to_array($listeners); $this->assertEquals([ $callback5, @@ -81,8 +79,6 @@ public function testGetListenersForEventReturnsIteratorOfListenersInAttachmentOr $this->events->attach('foo', $callback2); $listeners = $this->getListenersForEvent('foo', $this->events); - $this->assertInstanceOf(Traversable::class, $listeners); - $listeners = iterator_to_array($listeners); $this->assertEquals([ $callback5, @@ -110,8 +106,6 @@ public function testGetListenersForEventCanReturnPriorityKeysWhenRequested() $this->events->attach('foo', $callback2, 5); $listeners = $this->getListenersForEvent('foo', $this->events, true); - $this->assertInstanceOf(Traversable::class, $listeners); - $listeners = iterator_to_array($listeners); $this->assertEquals([ 1 => $callback5,