diff --git a/README.md b/README.md index 9693c52..afecc45 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,128 @@ $evm->addEventListener([Events::postFlush], new DoctrineEventsDispatcher($tracke That's it! You're all set! Now you can add as many Symfony's listeners as you need to your `$dispatcher`, and you'll be able to react to the domain events raised by your application. + +## Outbox pattern + +This library also provides support for the [Outbox pattern](https://microservices.io/patterns/data/application-events.html) implementation. +The idea behind the implementation is to be able to add entities to an "ongoing" transaction by hooking into Doctrine's `onFlush` event, +creating "outbox" entries based on the application's domain events, and safely store them using the same DB transaction. + +In order to enrich your Domain Event and be able to set all the data required by the Outbox entity, +you need to create a `Converter` class (by implementing `Dsantang\DomainEventsDoctrine\Outbox\Converter` interface) +An example of an outbox event is as follows: + +```php +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\Converter; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +final class YourOutboxConverter implements Converter +{ + public function convert(DomainEvent $domainEvent) : OutboxEntry + { + return new YourOutboxEntry($domainEvent); + } +} + +final class YourOutboxEntry implements OutboxEntry +{ + /** @var DomainEvent */ + private $domainEvent; + + public function __construct(DomainEvent $domainEvent) + { + $this->domainEvent = $domainEvent; + } + + public function getName() : string + { + return 'OrderDispatched'; + } + + public function getAggregateId() : UuidInterface + { + return Uuid::fromString('d1702762-548b-11e9-8647-d663bd873d93'); + } + + public function getAggregateType() : string + { + return 'Order'; + } + + public function getPayloadType() : string + { + return 'OrderStructure'; + } + + public function getMessageKey() : string + { + return 'd663bd873d93'; + } + + public function getMessageRoute() : string + { + return 'aggregate.order'; + } + + public function getMessageType() : string + { + return 'OrderCreated'; + } + + public function getPayload() : string + { + return json_encode($this->domainEvent) + } + + public function getSchemaVersion() : int + { + return 1; + } +} +``` +In order to persist your Outbox entries, you must create a Doctrine Entity class inside your application that extends +`Dsantang\DomainEventsDoctrine\Outbox\OutboxMappedSuperclass`. +Please note that this approach uses [Doctrine's Inheritance Mapping](https://www.doctrine-project.org/projects/doctrine-orm/en/2.6/reference/inheritance-mapping.html#mapped-superclasses): + +Here is an example of an Outbox Entity class: +```php +namespace YourNamespace; + +use Doctrine\ORM\Mapping as ORM; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxMappedSuperclass; + +/** + * @ORM\Entity() + * @ORM\Table + */ +class YourOutboxEntity extends OutboxMappedSuperclass +{ + /** + * @ORM\Column(type="string") + * + * @var string + */ + private $someAdditionalField; +} +``` + +And an example of the required configuration as is follows: +**Warning:** this solution assumes that you're using `Dsantang\DomainEvents\DomainEvent` in order to raise your domain events. + +```php +use Dsantang\DomainEventsDoctrine\Outbox\MapBased; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxMappedSuperclass; + +// Your class must extend OutboxMappedSuperclass +$yourOutboxEntity = new YourOutboxEntity(); + +$mapBased = new MapBased($yourOutboxEntity); +$mapBased->addConverter('YouNamespace\YourDomainEvent', new YourOutboxConverter()); + +// Always use with OnFlush event +$evm->addEventListener([Events::onFlush], $mapBased); + +``` diff --git a/composer.json b/composer.json index a9c97e8..84e664f 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "require": { "php": "^7.2", "doctrine/orm": "^2.6", - "dsantang/domain-events": "^0.3" + "dsantang/domain-events": "^0.3", + "ramsey/uuid": "^3.8" }, "suggest": { "symfony/event-dispatcher" : "To be able to dispatch domain events via a Symfony's EventDispatcherInterface." diff --git a/composer.lock b/composer.lock index 2f6e95f..78fb6fb 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": "9b638c3797b2210c2d4f3c6965b219d1", + "content-hash": "21c87779e79f417cb6449f6adc306a59", "packages": [ { "name": "doctrine/annotations", @@ -915,6 +915,133 @@ ], "time": "2019-02-15T14:31:03+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.99", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "reference": "84b4dfb120c6f9b4ff7b3685f9b8f1aa365a0c95", + "shasum": "" + }, + "require": { + "php": "^7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "time": "2018-07-02T15:55:56+00:00" + }, + { + "name": "ramsey/uuid", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "d09ea80159c1929d75b3f9c60504d613aeb4a1e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/d09ea80159c1929d75b3f9c60504d613aeb4a1e3", + "reference": "d09ea80159c1929d75b3f9c60504d613aeb4a1e3", + "shasum": "" + }, + "require": { + "paragonie/random_compat": "^1.0|^2.0|9.99.99", + "php": "^5.4 || ^7.0", + "symfony/polyfill-ctype": "^1.8" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "codeception/aspect-mock": "^1.0 | ~2.0.0", + "doctrine/annotations": "~1.2.0", + "goaop/framework": "1.0.0-alpha.2 | ^1.0 | ~2.1.0", + "ircmaxell/random-lib": "^1.1", + "jakub-onderka/php-parallel-lint": "^0.9.0", + "mockery/mockery": "^0.9.9", + "moontoast/math": "^1.1", + "php-mock/php-mock-phpunit": "^0.3|^1.1", + "phpunit/phpunit": "^4.7|^5.0|^6.5", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "ext-ctype": "Provides support for PHP Ctype functions", + "ext-libsodium": "Provides the PECL libsodium extension for use with the SodiumRandomGenerator", + "ext-uuid": "Provides the PECL UUID extension for use with the PeclUuidTimeGenerator and PeclUuidRandomGenerator", + "ircmaxell/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "moontoast/math": "Provides support for converting UUID to 128-bit integer (in string form).", + "ramsey/uuid-console": "A console application for generating UUIDs with ramsey/uuid", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marijn Huizendveld", + "email": "marijn.huizendveld@gmail.com" + }, + { + "name": "Thibaud Fabre", + "email": "thibaud@aztech.io" + }, + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "Formerly rhumsaa/uuid. A PHP 5.4+ library for generating RFC 4122 version 1, 3, 4, and 5 universally unique identifiers (UUID).", + "homepage": "https://github.com/ramsey/uuid", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "time": "2018-07-19T23:38:55+00:00" + }, { "name": "symfony/console", "version": "v4.2.3", @@ -1055,6 +1182,64 @@ ], "time": "2018-12-05T08:06:11+00:00" }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.10.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", + "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Gert de Pagter", + "email": "backendtea@gmail.com" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "time": "2018-08-06T14:22:27+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.10.0", @@ -1598,7 +1783,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🅱 Nette Bootstrap: the simple way to configure and bootstrap your Nette application.", + "description": "? Nette Bootstrap: the simple way to configure and bootstrap your Nette application.", "homepage": "https://nette.org", "keywords": [ "bootstrapping", @@ -1663,7 +1848,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "💎 Nette Dependency Injection Container: Flexible, compiled and full-featured DIC with perfectly usable autowiring and support for all new PHP 7.1 features.", + "description": "? Nette Dependency Injection Container: Flexible, compiled and full-featured DIC with perfectly usable autowiring and support for all new PHP 7.1 features.", "homepage": "https://nette.org", "keywords": [ "compiled", @@ -1728,7 +1913,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🔍 Nette Finder: find files and directories with an intuitive API.", + "description": "? Nette Finder: find files and directories with an intuitive API.", "homepage": "https://nette.org", "keywords": [ "filesystem", @@ -1788,7 +1973,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🍸 Nette NEON: encodes and decodes NEON file format.", + "description": "? Nette NEON: encodes and decodes NEON file format.", "homepage": "http://ne-on.org", "keywords": [ "export", @@ -1851,7 +2036,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 7.3 features.", + "description": "? Nette PHP Generator: generates neat PHP code for you. Supports new PHP 7.3 features.", "homepage": "https://nette.org", "keywords": [ "code", @@ -1915,7 +2100,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🍀 Nette RobotLoader: high performance and comfortable autoloader that will search and autoload classes within your application.", + "description": "? Nette RobotLoader: high performance and comfortable autoloader that will search and autoload classes within your application.", "homepage": "https://nette.org", "keywords": [ "autoload", @@ -1988,7 +2173,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "description": "? Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", "homepage": "https://nette.org", "keywords": [ "array", @@ -4060,64 +4245,6 @@ "homepage": "https://symfony.com", "time": "2019-01-16T20:35:37+00:00" }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.10.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/e3d826245268269cd66f8326bd8bc066687b4a19", - "reference": "e3d826245268269cd66f8326bd8bc066687b4a19", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.9-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - }, - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "time": "2018-08-06T14:22:27+00:00" - }, { "name": "symfony/process", "version": "v4.2.2", diff --git a/src/Outbox/Converter.php b/src/Outbox/Converter.php new file mode 100644 index 0000000..e1f619b --- /dev/null +++ b/src/Outbox/Converter.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Outbox; + +use Dsantang\DomainEvents\DomainEvent; + +interface Converter +{ + public function convert(DomainEvent $domainEvent) : OutboxEntry; +} diff --git a/src/Outbox/EventsHandler.php b/src/Outbox/EventsHandler.php new file mode 100644 index 0000000..3d24926 --- /dev/null +++ b/src/Outbox/EventsHandler.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Outbox; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\UnitOfWork; +use Dsantang\DomainEvents\Counter; +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEvents\EventAware; +use function array_filter; +use function array_merge; +use function count; +use function ksort; + +abstract class EventsHandler +{ + /** @var OutboxMappedSuperclass */ + protected $outboxMappedSuperclass; + + public function __construct(OutboxMappedSuperclass $outboxMappedSuperclass) + { + $this->outboxMappedSuperclass = $outboxMappedSuperclass; + } + + /** + * @return OutboxEntry[] + * + * @var DomainEvent[] $domainEvents + */ + abstract protected function convert(DomainEvent ...$domainEvents) : array; + + public function onFlush(OnFlushEventArgs $eventArgs) : void + { + $domainEvents = $this->getDomainEvents($eventArgs); + + $outboxEvents = $this->convert(...$domainEvents); + + $this->persist($eventArgs->getEntityManager(), ...$outboxEvents); + } + + /** + * @return DomainEvent[] + */ + protected function getDomainEvents(OnFlushEventArgs $eventArgs) : array + { + $unitOfWork = $eventArgs->getEntityManager() + ->getUnitOfWork(); + + $events = []; + + foreach (self::getEventAwareEntities($unitOfWork) as $entity) { + $events += $entity->expelRecordedEvents(); + } + + ksort($events); + + Counter::reset(); + + return $events; + } + + protected function persist(EntityManagerInterface $entityManager, OutboxEntry ...$outboxEntries) : void + { + if (count($outboxEntries) <= 0) { + return; + } + + foreach ($outboxEntries as $outboxEntry) { + $entity = $this->outboxMappedSuperclass->fromOutboxEntry($outboxEntry); + $entityManager->persist($entity); + } + + $entityManager->getUnitOfWork()->computeChangeSets(); + } + + /** + * @return EventAware[] + */ + private static function getEventAwareEntities(UnitOfWork $unitOfWork) : array + { + $entities = array_merge( + $unitOfWork->getScheduledEntityInsertions(), + $unitOfWork->getScheduledEntityUpdates(), + $unitOfWork->getScheduledEntityDeletions() + ); + + return array_filter($entities, static function ($entity) { + return $entity instanceof EventAware; + }); + } +} diff --git a/src/Outbox/MapBased.php b/src/Outbox/MapBased.php new file mode 100644 index 0000000..62f0b03 --- /dev/null +++ b/src/Outbox/MapBased.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Outbox; + +use Dsantang\DomainEvents\DomainEvent; +use InvalidArgumentException; +use function class_exists; +use function get_class; +use function sprintf; + +final class MapBased extends EventsHandler +{ + /** @var Converter[] */ + private $conversionMap; + + public function addConverter(string $domainEventClass, Converter $converter) : void + { + if (! class_exists($domainEventClass)) { + throw new InvalidArgumentException( + sprintf('Domain Event \"%s\" does not exist', $domainEventClass) + ); + } + + $this->conversionMap[$domainEventClass] = $converter; + } + + /** + * @return OutboxEntry[] + * + * @var DomainEvent[] $domainEvents + */ + public function convert(DomainEvent ...$domainEvents) : array + { + $outboxEntries = []; + + foreach ($domainEvents as $domainEvent) { + if (! isset($this->conversionMap[get_class($domainEvent)])) { + continue; + } + + $converter = $this->conversionMap[get_class($domainEvent)]; + + $outboxEntries[] = $converter->convert($domainEvent); + } + + return $outboxEntries; + } +} diff --git a/src/Outbox/OutboxEntry.php b/src/Outbox/OutboxEntry.php new file mode 100644 index 0000000..f19a67f --- /dev/null +++ b/src/Outbox/OutboxEntry.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Outbox; + +use Ramsey\Uuid\UuidInterface; + +interface OutboxEntry +{ + public function getMessageKey() : string; + + public function getMessageRoute() : string; + + public function getMessageType() : string; + + public function getAggregateId() : UuidInterface; + + public function getAggregateType() : string; + + public function getPayloadType() : string; + + public function getPayload() : string; + + public function getSchemaVersion() : int; +} diff --git a/src/Outbox/OutboxMappedSuperclass.php b/src/Outbox/OutboxMappedSuperclass.php new file mode 100644 index 0000000..a7893b3 --- /dev/null +++ b/src/Outbox/OutboxMappedSuperclass.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Outbox; + +use DateTimeImmutable; +use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\MappedSuperclass; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +/** + * @MappedSuperclass + */ +abstract class OutboxMappedSuperclass +{ + /** + * @ORM\Id + * @ORM\Column(type="uuid") + * + * @var UuidInterface + */ + protected $id; + + /** + * @ORM\Column(type="string") + * + * @var string + */ + protected $messageKey; + + /** + * @ORM\Column(type="string") + * + * @var string + */ + protected $messageRoute; + + /** + * @ORM\Column(type="string") + * + * @var string + */ + protected $messageType; + + /** + * @ORM\Column(type="uuid") + * + * @var UuidInterface + */ + protected $aggregateId; + + /** + * @ORM\Column(type="string") + * + * @var string + */ + protected $aggregateType; + + /** + * @ORM\Column(type="string") + * + * @var string + */ + protected $payloadType; + + /** + * @ORM\Column(type="json") + * + * @var string + */ + protected $payload; + + /** + * @ORM\Column(type="integer") + * + * @var int + */ + protected $schemaVersion; + + /** + * @ORM\Column(type="utc_datetime_immutable") + * + * @var DateTimeImmutable + */ + protected $createdAt; + + public function fromOutboxEntry(OutboxEntry $outboxEntry) : OutboxMappedSuperclass + { + $outbox = clone $this; + + $outbox->id = Uuid::uuid4(); + $outbox->messageKey = $outboxEntry->getMessageKey(); + $outbox->messageRoute = $outboxEntry->getMessageRoute(); + $outbox->messageType = $outboxEntry->getMessageType(); + $outbox->aggregateId = $outboxEntry->getAggregateId(); + $outbox->aggregateType = $outboxEntry->getAggregateType(); + $outbox->payloadType = $outboxEntry->getPayloadType(); + $outbox->payload = $outboxEntry->getPayload(); + $outbox->schemaVersion = $outboxEntry->getSchemaVersion(); + $outbox->createdAt = new DateTimeImmutable(); + + return $outbox; + } +} diff --git a/tests/Integration/MapBasedEventsHandlerTest.php b/tests/Integration/MapBasedEventsHandlerTest.php new file mode 100644 index 0000000..d4956bf --- /dev/null +++ b/tests/Integration/MapBasedEventsHandlerTest.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Integration; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\UnitOfWork; +use Dsantang\DomainEventsDoctrine\Outbox\MapBased; +use Dsantang\DomainEventsDoctrine\Tests\OutboxSubClass; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\EventArgsProvider; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters\FirstOutboxConverter; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\FirstDomainEvent; +use PHPUnit\Framework\TestCase; + +final class MapBasedEventsHandlerTest extends TestCase +{ + use EventArgsProvider; + + /** @var MapBased */ + private $mapBasedEventsHandler; + + /** + * @before + */ + public function setUpDependencies() : void + { + $this->mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + } + + public function testOutboxHappyPathWorkflowOnFlushEvent() : void + { + $this->mapBasedEventsHandler->addConverter( + FirstDomainEvent::class, + new FirstOutboxConverter() + ); + + $eventArgs = $this->getEventArgs(); + + $this->entityManager->expects(self::once())->method('persist'); + $this->unitOfWork->expects(self::once())->method('computeChangeSets'); + + $this->mapBasedEventsHandler->onFlush($eventArgs); + } +} diff --git a/tests/OutboxSubClass.php b/tests/OutboxSubClass.php new file mode 100644 index 0000000..e3d1100 --- /dev/null +++ b/tests/OutboxSubClass.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests; + +use Doctrine\ORM\Mapping as ORM; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxMappedSuperclass; + +/** + * @ORM\Entity() + * @ORM\Table + */ +class OutboxSubClass extends OutboxMappedSuperclass +{ + /** + * @ORM\Column(type="string", name="field_1", nullable=true) + * + * @var string + */ + private $field1; + + /** + * @ORM\Column(type="string", name="field_2", nullable=true) + * + * @var string + */ + private $field2; + + public function getField1() : string + { + return $this->field1; + } + + public function getField2() : string + { + return $this->field2; + } +} diff --git a/tests/Unit/Outbox/EventArgsProvider.php b/tests/Unit/Outbox/EventArgsProvider.php new file mode 100644 index 0000000..67ef4eb --- /dev/null +++ b/tests/Unit/Outbox/EventArgsProvider.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox; + +use Doctrine\ORM\Event\OnFlushEventArgs; +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEvents\EventAware; +use Dsantang\DomainEvents\Registry\OrderedEventRegistry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\FirstDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\SecondDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\ThirdDomainEvent; +use PHPUnit\Framework\MockObject\MockObject; + +trait EventArgsProvider +{ + /** @var MockObject entityManager */ + private $entityManager; + + /** @var MockObject unitOfWork */ + private $unitOfWork; + + /** + * @return OnFlushEventArgs|MockObject + */ + private function getEventArgs(bool $withEvents = true) : OnFlushEventArgs + { + $insertions = $updates = $deletions = []; + + if ($withEvents) { + $entity1 = new class() implements EventAware { + use OrderedEventRegistry; + + public function trigger(DomainEvent $event) : void + { + $this->triggeredA($event); + } + }; + + $entity2 = new class() implements EventAware { + use OrderedEventRegistry; + + public function trigger(DomainEvent $event) : void + { + $this->triggeredA($event); + } + }; + + $entity3 = new class() implements EventAware { + use OrderedEventRegistry; + + public function trigger(DomainEvent $event) : void + { + $this->triggeredA($event); + } + }; + + $domainEvent1 = new FirstDomainEvent(); + $domainEvent2 = new SecondDomainEvent(); + $domainEvent3 = new ThirdDomainEvent(); + + $entity1->trigger($domainEvent1); + $entity2->trigger($domainEvent2); + $entity3->trigger($domainEvent3); + + $updates = [$entity3]; + $deletions = [$entity2, $entity1]; + } + + $this->unitOfWork->expects(self::any())->method('getScheduledEntityInsertions')->willReturn($insertions); + $this->unitOfWork->expects(self::any())->method('getScheduledEntityUpdates')->willReturn($updates); + $this->unitOfWork->expects(self::any())->method('getScheduledEntityDeletions')->willReturn($deletions); + + $this->entityManager->expects(self::any())->method('getUnitOfWork')->willReturn($this->unitOfWork); + + $eventArgs = $this->createMock(OnFlushEventArgs::class); + $eventArgs->expects(self::any())->method('getEntityManager')->willReturn($this->entityManager); + + return $eventArgs; + } +} diff --git a/tests/Unit/Outbox/MapBasedTest.php b/tests/Unit/Outbox/MapBasedTest.php new file mode 100644 index 0000000..d460f18 --- /dev/null +++ b/tests/Unit/Outbox/MapBasedTest.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\UnitOfWork; +use Dsantang\DomainEventsDoctrine\Outbox\Converter; +use Dsantang\DomainEventsDoctrine\Outbox\MapBased; +use Dsantang\DomainEventsDoctrine\Tests\OutboxSubClass; +use Dsantang\DomainEventsDoctrine\Tests\RandomDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters\FirstOutboxConverter; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters\SecondOutboxConverter; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters\ThirdOutboxConverter; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\FirstDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\SecondDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\ThirdDomainEvent; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use function array_pop; + +final class MapBasedTest extends TestCase +{ + use EventArgsProvider; + + /** @var Converter[] */ + private $conversionMap; + + /** + * @before + */ + public function setUpDependencies() : void + { + $this->conversionMap = [ + FirstDomainEvent::class => new FirstOutboxConverter(), + SecondDomainEvent::class => new SecondOutboxConverter(), + ThirdDomainEvent::class => new ThirdOutboxConverter(), + ]; + + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + } + + public function testDealWithInvalidKeyConversionMap() : void + { + $this->expectException(InvalidArgumentException::class); + + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + $mapBasedEventsHandler->addConverter( + 'Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\NonExistentDomainEvent', + new FirstOutboxConverter() + ); + } + + public function testConvert() : void + { + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + foreach ($this->conversionMap as $domainEventClassName => $converter) { + $mapBasedEventsHandler->addConverter($domainEventClassName, $converter); + } + + $domainEvents = [new ThirdDomainEvent(), new FirstDomainEvent(), new SecondDomainEvent()]; + + $returnedOutboxEvents = $mapBasedEventsHandler->convert(...$domainEvents); + + $expectedOutboxEvents = [ + (new ThirdOutboxConverter())->convert(new ThirdDomainEvent()), + (new FirstOutboxConverter())->convert(new FirstDomainEvent()), + (new SecondOutboxConverter())->convert(new SecondDomainEvent()), + ]; + + self::assertEquals($expectedOutboxEvents, $returnedOutboxEvents); + } + + public function testConvertWithANonOutboxEntryDomainEvent() : void + { + array_pop($this->conversionMap); + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + foreach ($this->conversionMap as $domainEventClassName => $converter) { + $mapBasedEventsHandler->addConverter($domainEventClassName, $converter); + } + + $domainEvents = [new FirstDomainEvent(), new SecondDomainEvent(), new ThirdDomainEvent()]; + + $returnedOutboxEvents = $mapBasedEventsHandler->convert(...$domainEvents); + + $expectedOutboxEvents = [ + (new FirstOutboxConverter())->convert(new FirstDomainEvent()), + (new SecondOutboxConverter())->convert(new SecondDomainEvent()), + ]; + + self::assertEquals($expectedOutboxEvents, $returnedOutboxEvents); + } + + public function testConvertANonOutboxRelatedDomainEvent() : void + { + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + foreach ($this->conversionMap as $domainEventClassName => $converter) { + $mapBasedEventsHandler->addConverter($domainEventClassName, $converter); + } + + $domainEvents = [new RandomDomainEvent(), new SecondDomainEvent()]; + + $returnedOutboxEvents = $mapBasedEventsHandler->convert(...$domainEvents); + + $expectedOutboxEvents = [(new SecondOutboxConverter())->convert(new SecondDomainEvent())]; + + self::assertEquals($expectedOutboxEvents, $returnedOutboxEvents); + } + + public function testOnFlushWithDomainEvents() : void + { + $eventArgs = $this->getEventArgs(); + + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + foreach ($this->conversionMap as $domainEventClassName => $converter) { + $mapBasedEventsHandler->addConverter($domainEventClassName, $converter); + } + + $this->entityManager->expects(self::exactly(3))->method('persist'); + + $mapBasedEventsHandler->onFlush($eventArgs); + } + + public function testOnFlushWithNoDomainEvents() : void + { + $eventArgs = $this->getEventArgs(false); + + $mapBasedEventsHandler = new MapBased(new OutboxSubClass()); + + foreach ($this->conversionMap as $domainEventClassName => $converter) { + $mapBasedEventsHandler->addConverter($domainEventClassName, $converter); + } + + $this->unitOfWork->expects(self::never())->method('computeChangeSets'); + + $mapBasedEventsHandler->onFlush($eventArgs); + } +} diff --git a/tests/Unit/Outbox/Stub/Converters/FirstOutboxConverter.php b/tests/Unit/Outbox/Stub/Converters/FirstOutboxConverter.php new file mode 100644 index 0000000..a67896d --- /dev/null +++ b/tests/Unit/Outbox/Stub/Converters/FirstOutboxConverter.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\Converter; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\FirstOutboxEntry; + +final class FirstOutboxConverter implements Converter +{ + public function convert(DomainEvent $domainEvent) : OutboxEntry + { + return new FirstOutboxEntry($domainEvent); + } +} diff --git a/tests/Unit/Outbox/Stub/Converters/SecondOutboxConverter.php b/tests/Unit/Outbox/Stub/Converters/SecondOutboxConverter.php new file mode 100644 index 0000000..ac63f68 --- /dev/null +++ b/tests/Unit/Outbox/Stub/Converters/SecondOutboxConverter.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\Converter; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\SecondOutboxEntry; + +final class SecondOutboxConverter implements Converter +{ + public function convert(DomainEvent $domainEvent) : OutboxEntry + { + return new SecondOutboxEntry($domainEvent); + } +} diff --git a/tests/Unit/Outbox/Stub/Converters/ThirdOutboxConverter.php b/tests/Unit/Outbox/Stub/Converters/ThirdOutboxConverter.php new file mode 100644 index 0000000..4bcf94a --- /dev/null +++ b/tests/Unit/Outbox/Stub/Converters/ThirdOutboxConverter.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\Converters; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\Converter; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\ThirdOutboxEntry; + +final class ThirdOutboxConverter implements Converter +{ + public function convert(DomainEvent $domainEvent) : OutboxEntry + { + return new ThirdOutboxEntry($domainEvent); + } +} diff --git a/tests/Unit/Outbox/Stub/DomainEvents/FirstDomainEvent.php b/tests/Unit/Outbox/Stub/DomainEvents/FirstDomainEvent.php new file mode 100644 index 0000000..45e1227 --- /dev/null +++ b/tests/Unit/Outbox/Stub/DomainEvents/FirstDomainEvent.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents; + +use Dsantang\DomainEvents\DomainEvent; + +final class FirstDomainEvent implements DomainEvent +{ + public function getName() : string + { + return 'first'; + } +} diff --git a/tests/Unit/Outbox/Stub/DomainEvents/SecondDomainEvent.php b/tests/Unit/Outbox/Stub/DomainEvents/SecondDomainEvent.php new file mode 100644 index 0000000..444127f --- /dev/null +++ b/tests/Unit/Outbox/Stub/DomainEvents/SecondDomainEvent.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents; + +use Dsantang\DomainEvents\DomainEvent; + +final class SecondDomainEvent implements DomainEvent +{ + public function getName() : string + { + return 'second'; + } +} diff --git a/tests/Unit/Outbox/Stub/DomainEvents/ThirdDomainEvent.php b/tests/Unit/Outbox/Stub/DomainEvents/ThirdDomainEvent.php new file mode 100644 index 0000000..56000c0 --- /dev/null +++ b/tests/Unit/Outbox/Stub/DomainEvents/ThirdDomainEvent.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents; + +use Dsantang\DomainEvents\DomainEvent; + +final class ThirdDomainEvent implements DomainEvent +{ + public function getName() : string + { + return 'third'; + } +} diff --git a/tests/Unit/Outbox/Stub/OutboxEntries/FirstOutboxEntry.php b/tests/Unit/Outbox/Stub/OutboxEntries/FirstOutboxEntry.php new file mode 100644 index 0000000..2b1c726 --- /dev/null +++ b/tests/Unit/Outbox/Stub/OutboxEntries/FirstOutboxEntry.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +final class FirstOutboxEntry implements OutboxEntry +{ + /** @var DomainEvent */ + private $domainEvent; + + public function __construct(DomainEvent $domainEvent) + { + $this->domainEvent = $domainEvent; + } + + public function getAggregateId() : UuidInterface + { + return Uuid::fromString('d1702762-548b-11e9-8647-d663bd873d93'); + } + + public function getAggregateType() : string + { + return 'Order'; + } + + public function getPayloadType() : string + { + return 'OrderStructure'; + } + + public function getMessageKey() : string + { + return 'd663bd873d93'; + } + + public function getMessageRoute() : string + { + return 'aggregate.order'; + } + + public function getMessageType() : string + { + return $this->domainEvent->getName(); + } + + public function getPayload() : string + { + return '{"foo":"bar"}'; + } + + public function getSchemaVersion() : int + { + return 5; + } +} diff --git a/tests/Unit/Outbox/Stub/OutboxEntries/SecondOutboxEntry.php b/tests/Unit/Outbox/Stub/OutboxEntries/SecondOutboxEntry.php new file mode 100644 index 0000000..a529908 --- /dev/null +++ b/tests/Unit/Outbox/Stub/OutboxEntries/SecondOutboxEntry.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +final class SecondOutboxEntry implements OutboxEntry +{ + /** @var DomainEvent */ + private $domainEvent; + + public function __construct(DomainEvent $domainEvent) + { + $this->domainEvent = $domainEvent; + } + + public function getName() : string + { + return 'CartCreated'; + } + + public function getAggregateId() : UuidInterface + { + return Uuid::fromString('cece22ea-57ae-11e9-8647-d663bd873d93'); + } + + public function getAggregateType() : string + { + return 'Cart'; + } + + public function getPayloadType() : string + { + return 'CartType'; + } + + public function getMessageKey() : string + { + return 'af422e7a'; + } + + public function getMessageRoute() : string + { + return 'snapshot.cart'; + } + + public function getMessageType() : string + { + return $this->domainEvent->getName(); + } + + public function getPayload() : string + { + return '{"foo":"bar"}'; + } + + public function getSchemaVersion() : int + { + return 1; + } +} diff --git a/tests/Unit/Outbox/Stub/OutboxEntries/ThirdOutboxEntry.php b/tests/Unit/Outbox/Stub/OutboxEntries/ThirdOutboxEntry.php new file mode 100644 index 0000000..46538ed --- /dev/null +++ b/tests/Unit/Outbox/Stub/OutboxEntries/ThirdOutboxEntry.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries; + +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +final class ThirdOutboxEntry implements OutboxEntry +{ + /** @var DomainEvent */ + private $domainEvent; + + public function __construct(DomainEvent $domainEvent) + { + $this->domainEvent = $domainEvent; + } + + public function getName() : string + { + return 'CustomerDeleted'; + } + + public function getAggregateId() : UuidInterface + { + return Uuid::fromString('49ca1f44-56ec-11e9-8647-d663bd873d93'); + } + + public function getAggregateType() : string + { + return 'CustomerType'; + } + + public function getPayloadType() : string + { + return 'CustomerDetails'; + } + + public function getMessageKey() : string + { + return 'ba70d882'; + } + + public function getMessageRoute() : string + { + return 'event.customer'; + } + + public function getMessageType() : string + { + return $this->domainEvent->getName(); + } + + public function getPayload() : string + { + return '{"foo":"bar"}'; + } + + public function getSchemaVersion() : int + { + return 2; + } +} diff --git a/tests/Unit/Outbox/Stub/StubMapBased.php b/tests/Unit/Outbox/Stub/StubMapBased.php new file mode 100644 index 0000000..6bff4f2 --- /dev/null +++ b/tests/Unit/Outbox/Stub/StubMapBased.php @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Dsantang\DomainEvents\DomainEvent; +use Dsantang\DomainEventsDoctrine\Outbox\EventsHandler; +use Dsantang\DomainEventsDoctrine\Outbox\OutboxEntry; + +final class StubMapBased extends EventsHandler +{ + /** + * @return DomainEvent[] array + */ + public function getDomainEvents(OnFlushEventArgs $eventArgs) : array + { + return parent::getDomainEvents($eventArgs); + } + + public function persist(EntityManagerInterface $entityManager, OutboxEntry ...$outboxEntries) : void + { + parent::persist($entityManager, ...$outboxEntries); + } + + /** + * @return OutboxEntry[] + * + * @var DomainEvent[] $domainEvents + */ + protected function convert(DomainEvent ...$domainEvents) : array + { + return []; + } +} diff --git a/tests/Unit/Outbox/StubMapBasedTest.php b/tests/Unit/Outbox/StubMapBasedTest.php new file mode 100644 index 0000000..89494e9 --- /dev/null +++ b/tests/Unit/Outbox/StubMapBasedTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox; + +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\UnitOfWork; +use Dsantang\DomainEvents\Counter; +use Dsantang\DomainEventsDoctrine\Tests\OutboxSubClass; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\FirstDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\SecondDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\DomainEvents\ThirdDomainEvent; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\FirstOutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\SecondOutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\OutboxEntries\ThirdOutboxEntry; +use Dsantang\DomainEventsDoctrine\Tests\Unit\Outbox\Stub\StubMapBased; +use PHPUnit\Framework\TestCase; +use function array_values; + +final class StubMapBasedTest extends TestCase +{ + use EventArgsProvider; + + /** + * @before + */ + public function setUpDependencies() : void + { + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->unitOfWork = $this->createMock(UnitOfWork::class); + } + + public function testGetDomainsEvents() : void + { + Counter::reset(); + + $eventArgs = $this->getEventArgs(); + + self::assertEquals(3, Counter::getNext()); + + $mapBasedEventsHandler = new StubMapBased(new OutboxSubClass()); + + $eventsResult = $mapBasedEventsHandler->getDomainEvents($eventArgs); + + self::assertEquals(0, Counter::getNext()); + + $eventsExpected = [new FirstDomainEvent(), new SecondDomainEvent(), new ThirdDomainEvent()]; + + self::assertEquals(array_values($eventsResult), array_values($eventsExpected)); + } + + /** + * @return mixed[] array + */ + public function persistDataProvider() : array + { + return [ + [[], 0, 0], + [[new FirstOutboxEntry(new FirstDomainEvent())], 1, 1], + [ + [ + new ThirdOutboxEntry(new ThirdDomainEvent()), + new FirstOutboxEntry(new FirstDomainEvent()), + new SecondOutboxEntry(new SecondDomainEvent()), + ], 3, + 1, + ], + ]; + } + + /** + * @param mixed[] $outboxEvents + * + * @dataProvider persistDataProvider + */ + public function testPersist(array $outboxEvents, int $persistCalls, int $computeChangeSetsCalls) : void + { + $eventArgs = $this->getEventArgs(); + + $mapBasedEventsHandler = new StubMapBased(new OutboxSubClass()); + + $this->entityManager->expects(self::exactly($persistCalls))->method('persist'); + + $this->unitOfWork->expects(self::exactly($computeChangeSetsCalls))->method('computeChangeSets'); + + $mapBasedEventsHandler->persist($eventArgs->getEntityManager(), ...$outboxEvents); + } +}