diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php new file mode 100644 index 0000000..016558f --- /dev/null +++ b/src/Events/AbstractEvent.php @@ -0,0 +1,70 @@ +<?php +/** + * Definition of AbstractEvent + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Events; + +use FF\Factories\ClassLocators\ClassIdentifierAwareInterface; + +/** + * Class AbstractEvent + * + * @package FF\Events + */ +abstract class AbstractEvent implements ClassIdentifierAwareInterface +{ + /** + * For use with the BaseNamespaceClassLocator of the EventsFactory + */ + const COMMON_NS_SUFFIX = 'Events'; + + /** + * @var bool + */ + protected $isCanceled = false; + + /** + * @return bool + */ + public function isCanceled(): bool + { + return $this->isCanceled; + } + + /** + * @param bool $isCanceled + * @return $this + */ + public function setIsCanceled(bool $isCanceled) + { + $this->isCanceled = $isCanceled; + return $this; + } + + /** + * @return $this + */ + public function cancel() + { + return $this->setIsCanceled(true); + } + + /** + * {@inheritDoc} + */ + public static function getClassIdentifier(): string + { + $className = get_called_class(); + $needle = '\\' . self::COMMON_NS_SUFFIX . '\\'; + $pos = strpos($className, $needle); + if ($pos === false) return $className; + + return substr($className, $pos + strlen($needle)); + } +} \ No newline at end of file diff --git a/src/Services/EventBroker.php b/src/Services/EventBroker.php new file mode 100644 index 0000000..aa9232a --- /dev/null +++ b/src/Services/EventBroker.php @@ -0,0 +1,244 @@ +<?php +/** + * Definition of EventBroker + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services; + +use FF\DataStructures\IndexedCollection; +use FF\DataStructures\OrderedCollection; +use FF\Events\AbstractEvent; +use FF\Factories\Exceptions\ClassNotFoundException; +use FF\Services\Factories\EventsFactory; + +/** + * Class EventBroker + * + * @package FF\Services + */ +class EventBroker extends AbstractService +{ + /** + * @var IndexedCollection + */ + protected $subscriptions; + + /** + * @var EventsFactory + */ + protected $eventsFactory; + + /** + * {@inheritDoc} + */ + protected function initialize(array $options) + { + parent::initialize($options); + + $this->subscriptions = new IndexedCollection(); + $this->eventsFactory = EventsFactory::getInstance(); + } + + /** + * @return EventsFactory + */ + public function getEventsFactory(): EventsFactory + { + return $this->eventsFactory; + } + + /** + * @return IndexedCollection + */ + public function getSubscriptions(): IndexedCollection + { + return $this->subscriptions; + } + + /** + * @param string $classIdentifier + * @return OrderedCollection + */ + public function getSubscribers(string $classIdentifier): OrderedCollection + { + $this->initializeSubscribersCollection($classIdentifier); + return $this->subscriptions->get($classIdentifier); + } + + /** + * Appends a listener to the subscribers list of an event + * + * Removes any previous subscriptions of the listener first to the named event. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function subscribe(callable $listener, string $classIdentifier) + { + $this->unsubscribe($listener, $classIdentifier); + $this->initializeSubscribersCollection($classIdentifier); + + $this->subscriptions->get($classIdentifier)->push($listener); + return $this; + } + + /** + * Prepends a listener to the subscriber list of an event + * + * Removes any previous subscriptions of the listener first to the named event. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function subscribeFirst(callable $listener, string $classIdentifier) + { + $this->unsubscribe($listener, $classIdentifier); + $this->initializeSubscribersCollection($classIdentifier); + + $this->subscriptions->get($classIdentifier)->unshift($listener); + return $this; + } + + /** + * Unsubscribes a listener + * + * If $name is omitted, the listener will be unsubscribed from each event it was subscribed to. + * + * @param callable $listener + * @param string $classIdentifier + * @return $this + */ + public function unsubscribe(callable $listener, string $classIdentifier = null) + { + /** @var OrderedCollection $listenerCollection */ + foreach ($this->subscriptions as $name => $listenerCollection) { + if (!is_null($classIdentifier) && $classIdentifier != $name) continue; + + $index = $listenerCollection->search($listener, true); + if (is_null($index)) continue; + + // remove listener from event + unset($listenerCollection[$index]); + } + + return $this; + } + + /** + * Removes all subscriptions for this event + * + * @param string $classIdentifier + * @return $this + */ + public function unsubscribeAll(string $classIdentifier) + { + unset($this->subscriptions[$classIdentifier]); + return $this; + } + + /** + * Checks whether any listeners where subscribed to the named event + * + * @param string $classIdentifier + * @return bool + */ + public function hasSubscribers(string $classIdentifier): bool + { + return $this->subscriptions->has($classIdentifier) && !$this->subscriptions->get($classIdentifier)->isEmpty(); + } + + /** + * Checks of the listener has been subscribed to the given event + * + * @param callable $listener + * @param string $classIdentifier + * @return bool + */ + public function isSubscribed(callable $listener, string $classIdentifier): bool + { + if (!$this->hasSubscribers($classIdentifier)) return false; + + return !is_null($this->subscriptions->get($classIdentifier)->search($listener)); + } + + /**Notifies all listeners of events of the given type + * + * Listeners will be notified in the order of their subscriptions. + * Does nothing if no listeners subscribed to the type of the event. + * + * Creates an event instance and fires it. + * Does nothing if no suitable event model could be created. + * + * Any given $args will be passed to the constructor of the suitable event + * model class in the given order. + * + * @param string $classIdentifier + * @param mixed ...$args + * @return $this + */ + public function fire(string $classIdentifier, ...$args) + { + $event = $this->createEvent($classIdentifier, ...$args); + if (is_null($event)) return $this; + + foreach ($this->getSubscribers($classIdentifier) as $listener) { + $this->notify($listener, $event); + + if ($event->isCanceled()) { + // stop notifying further listeners if event has been canceled + break; + } + } + + return $this; + } + + /** + * Initialize listener collection if necessary + * + * @param string $classIdentifier + */ + protected function initializeSubscribersCollection(string $classIdentifier) + { + if ($this->subscriptions->has($classIdentifier)) return; + + $this->subscriptions->set($classIdentifier, new OrderedCollection()); + } + + /** + * Create a fresh event instance + * + * @param string $classIdentifier + * @param mixed ...$args + * @return AbstractEvent|null + */ + protected function createEvent(string $classIdentifier, ...$args): ?AbstractEvent + { + try { + return $this->eventsFactory->create($classIdentifier, ...$args); + } catch (ClassNotFoundException $e) { + return null; + } + } + + /** + * Passes the event to the listener + * + * The listener will be invoked with the event as the first and only argument. + * Any return values of the listener will be discarded. + * + * @param callable $listener + * @param AbstractEvent $event + */ + protected function notify(callable $listener, AbstractEvent $event) + { + call_user_func($listener, $event); + } +} \ No newline at end of file diff --git a/src/Services/Factories/EventsFactory.php b/src/Services/Factories/EventsFactory.php new file mode 100644 index 0000000..6f76a06 --- /dev/null +++ b/src/Services/Factories/EventsFactory.php @@ -0,0 +1,82 @@ +<?php +/** + * Definition of EventsFactory + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Factories; + +use FF\Events\AbstractEvent; +use FF\Factories\AbstractFactory; +use FF\Factories\ClassLocators\BaseNamespaceClassLocator; +use FF\Factories\ClassLocators\ClassLocatorInterface; + +/** + * Class EventsFactory + * + * @package FF\Services\Factories + */ +class EventsFactory extends AbstractFactory +{ + /** + * @var EventsFactory + */ + protected static $instance; + + /** + * Declared protected to prevent external usage. + * Uses a BaseNamespaceClassLocator pre-configured with the 'Events' as common suffix and the FF namespace. + * + * @see \FF\Factories\ClassLocators\BaseNamespaceClassLocator + */ + protected function __construct() + { + parent::__construct(new BaseNamespaceClassLocator(AbstractEvent::COMMON_NS_SUFFIX, 'FF')); + } + + /** + * Declared protected to prevent external usage + */ + protected function __clone() + { + + } + + /** + * {@inheritDoc} + * @return BaseNamespaceClassLocator + */ + public function getClassLocator(): ClassLocatorInterface + { + return parent::getClassLocator(); + } + + /** + * Retrieves the singleton instance of this class + * + * @return EventsFactory + */ + public static function getInstance(): EventsFactory + { + if (is_null(self::$instance)) { + self::$instance = new EventsFactory(); + } + + return self::$instance; + } + + /** + * {@inheritdoc} + * @return AbstractEvent + */ + public function create(string $classIdentifier, ...$args) + { + /** @var AbstractEvent $event */ + $event = parent::create($classIdentifier, ...$args); + return $event; + } +} \ No newline at end of file diff --git a/src/Services/Traits/EventEmitterTrait.php b/src/Services/Traits/EventEmitterTrait.php new file mode 100644 index 0000000..d004f2b --- /dev/null +++ b/src/Services/Traits/EventEmitterTrait.php @@ -0,0 +1,45 @@ +<?php +/** + * Definition of EventEmitterTrait + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Services\Traits; + +use FF\Services\EventBroker; +use FF\Services\Factories\SF; + +/** + * Trait EventEmitterTrait + * + * @package FF\Services\Traits + */ +trait EventEmitterTrait +{ + /** + * Creates an event instance and fires it + * + * Delegates the execution to the EventBroker provided by the ServiceFactory. + * + * @param string $classIdentifier + * @param mixed ...$args + * @return $this + */ + protected function fire(string $classIdentifier, ...$args) + { + /** @var EventBroker $eventBroker */ + static $eventBroker = null; + + if (is_null($eventBroker)) { + $eventBroker = SF::i()->get('EventBroker'); + } + + $eventBroker->fire($classIdentifier, ...$args); + + return $this; + } +} \ No newline at end of file diff --git a/tests/Events/AbstractEventTest.php b/tests/Events/AbstractEventTest.php new file mode 100644 index 0000000..075ff80 --- /dev/null +++ b/tests/Events/AbstractEventTest.php @@ -0,0 +1,68 @@ +<?php +/** + * Definition of AbstractEventTest + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Events; + +use FF\Events\AbstractEvent; +use PHPUnit\Framework\TestCase; + +/** + * Test AbstractEventTest + * + * @package FF\Tests + */ +class AbstractEventTest extends TestCase +{ + /** + * @var MyEvent + */ + protected $uut; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new MyEvent(); + } + + /** + * Tests the namesake method/feature + */ + public function testSetGetIsCanceled() + { + $same = $this->uut->setIsCanceled(true); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->isCanceled()); + } + + /** + * Tests the namesake method/feature + */ + public function testCancel() + { + $same = $this->uut->cancel(); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->isCanceled()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassIdentifier() + { + $this->assertEquals('MyEvent', MyEvent::getClassIdentifier()); + } +} + +class MyEvent extends AbstractEvent +{ + +} \ No newline at end of file diff --git a/tests/Services/EventBrokerTest.php b/tests/Services/EventBrokerTest.php new file mode 100644 index 0000000..f06f527 --- /dev/null +++ b/tests/Services/EventBrokerTest.php @@ -0,0 +1,287 @@ +<?php +/** + * Definition of EventBrokerTest + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services { + + use FF\DataStructures\IndexedCollection; + use FF\DataStructures\OrderedCollection; + use FF\Services\EventBroker; + use FF\Services\Factories\EventsFactory; + use FF\Tests\Services\Events\EventA; + use FF\Tests\Services\Events\EventB; + use PHPUnit\Framework\TestCase; + + /** + * Test EventBrokerTest + * + * @package FF\Tests + */ + class EventBrokerTest extends TestCase + { + /** + * @var EventBroker + */ + protected $uut; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->uut = new EventBroker(); + $this->uut->getEventsFactory() + ->getClassLocator() + ->prependNamespaces(__NAMESPACE__); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->uut->unsubscribeAll('EventA'); + } + + /** + * Tests the namesake method/feature + */ + public function testGetEventsFactory() + { + $this->assertInstanceOf(EventsFactory::class, $this->uut->getEventsFactory()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetSubscriptions() + { + $this->assertInstanceOf(IndexedCollection::class, $this->uut->getSubscriptions()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetSubscribers() + { + $this->assertInstanceOf(OrderedCollection::class, $this->uut->getSubscribers('EventA')); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribe() + { + $callback = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + $this->assertSame($callback, $this->uut->getSubscribers('EventA')->getFirst()); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeRepeated() + { + $callback = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback, 'EventA')->subscribe($callback, 'EventA'); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeAppend() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA'); + $this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(0)); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(1)); + } + + /** + * Tests the namesake method/feature + */ + public function testSubscribeFirst() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribeFirst($callback2, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(1)); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0)); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribe() + { + $callback1 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback1, 'EventB') + ->unsubscribe($callback1); + $this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty()); + $this->assertTrue($this->uut->getSubscribers('EventB')->isEmpty()); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribeNamed() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA') + ->unsubscribe($callback1, 'EventA'); + $this->assertSame($this->uut, $same); + $this->assertEquals(1, count($this->uut->getSubscribers('EventA'))); + $this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0)); + } + + /** + * Tests the namesake method/feature + */ + public function testUnsubscribeAll() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutA']; + + $same = $this->uut->subscribe($callback1, 'EventA') + ->subscribe($callback2, 'EventA') + ->unsubscribeAll('EventA'); + $this->assertSame($this->uut, $same); + $this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty()); + } + + /** + * Tests the namesake method/feature + */ + public function testHasSubscribers() + { + $callback1 = [new ListenerA(), 'shoutA']; + + $this->uut->subscribe($callback1, 'EventA'); + + $this->assertTrue($this->uut->hasSubscribers('EventA')); + $this->assertFalse($this->uut->hasSubscribers('EventB')); + } + + /** + * Tests the namesake method/feature + */ + public function testIsSubscribed() + { + $callback1 = [new ListenerA(), 'shoutA']; + $callback2 = [new ListenerA(), 'shoutB']; + + $this->uut->subscribe($callback1, 'EventA'); + + $this->assertTrue($this->uut->isSubscribed($callback1, 'EventA')); + $this->assertFalse($this->uut->isSubscribed($callback1, 'EventB')); + $this->assertFalse($this->uut->isSubscribed($callback2, 'EventA')); + $this->assertFalse($this->uut->isSubscribed($callback2, 'EventB')); + } + + /** + * Tests the namesake method/feature + */ + public function testFire() + { + $same = $this->uut->fire('EventA', 'foo'); + $this->assertSame($this->uut, $same); + } + + /** + * Tests the namesake method/feature + */ + public function testFireWithListener() + { + $this->expectOutputString('foo'); + + $this->uut->subscribe([new ListenerA(), 'shoutA'], 'EventA') + ->fire('EventA', 'foo'); + } + + /** + * Tests the namesake method/feature + */ + public function testFireCancel() + { + $this->expectOutputString('foo'); // output would be 'foofoo' if event canceling does not work + + $this->uut->subscribe([new ListenerA(), 'cancelA'], 'EventA') + ->subscribe([new ListenerB(), 'neverToReach'], 'EventA') + ->fire('EventA', 'foo'); + } + } + + class ListenerA + { + public function shoutA(EventA $event) + { + print $event->content; + } + + public function shoutB(EventB $event) + { + print $event->content; + } + + public function cancelA(EventA $event) + { + print $event->content; + $event->cancel(); + } + } + + class ListenerB extends ListenerA + { + public function shoutB(EventB $event) + { + print $event->content; + } + + public function neverToReach(EventA $event) + { + print $event->content; + } + } +} + +namespace FF\Tests\Services\Events { + + use FF\Events\AbstractEvent; + + class EventA extends AbstractEvent + { + public $content; + + public function __construct(string $content) + { + $this->content = $content; + } + } + + class EventB extends EventA + { + + } +} \ No newline at end of file diff --git a/tests/Services/Factories/EventsFactoryTest.php b/tests/Services/Factories/EventsFactoryTest.php new file mode 100644 index 0000000..2b8aad7 --- /dev/null +++ b/tests/Services/Factories/EventsFactoryTest.php @@ -0,0 +1,41 @@ +<?php +/** + * Definition of EventsFactoryTest + * + * @author Marco Stoll <marco@fast-forward-encoding.de> + * @copyright 2019-forever Marco Stoll + * @filesource + */ +declare(strict_types=1); + +namespace FF\Tests\Services\Factories; + +use FF\Factories\ClassLocators\BaseNamespaceClassLocator; +use FF\Services\Factories\EventsFactory; +use PHPUnit\Framework\TestCase; + +/** + * Test EventsFactoryTest + * + * @package FF\Tests + */ +class EventsFactoryTest extends TestCase +{ + /** + * Tests the namesake method/feature + */ + public function testGetInstance() + { + $instance = EventsFactory::getInstance(); + $this->assertInstanceOf(EventsFactory::class, $instance); + $this->assertSame($instance, EventsFactory::getInstance()); + } + + /** + * Tests the namesake method/feature + */ + public function testGetClassLocator() + { + $this->assertInstanceOf(BaseNamespaceClassLocator::class, EventsFactory::getInstance()->getClassLocator()); + } +} \ No newline at end of file