From e31b4b185be0f06747173de146ba9e120c4c6f77 Mon Sep 17 00:00:00 2001 From: Francisco Neves Date: Tue, 5 Sep 2017 17:42:22 +0100 Subject: [PATCH] Add project --- .travis.yml | 11 + README.md | 70 ++++- composer.json | 33 +++ phpunit.xml | 18 ++ src/Neves/Events/EventServiceProvider.php | 47 ++++ src/Neves/Events/TransactionalDispatcher.php | 280 +++++++++++++++++++ src/config/transactional-events.php | 26 ++ tests/TransactionalDispatcherTest.php | 228 +++++++++++++++ 8 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 .travis.yml create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Neves/Events/EventServiceProvider.php create mode 100644 src/Neves/Events/TransactionalDispatcher.php create mode 100644 src/config/transactional-events.php create mode 100644 tests/TransactionalDispatcherTest.php diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..eca0cb05a4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: php + +php: + - 7.0 + - 7.1 + +before_script: + - composer self-update + - composer install --prefer-source --no-interaction --dev + +script: phpunit diff --git a/README.md b/README.md index 45e68786b2..63b89fb435 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,68 @@ -# laravel-transactional-events -Enabling transactional events in Laravel +# Transactional Events on Laravel TravisCI Status + +This package brings transactional events to your Laravel application, allowing to achieve consistency between dispatched events and active database transactions. +
+Without changing a line of code your application will be able to take advantage from transactional events, out of the box. + +## Why transactional events? +When your application hits a size that requires some effort to organize things, you may want to dispatch events on models to represent changes on their state. Let's say, for instance, that a ordering tickets is a complex process that calls an external payment service and triggers a notification that will be sent by e-mail, SMS, ... + +```php + +// OrdersController.php +DB::transaction(function() { + $user = User::find(...); + $concert = Concert::find(...); + $tickets = $concert->orderTickets($user, 3); + PaymentService::createOrder($tickets); +}); + +// Concert.php +public function orderTickets($user, $amount) +{ + ... + event(UserDisabledAccount::class); +} +``` + +In case the transaction of the example fails due to an error on external payment service or due to other reason in the database-level, such as deadlocks, will rollback all your database changes. **However, the event was actually dispatched and it will be executed, even the whole transaction failed**. + +Here is the purpose of this package: if an event is dispatched within a transaction, it will be executed if and only if the transaction succeeds. If the transaction fails, they will never execute. + +However, if have parts on your code that do not leverage transactions, **events will be dispatched using the default Laravel Event Dispatcher**. + +## Installation +**Note:** This package is only available for Laravel 5.5 LTS. + +The installation of this package leverages the Package Auto-Discovery feature enabled in Laravel 5.5. Therefore, you just need to add this package to `composer.json` file. + +```php +composer require "fntneves/laravel-transactional-events" +``` + +The configuration file can be customized, just publish the provided one `transactional-events.php` to your config folder. + +```php +php artisan vendor:publish --provider="Neves\Events\EventServiceProvider" +``` + + +## Usage + +Once the package is installed, it is ready to use out-of-the-box. By default, it will start to handle all events of `App\Events` namespace as transactional events. + +The `Event::dispatch(...)` facade or the `event(new UserRegistered::class)` helper method can still be used to dispatch events. If you use queues, they will still work smoothly, since this package only adds a transactional layer over the event dispatcher. + +**Note:** This package only applies the transactional behavior to events dispatched within a database transaction. Otherwise, it will perform the same as the default Laravel Event Dispatcher. + + +## Configuration + +**enabled**: The transactional behavior of events can be enable or disable by setting up the `enable` property in configuration file. + +**events**: By default, the transactional behavior will be applied to events on `App\Events` namespace. This is configurable to patterns and full namespaces. + +**exclude**: Apart of the transactional events, you can choose specific events to be handled with the default Laravel Event Dispatcher, i.e., without the transactional behavior. + +## License +This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000000..53f3da0e15 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "fntneves/laravel-transactional-events", + "description": "Transactional layer for Laravel Event Dispatcher", + "type": "library", + "require": { + "illuminate/events": "5.5.*", + "illuminate/support": "5.5.*", + "illuminate/database": "5.5.*" + }, + "require-dev": { + "phpunit/phpunit": "^6.3", + "mockery/mockery": "^0.9.9" + }, + "license": "MIT", + "authors": [ + { + "name": "Francisco Neves", + "email": "contact@francisconeves.com" + } + ], + "autoload": { + "psr-0": { + "Neves\\Events\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Neves\\Events\\EventServiceProvider" + ] + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000000..e89ac6d802 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests/ + + + \ No newline at end of file diff --git a/src/Neves/Events/EventServiceProvider.php b/src/Neves/Events/EventServiceProvider.php new file mode 100644 index 0000000000..907cf2a524 --- /dev/null +++ b/src/Neves/Events/EventServiceProvider.php @@ -0,0 +1,47 @@ +app->extend('events', function () { + $dispatcher = new TransactionalDispatcher( + $this->app->make('db'), + $this->app->make(EventDispatcher::class) + ); + + $dispatcher->setTransactionalEvents(config('transactional-events.events', [])); + $dispatcher->setExcludedEvents(config('transactional-events.exclude', [])); + + return $dispatcher; + }); + } + } + + /** + * Bootstrap the application events. + * + * @return void + */ + public function boot() + { + $this->publishes([ + __DIR__.'/../../config/transactional-events.php' => config_path('transactional-events.php'), + ]); + + $this->mergeConfigFrom( + __DIR__.'/../../config/transactional-events.php', 'transactional-events' + ); + } +} diff --git a/src/Neves/Events/TransactionalDispatcher.php b/src/Neves/Events/TransactionalDispatcher.php new file mode 100644 index 0000000000..6cdb5867d4 --- /dev/null +++ b/src/Neves/Events/TransactionalDispatcher.php @@ -0,0 +1,280 @@ +connectionResolver = $connectionResolver; + $this->dispatcher = $eventDispatcher; + } + + /** + * Dispatch an event and call the listeners. + * + * @param string|object $event + * @param mixed $payload + * @param bool $halt + * @return array|null + */ + public function dispatch($event, $payload = [], $halt = false) + { + $connection = $this->connectionResolver->connection(); + $connectionId = spl_object_hash($connection); + + if (! $this->isTransactionalEvent($connection, $event)) { + return $this->dispatcher->dispatch($event, $payload, $halt); + } + + $this->dispatcher->listen($connectionId.'_commit', function () use ($event, $payload) { + $this->dispatcher->dispatch($event, $payload); + }); + } + + /** + * Flush all enqueued events. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @return void + */ + public function commit(ConnectionInterface $connection) + { + $connectionId = spl_object_hash($connection); + + $this->dispatcher->dispatch($connectionId.'_commit'); + $this->dispatcher->forget($connectionId.'_commit'); + } + + /** + * Set list of events that should be handled by transactional layer. + * + * @param array $enabled + * @return void + */ + public function setTransactionalEvents(array $enabled) + { + $this->transactional = $enabled; + } + + /** + * Set exceptions list. + * + * @param array $except + * @return void + */ + public function setExcludedEvents(array $except) + { + $this->exclude = $except; + } + + /** + * Clear enqueued events. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @return void + */ + public function rollback(ConnectionInterface $connection) + { + $connectionId = spl_object_hash($connection); + $this->dispatcher->forget($connectionId.'_commit'); + } + + /** + * Check whether an event is a transactional event or not. + * + * @param \Illuminate\Database\ConnectionInterface $connection + * @param string|object $event + * @return bool + */ + private function isTransactionalEvent(ConnectionInterface $connection, $event) + { + if ($connection->transactionLevel() < 1) { + return false; + } + + return $this->shouldHandle($event); + } + + /** + * Check whether an event should be handled by this layer or not. + * + * @param string|object $event + * @return bool + */ + private function shouldHandle($event) + { + $event = is_string($event) ? $event : get_class($event); + + foreach ($this->exclude as $exception) { + if ($this->matches($exception, $event)) { + return false; + } + } + + foreach ($this->transactional as $enabled) { + if ($this->matches($enabled, $event)) { + return true; + } + } + + return false; + } + + /** + * Check whether an event name matches a pattern or not. + * + * @param string $pattern + * @param string $event + * @return bool + */ + private function matches($pattern, $event) + { + return (Str::contains($pattern, '*') && Str::is($pattern, $event)) + || Str::startsWith($event, $pattern); + } + + /** + * Register an event listener with the dispatcher. + * + * @param string|array $events + * @param mixed $listener + * @return void + */ + public function listen($events, $listener) + { + $this->dispatcher->listen($events, $listener); + } + + /** + * Determine if a given event has listeners. + * + * @param string $eventName + * @return bool + */ + public function hasListeners($eventName) + { + return $this->dispatcher->hasListeners($eventName); + } + + /** + * Register an event subscriber with the dispatcher. + * + * @param object|string $subscriber + * @return void + */ + public function subscribe($subscriber) + { + $this->dispatcher->subscribe($subscriber); + } + + /** + * Dispatch an event until the first non-null response is returned. + * + * @param string|object $event + * @param mixed $payload + * @return array|null + */ + public function until($event, $payload = []) + { + return $this->dispatcher->until($event, $payload); + } + + /** + * Register an event and payload to be fired later. + * + * @param string $event + * @param array $payload + * @return void + */ + public function push($event, $payload = []) + { + $this->dispatcher->push($event, $payload); + } + + /** + * Flush a set of pushed events. + * + * @param string $event + * @return void + */ + public function flush($event) + { + $this->dispatcher->flush($event); + } + + /** + * Remove a set of listeners from the dispatcher. + * + * @param string $event + * @return void + */ + public function forget($event) + { + $this->dispatcher->forget($event); + } + + /** + * Forget all of the queued listeners. + * + * @return void + */ + public function forgetPushed() + { + $this->dispatcher->forgetPushed(); + } + + /** + * Dynamically pass methods to the default dispatcher. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + return $this->dispatcher->$method(...$parameters); + } +} diff --git a/src/config/transactional-events.php b/src/config/transactional-events.php new file mode 100644 index 0000000000..fed23e2971 --- /dev/null +++ b/src/config/transactional-events.php @@ -0,0 +1,26 @@ + true, + + 'events' => [ + 'App\Events', + ], + + 'exclude' => [ + // + ], +]; diff --git a/tests/TransactionalDispatcherTest.php b/tests/TransactionalDispatcherTest.php new file mode 100644 index 0000000000..b750245913 --- /dev/null +++ b/tests/TransactionalDispatcherTest.php @@ -0,0 +1,228 @@ +connectionResolverMock = m::mock(ConnectionResolverInterface::class); + $this->dispatcher = new TransactionalDispatcher($this->connectionResolverMock, new Dispatcher()); + $this->dispatcher->setTransactionalEvents(['*']); + } + + /** @test */ + public function it_immediately_dispatches_event_out_of_transactions() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + $this->setupTransactionLevel(0); + + $this->dispatcher->dispatch('foo'); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + /** @test */ + public function it_enqueues_event_dispatched_in_transactions() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + $this->setupTransactionLevel(1); + + $this->dispatcher->dispatch('foo'); + + $this->assertTrue($this->hasCommitListeners()); + $this->assertArrayNotHasKey('__events.test', $_SERVER); + } + + /** @test */ + public function it_dispatches_events_on_commit() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + $this->setupTransactionLevel(1); + $this->dispatcher->dispatch('foo'); + + $this->dispatcher->commit($this->getConnection()); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + /** @test */ + public function it_forgets_enqueued_events_on_rollback() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + $this->setupTransactionLevel(1); + $this->dispatcher->dispatch('foo'); + + $this->dispatcher->rollback($this->getConnection()); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertArrayNotHasKey('__events.test', $_SERVER); + } + + /** @test */ + public function it_immediately_dispatches_events_present_in_exceptions_list() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setExcludedEvents(['foo']); + $this->dispatcher->dispatch('foo'); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + /** @test */ + public function it_immediately_dispatches_events_not_present_in_enabled_list() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setTransactionalEvents(['bar']); + $this->dispatcher->dispatch('foo'); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + /** @test */ + public function it_immediately_dispatches_events_that_do_not_match_a_pattern() + { + $this->dispatcher->listen('foo', function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setTransactionalEvents(['foo/*']); + $this->dispatcher->dispatch('foo'); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + /** @test */ + public function it_enqueues_events_that_do_match_a_pattern() + { + $this->dispatcher->listen('foo/bar', function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setTransactionalEvents(['foo/*']); + $this->dispatcher->dispatch('foo/bar'); + + $this->assertTrue($this->hasCommitListeners()); + $this->assertArrayNotHasKey('__events.test', $_SERVER); + } + + /** @test */ + public function it_immediately_dispatches_specific_events_excluded_on_a_pattern() + { + $this->dispatcher->listen('foo/bar', function () { + $_SERVER['__events.test.bar'] = 'bar'; + }); + + $this->dispatcher->listen('foo/zen', function () { + $_SERVER['__events.test.zen'] = 'zen'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setTransactionalEvents(['foo/*']); + $this->dispatcher->setExcludedEvents(['foo/bar']); + $this->dispatcher->dispatch('foo/bar'); + $this->dispatcher->dispatch('foo/zen'); + + $this->assertTrue($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test.bar']); + $this->assertArrayNotHasKey('__env.test.zen', $_SERVER); + } + + /** @test */ + public function it_enqueues_events_matching_a_namespace_patterns() + { + $event = m::mock('\\Neves\\Event'); + $this->dispatcher->listen('\\Neves\\Event', function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->dispatch($event); + + $this->assertTrue($this->hasCommitListeners()); + $this->assertArrayNotHasKey('__events.test', $_SERVER); + } + + /** @test */ + public function it_dispatches_events_matching_a_namespace_patterns() + { + $event = m::mock('overload:\\App\\Neves\\Event'); + $this->dispatcher->listen(get_class($event), function () { + $_SERVER['__events.test'] = 'bar'; + }); + + $this->setupTransactionLevel(1); + $this->dispatcher->setTransactionalEvents(['App\*']); + $this->dispatcher->dispatch($event); + $this->dispatcher->commit($this->getConnection()); + + $this->assertFalse($this->hasCommitListeners()); + $this->assertEquals('bar', $_SERVER['__events.test']); + } + + private function hasCommitListeners() + { + $connectionId = spl_object_hash($this->connectionResolverMock->connection()); + + return $this->dispatcher->hasListeners($connectionId.'_commit'); + } + + private function getConnection() + { + return $this->connectionResolverMock->connection(); + } + + private function setupTransactionLevel($level = 1) + { + $connection = m::mock(ConnectionInterface::class) + ->shouldReceive('transactionLevel') + ->andReturn($level) + ->mock(); + + $this->connectionResolverMock = $this->connectionResolverMock + ->shouldReceive('connection') + ->andReturn($connection) + ->mock(); + } +}