From e35e350e481594fd2c15be6556a6f981099b6922 Mon Sep 17 00:00:00 2001 From: Alexander Miertsch Date: Fri, 22 May 2015 21:00:27 +0200 Subject: [PATCH] Patch-3: Add ProophActionEventDispatcher --- src/Event/ActionEvent.php | 2 +- src/Event/DefaultActionEvent.php | 178 ++++++++++++++++ src/Event/DefaultListenerHandler.php | 46 ++++ src/Event/ListenerHandler.php | 2 +- src/Event/ProophActionEventDispatcher.php | 156 ++++++++++++++ tests/Event/DefaultActionEventTest.php | 175 ++++++++++++++++ .../Event/ProophActionEventDispatcherTest.php | 196 ++++++++++++++++++ .../ZF2}/Zf2ActionEventDispatcherTest.php | 0 8 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 src/Event/DefaultActionEvent.php create mode 100644 src/Event/DefaultListenerHandler.php create mode 100644 src/Event/ProophActionEventDispatcher.php create mode 100644 tests/Event/DefaultActionEventTest.php create mode 100644 tests/Event/ProophActionEventDispatcherTest.php rename tests/{ => Event/ZF2}/Zf2ActionEventDispatcherTest.php (100%) diff --git a/src/Event/ActionEvent.php b/src/Event/ActionEvent.php index 7d8d09b..d160af3 100644 --- a/src/Event/ActionEvent.php +++ b/src/Event/ActionEvent.php @@ -71,7 +71,7 @@ public function setTarget($target); /** * Set event parameters * - * @param string $params + * @param array|\ArrayAccess $params * @return void */ public function setParams($params); diff --git a/src/Event/DefaultActionEvent.php b/src/Event/DefaultActionEvent.php new file mode 100644 index 0000000..344b766 --- /dev/null +++ b/src/Event/DefaultActionEvent.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * Date: 5/22/15 - 6:59 PM + */ +namespace Prooph\Common\Event; + +/** + * Class DefaultActionEvent + * + * Default implementation of ActionEvent + * + * @package Prooph\Common\Event + * @author Alexander Miertsch + */ +class DefaultActionEvent implements ActionEvent +{ + /** + * @var string + */ + protected $name; + + /** + * @var mixed + */ + protected $target; + + /** + * @var array|\ArrayAccess + */ + protected $params; + + /** + * @var boolean + */ + protected $stopPropagation = false; + + /** + * @param string $name + * @param mixed|null $target + * @param array|\ArrayAccess|null $params + */ + public function __construct($name, $target = null, $params = null) + { + $this->setName($name); + + $this->setTarget($target); + + if ($params === null) { + $params = []; + } + + $this->setParams($params); + } + + /** + * Get event name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Get target/context from which event was triggered + * + * @return null|string|object + */ + public function getTarget() + { + return $this->target; + } + + /** + * Get parameters passed to the event + * + * @return array|\ArrayAccess + */ + public function getParams() + { + return $this->params; + } + + /** + * Get a single parameter by name + * + * @param string $name + * @param mixed $default Default value to return if parameter does not exist + * @return mixed + */ + public function getParam($name, $default = null) + { + return isset($this->params[$name])? $this->params[$name] : $default; + } + + /** + * Set the event name + * + * @param string $name + * @throws \InvalidArgumentException + * @return void + */ + public function setName($name) + { + if (! is_string($name)) { + throw new \InvalidArgumentException("Event name is invalid. Expected string. Got " . gettype($name)); + } + + $this->name = $name; + } + + /** + * Set the event target/context + * + * @param null|string|object $target + * @return void + */ + public function setTarget($target) + { + $this->target = $target; + } + + /** + * Set event parameters + * + * @param array|\ArrayAccess $params + * @throws \InvalidArgumentException + * @return void + */ + public function setParams($params) + { + if (! is_array($params) && ! $params instanceof \ArrayAccess) { + throw new \InvalidArgumentException("Event params are invalid. Expected type is array or \\ArrayAccess. Got " . gettype($params)); + } + + $this->params = $params; + } + + /** + * Set a single parameter by key + * + * @param string $name + * @param mixed $value + * @return void + */ + public function setParam($name, $value) + { + $this->params[$name] = $value; + } + + /** + * Indicate whether or not the parent ActionEventDispatcher should stop propagating events + * + * @param bool $flag + * @return void + */ + public function stopPropagation($flag = true) + { + $this->stopPropagation = $flag; + } + + /** + * Has this event indicated event propagation should stop? + * + * @return bool + */ + public function propagationIsStopped() + { + return $this->stopPropagation; + } +} \ No newline at end of file diff --git a/src/Event/DefaultListenerHandler.php b/src/Event/DefaultListenerHandler.php new file mode 100644 index 0000000..0120a70 --- /dev/null +++ b/src/Event/DefaultListenerHandler.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * Date: 5/22/15 - 8:27 PM + */ +namespace Prooph\Common\Event; + +/** + * Class DefaultListenerHandler + * + * @package Prooph\Common\Event + * @author Alexander Miertsch + */ +final class DefaultListenerHandler implements ListenerHandler +{ + /** + * @var callable|ActionEventListener + */ + private $listener; + + /** + * @param callable|ActionEventListener $listener + * @throws \InvalidArgumentException + */ + public function __construct($listener) + { + if (! $listener instanceof ActionEventListener && !is_callable($listener)) { + throw new \InvalidArgumentException('Given parameter listener should be callable or an instance of ActionEventListener. Got ' . (is_object($listener)? get_class($listener) : gettype($listener))); + } + + $this->listener = $listener; + } + + /** + * @return callable|ActionEventListener + */ + public function getActionEventListener() + { + return $this->listener; + } +} \ No newline at end of file diff --git a/src/Event/ListenerHandler.php b/src/Event/ListenerHandler.php index b9d1fa1..ed58337 100644 --- a/src/Event/ListenerHandler.php +++ b/src/Event/ListenerHandler.php @@ -20,7 +20,7 @@ interface ListenerHandler { /** - * @return ActionEventListener + * @return callable|ActionEventListener */ public function getActionEventListener(); } \ No newline at end of file diff --git a/src/Event/ProophActionEventDispatcher.php b/src/Event/ProophActionEventDispatcher.php new file mode 100644 index 0000000..4a814b0 --- /dev/null +++ b/src/Event/ProophActionEventDispatcher.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * Date: 5/22/15 - 6:57 PM + */ +namespace Prooph\Common\Event; + +/** + * Class ProophActionEventDispatcher + * + * @package Prooph\Common\Event + * @author Alexander Miertsch + */ +class ProophActionEventDispatcher implements ActionEventDispatcher +{ + /** + * Map of event name to listeners array + * + * @var array + */ + protected $events = []; + + /** + * @param null|string $name of the action event + * @param string|object $target of the action event + * @param null|array|\ArrayAccess $params with which the event is initialized + * @return ActionEvent that can be triggered by the ActionEventDispatcher + */ + public function getNewActionEvent($name = null, $target = null, $params = null) + { + if ($name === null) { + $name = 'action_event'; + } + + return new DefaultActionEvent($name, $target, $params); + } + + /** + * Trigger an action event + * + * @param \Prooph\Common\Event\ActionEvent $event + */ + public function dispatch(ActionEvent $event) + { + foreach ($this->getListeners($event) as $listenerHandler) { + $listener = $listenerHandler->getActionEventListener(); + $listener($event); + if ($event->propagationIsStopped()) return; + } + } + + /** + * Trigger an event until the given callback returns a boolean true + * + * The callback is invoked after each listener and gets the action event as only argument + * + * @param \Prooph\Common\Event\ActionEvent $event + * @param callable $callback + */ + public function dispatchUntil(ActionEvent $event, $callback) + { + foreach ($this->getListeners($event) as $listenerHandler) { + $listener = $listenerHandler->getActionEventListener(); + $listener($event); + + if ($event->propagationIsStopped()) return; + if ($callback($event) === true) return; + } + } + + /** + * Attach a listener to an event + * + * @param string $event Name of the event + * @param callable|ActionEventListener $listener + * @param int $priority Priority at which to register listener + * @throws \InvalidArgumentException + * @return ListenerHandler + */ + public function attachListener($event, $listener, $priority = 1) + { + if (! is_string($event)) { + throw new \InvalidArgumentException("Given parameter event should be a string. Got " . gettype($event)); + } + + $handler = new DefaultListenerHandler($listener); + + $this->events[$event][((int) $priority) . '.0'][] = $handler; + + return $handler; + } + + /** + * Detach an event listener + * + * @param ListenerHandler $listenerHandler + * @return bool + */ + public function detachListener(ListenerHandler $listenerHandler) + { + foreach ($this->events as &$prioritizedListeners) { + foreach ($prioritizedListeners as &$listenerHandlers) { + foreach ($listenerHandlers as $index => $listedListenerHandler) { + if ($listedListenerHandler === $listenerHandler) { + unset($listenerHandlers[$index]); + return true; + } + } + } + } + + return false; + } + + /** + * Attach a listener aggregate + * + * @param ActionEventListenerAggregate $aggregate + */ + public function attachListenerAggregate(ActionEventListenerAggregate $aggregate) + { + $aggregate->attach($this); + } + + /** + * Detach a listener aggregate + * + * @param ActionEventListenerAggregate $aggregate + */ + public function detachListenerAggregate(ActionEventListenerAggregate $aggregate) + { + $aggregate->detach($this); + } + + /** + * @param ActionEvent $event + * @return ListenerHandler[] + */ + private function getListeners(ActionEvent $event) + { + $prioritizedListeners = isset($this->events[$event->getName()])? $this->events[$event->getName()] : [] ; + + krsort($prioritizedListeners, SORT_NUMERIC); + + foreach ($prioritizedListeners as $listenersByPriority) { + foreach ($listenersByPriority as $listenerHandler) { + yield $listenerHandler; + } + } + } +} \ No newline at end of file diff --git a/tests/Event/DefaultActionEventTest.php b/tests/Event/DefaultActionEventTest.php new file mode 100644 index 0000000..af316cf --- /dev/null +++ b/tests/Event/DefaultActionEventTest.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * Date: 5/22/15 - 7:12 PM + */ +namespace ProophTest\Common\Event; + + +use Prooph\Common\Event\DefaultActionEvent; + +class DefaultActionEventTest extends \PHPUnit_Framework_TestCase +{ + + /** + * @return DefaultActionEvent + */ + private function getTestEvent() + { + return new DefaultActionEvent('test-event', 'target', ['param1' => 'foo']); + } + + /** + * @test + */ + function it_can_be_initialized_with_a_name_a_target_and_params() + { + $event = $this->getTestEvent(); + + $this->assertEquals('test-event', $event->getName()); + $this->assertEquals('target', $event->getTarget()); + $this->assertEquals(['param1' => 'foo'], $event->getParams()); + } + + /** + * @test + */ + function it_can_initialized_without_a_target_and_params() + { + $event = new DefaultActionEvent('test-event'); + + $this->assertNull($event->getTarget()); + $this->assertEquals([], $event->getParams()); + } + + /** + * @test + */ + function it_returns_param_if_set() + { + $this->assertEquals('foo', $this->getTestEvent()->getParam('param1')); + } + + /** + * @test + */ + function it_returns_null_if_param_is_not_set_and_no_other_default_is_given() + { + $this->assertNull($this->getTestEvent()->getParam('unknown')); + } + + /** + * @test + */ + function it_returns_default_if_param_is_not_set() + { + $this->assertEquals('default', $this->getTestEvent()->getParam('unknown', 'default')); + } + + /** + * @test + */ + function it_changes_name_when_new_one_is_set() + { + $event = $this->getTestEvent(); + + $event->setName('new name'); + + $this->assertEquals('new name', $event->getName()); + } + + /** + * @test + * @dataProvider provideInvalidNames + * @expectedException \InvalidArgumentException + */ + function it_only_allows_strings_as_event_name($invalidName) + { + $this->getTestEvent()->setName($invalidName); + } + + /** + * @return array + */ + public function provideInvalidNames() + { + return [ + [1], + [true], + [[]], + [new \stdClass()] + ]; + } + + /** + * @test + */ + function it_overrides_params_array_if_new_one_is_set() + { + $event = $this->getTestEvent(); + + $event->setParams(['param_new' => 'bar']); + + $this->assertEquals(['param_new' => 'bar'], $event->getParams()); + } + + /** + * @test + */ + function it_allows_object_implementing_array_access_as_params() + { + $arrayLikeObject = new \ArrayObject(['object_param' => 'baz']); + + $event = $this->getTestEvent(); + + $event->setParams($arrayLikeObject); + + $this->assertSame($arrayLikeObject, $event->getParams()); + } + + /** + * @test + * @expectedException \InvalidArgumentException + */ + function it_does_not_allow_params_object_that_is_not_of_type_array_access() + { + $stdObj = new \stdClass(); + + $stdObj->param1 = 'foo'; + + $this->getTestEvent()->setParams($stdObj); + } + + /** + * @test + */ + function it_changes_target_if_new_is_set() + { + $event = $this->getTestEvent(); + + $target = new \stdClass(); + + $event->setTarget($target); + + $this->assertSame($target, $event->getTarget()); + } + + /** + * @test + */ + function it_indicates_that_propagation_should_be_stopped() + { + $event = $this->getTestEvent(); + + $this->assertFalse($event->propagationIsStopped()); + + $event->stopPropagation(); + + $this->assertTrue($event->propagationIsStopped()); + } +} \ No newline at end of file diff --git a/tests/Event/ProophActionEventDispatcherTest.php b/tests/Event/ProophActionEventDispatcherTest.php new file mode 100644 index 0000000..24cd40a --- /dev/null +++ b/tests/Event/ProophActionEventDispatcherTest.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * Date: 5/22/15 - 8:54 PM + */ +namespace ProophTest\Common\Event\ZF2; + +use Prooph\Common\Event\ActionEvent; +use Prooph\Common\Event\ProophActionEventDispatcher; +use ProophTest\Common\Mock\ActionEventListenerMock; +use ProophTest\Common\Mock\ActionListenerAggregateMock; + +class ProophActionEventDispatcherTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var ProophActionEventDispatcher + */ + private $proophActionEventDispatcher; + + protected function setUp() + { + $this->proophActionEventDispatcher = new ProophActionEventDispatcher(); + } + + /** + * @test + */ + function it_attaches_action_event_listeners_and_dispatch_event_to_them() + { + $lastEvent = null; + $listener1 = new ActionEventListenerMock(); + $listener2 = function (ActionEvent $event) use (&$lastEvent) { + if ($event->getParam('payload', false)) { + $lastEvent = $event; + } + }; + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListener("test", $listener2); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + $this->assertSame($lastEvent, $listener1->lastEvent); + } + + /** + * @test + */ + function it_detaches_a_listener() + { + $lastEvent = null; + $listener1 = new ActionEventListenerMock(); + $listener2 = function (ActionEvent $event) use (&$lastEvent) { + if ($event->getParam('payload', false)) { + $lastEvent = $event; + } + }; + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $handler = $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListener("test", $listener2); + + $this->proophActionEventDispatcher->detachListener($handler); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + $this->assertNull($listener1->lastEvent); + $this->assertSame($actionEvent, $lastEvent); + } + + /** + * @test + */ + function it_triggers_listeners_until_callback_returns_true() + { + $lastEvent = null; + $listener1 = new ActionEventListenerMock(); + $listener2 = function (ActionEvent $event) use (&$lastEvent) { + if ($event->getParam('payload', false)) { + $lastEvent = $event; + } + }; + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListener("test", $listener2); + + $this->proophActionEventDispatcher->dispatchUntil($actionEvent, function (ActionEvent $e) { + //We return true directly after first listener was triggered + return true; + }); + + $this->assertNull($lastEvent); + $this->assertSame($actionEvent, $listener1->lastEvent); + } + + /** + * @test + */ + function it_stops_dispatching_when_event_propagation_is_stopped() + { + $lastEvent = null; + $listener1 = new ActionEventListenerMock(); + $listener2 = function (ActionEvent $event) { $event->stopPropagation(true); }; + $listener3 = function (ActionEvent $event) use (&$lastEvent) { + if ($event->getParam('payload', false)) { + $lastEvent = $event; + } + }; + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListener("test", $listener2); + $this->proophActionEventDispatcher->attachListener("test", $listener3); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + $this->assertNull($lastEvent); + $this->assertSame($actionEvent, $listener1->lastEvent); + } + + /** + * @test + */ + function it_triggers_listeners_with_high_priority_first() + { + $lastEvent = null; + $listener1 = new ActionEventListenerMock(); + $listener2 = function (ActionEvent $event) { $event->stopPropagation(true); }; + $listener3 = function (ActionEvent $event) use (&$lastEvent) { + if ($event->getParam('payload', false)) { + $lastEvent = $event; + } + }; + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1, -100); + $this->proophActionEventDispatcher->attachListener("test", $listener3); + $this->proophActionEventDispatcher->attachListener("test", $listener2, 100); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + $this->assertNull($lastEvent); + $this->assertNull($listener1->lastEvent); + } + + /** + * @test + */ + function it_attaches_a_listener_aggregate() + { + $listener1 = new ActionEventListenerMock(); + $listenerAggregate = new ActionListenerAggregateMock(); + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListenerAggregate($listenerAggregate); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + //The listener aggregate attaches itself with a high priority and stops event propagation so $listener1 should not be triggered + $this->assertNull($listener1->lastEvent); + } + + /** + * @test + */ + function it_detaches_listener_aggregate() + { + $listener1 = new ActionEventListenerMock(); + $listenerAggregate = new ActionListenerAggregateMock(); + + $actionEvent = $this->proophActionEventDispatcher->getNewActionEvent("test", $this, ['payload' => true]); + + $this->proophActionEventDispatcher->attachListener("test", $listener1); + $this->proophActionEventDispatcher->attachListenerAggregate($listenerAggregate); + $this->proophActionEventDispatcher->detachListenerAggregate($listenerAggregate); + + $this->proophActionEventDispatcher->dispatch($actionEvent); + + //If aggregate is not detached it would stop the event propagation and $listener1 would not be triggered + $this->assertSame($actionEvent, $listener1->lastEvent); + } +} \ No newline at end of file diff --git a/tests/Zf2ActionEventDispatcherTest.php b/tests/Event/ZF2/Zf2ActionEventDispatcherTest.php similarity index 100% rename from tests/Zf2ActionEventDispatcherTest.php rename to tests/Event/ZF2/Zf2ActionEventDispatcherTest.php