├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── .whitesource ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── 01-how_to.md └── 02-projections.md ├── phpunit.xml.dist ├── ruleset.xml ├── src ├── CommandBus │ ├── CommandBusInterface.php │ ├── EventDispatchingCommandBus.php │ ├── Exception │ │ ├── CanNotInvokeHandlerException.php │ │ └── MissingHandlerException.php │ ├── Handler │ │ ├── HandlerLocatorInterface.php │ │ └── InMemoryLocator.php │ ├── README.md │ ├── SimpleCommandBus.php │ └── TacticianCommandBus.php ├── Domain │ ├── AggregateId.php │ ├── AggregateIdInterface.php │ ├── AggregateRootInterface.php │ ├── DomainEventInterface.php │ ├── DomainMessage.php │ ├── EventStream.php │ ├── Exception │ │ └── AggregateDoesNotExistException.php │ └── StreamName.php ├── EventBus │ ├── EventBusInterface.php │ ├── EventListenerInterface.php │ ├── README.md │ └── SimpleEventBus.php ├── EventDispatcher │ ├── EventDispatcherInterface.php │ ├── EventListenerInterface.php │ ├── InMemoryDispatcher.php │ └── README.md ├── EventSourcing │ ├── AggregateRepository.php │ ├── AggregateRepositoryFactory.php │ ├── AggregateRepositoryFactoryInterface.php │ ├── AggregateRepositoryInterface.php │ ├── AggregateRootTrait.php │ └── README.md ├── EventStore │ ├── Adapter │ │ ├── DbalAdapter.php │ │ ├── EventProcessorTrait.php │ │ ├── EventStoreAdapterInterface.php │ │ ├── InMemoryAdapter.php │ │ ├── MongoAdapter.php │ │ ├── MongoDbAdapter.php │ │ ├── RedisAdapter.php │ │ └── Schema │ │ │ └── DbalSchema.php │ ├── EventStore.php │ ├── EventStoreInterface.php │ ├── Exception │ │ ├── EventStoreException.php │ │ └── EventStreamNotFoundException.php │ ├── README.md │ └── Snapshot │ │ ├── Adapter │ │ ├── DbalSnapshotAdapter.php │ │ ├── InMemorySnapshotAdapter.php │ │ ├── RedisSnapshotAdapter.php │ │ ├── Schema │ │ │ └── SnapshotSchema.php │ │ ├── SnapshotProcessorTrait.php │ │ └── SnapshotStoreAdapterInterface.php │ │ ├── Snapshot.php │ │ ├── SnapshotStore.php │ │ ├── SnapshotStoreInterface.php │ │ ├── Snapshotter.php │ │ └── Strategy │ │ ├── CountSnapshotStrategy.php │ │ └── SnapshotStrategyInterface.php ├── Provider │ └── EngineServiceProvider.php └── Serializer │ ├── Adapter │ ├── JmsSerializerAdapter.php │ ├── PhpJsonSerializerAdapter.php │ ├── PhpSerializerAdapter.php │ └── SymfonySerializerAdapter.php │ ├── Exception │ ├── DeserializationInvalidValueException.php │ └── InvalidUuidException.php │ ├── README.md │ ├── SerializerInterface.php │ └── Type │ ├── DateTimeImmutableHandler.php │ ├── LocalizedDateTimeHandler.php │ ├── MapHandler.php │ ├── MoneyHandler.php │ ├── UuidSerializerHandler.php │ └── VectorHandler.php └── tests ├── CommandBus ├── EventDispatchingCommandBusTest.php ├── SimpleCommandBusTest.php └── TacticianCommandBusTest.php ├── Domain └── DomainMessageTest.php ├── EventBus └── SimpleEventBusTest.php ├── EventDispatcher └── EventDispatcherTest.php ├── EventSourcing ├── AggregateRepositoryFactoryTest.php └── EventSourcingRepositoryTest.php ├── EventStore ├── Aggregate │ └── AggregateIdTest.php ├── EventStoreTest.php ├── InMemoryEventStoreTest.php ├── RedisEventStore.php └── Snapshot │ ├── Adapter │ └── RedisSnapshotAdapterTest.php │ └── SnapshotTest.php ├── EventStoreIntegrationTest.php ├── Mock ├── AggregateRoot.php ├── AggregateRootCreated.php ├── AllEventsListener.php ├── AssignNameCommand.php ├── AssignNameHandler.php ├── Config │ ├── HelloFresh.Engine.Domain.AggregateId.yml │ ├── HelloFresh.Tests.Engine.Mock.AggregateRoot.yml │ ├── HelloFresh.Tests.Engine.Mock.AggregateRootCreated.yml │ ├── HelloFresh.Tests.Engine.Mock.NameAssigned.yml │ ├── HelloFresh.Tests.Engine.Mock.SomethingDone.yml │ └── HelloFresh.Tests.Engine.Mock.SomethingHappened.yml ├── CounterTrait.php ├── InvalidHandler.php ├── NameAssigned.php ├── PredisClient.php ├── SomethingDone.php ├── SomethingHappened.php ├── SomethingHappenedListener.php ├── TestCommand.php ├── TestHandler.php └── TracableEventListener.php └── Serializer └── Type ├── JMSSerializerHandlerTestCase.php └── VectorHandlerTest.php /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # How to reproduce 2 | - 3 | 4 | # Expected behaviour 5 | - 6 | 7 | # Actual behaviour 8 | - 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # What this PR changes: 2 | - 3 | 4 | # When reviewing, please consider: 5 | - 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.lock 2 | vendor/ 3 | bin/ 4 | logs/ 5 | build/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | env: 4 | global: 5 | - REDIS_HOST=localhost 6 | - MONGO_HOST=localhost 7 | - DB_NAME=events 8 | - DB_USER=postgres 9 | - DB_PASSWORD= 10 | - DB_HOST=localhost 11 | 12 | services: 13 | - redis-server 14 | - mongodb 15 | - postgresql 16 | 17 | php: 18 | - 7.0 19 | - nightly 20 | 21 | matrix: 22 | fast_finish: true 23 | allow_failures: 24 | - php: nightly 25 | 26 | before_install: 27 | - pecl install mongodb 28 | - echo "extension = mongo.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini 29 | - echo "extension = mongodb.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini 30 | - psql -c 'create database events;' -U postgres 31 | 32 | install: 33 | - composer self-update 34 | - composer install --prefer-dist 35 | 36 | script: 37 | - vendor/bin/phpunit --coverage-clover build/logs/clover.xml 38 | 39 | after_script: 40 | - vendor/bin/codacycoverage clover build/logs/clover.xml 41 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "scanSettings": { 3 | "configMode": "AUTO", 4 | "configExternalURL": "", 5 | "projectToken" : "" 6 | }, 7 | "checkRunSettings": { 8 | "vulnerableCheckRunConclusionLevel": "success" 9 | }, 10 | "issueSettings": { 11 | "minSeverityLevel": "NONE" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Request for contributions 2 | 3 | Please contribute to this repository if any of the following is true: 4 | - You have expertise in community development, communication, or education 5 | - You want open source communities to be more collaborative and inclusive 6 | - You want to help lower the burden to first time contributors 7 | 8 | # How to contribute 9 | 10 | Prerequisites: 11 | 12 | - familiarity with [GitHub PRs](https://help.github.com/articles/using-pull-requests) (pull requests) and issues 13 | - knowledge of Markdown for editing `.md` documents 14 | 15 | In particular, this community seeks the following types of contributions: 16 | 17 | - ideas: participate in an Issues thread or start your own to have your voice 18 | heard 19 | - resources: submit a PR to add to [docs README.md](README.md) with links to related content 20 | - outline sections: help us ensure that this repository is comprehensive. if 21 | there is a topic that is overlooked, please add it, even if it is just a stub 22 | in the form of a header and single sentence. Initially, most things fall into 23 | this category 24 | - write: contribute your expertise in an area by helping us expand the included 25 | content 26 | - copy editing: fix typos, clarify language, and generally improve the quality 27 | of the content 28 | - formatting: help keep content easy to read with consistent formatting 29 | - code: Fix issues or contribute new features to this or any related projects 30 | 31 | # Conduct 32 | 33 | We are committed to providing a friendly, safe and welcoming environment for 34 | all, regardless of gender, sexual orientation, disability, ethnicity, religion, 35 | or similar personal characteristic. 36 | 37 | Please be kind and courteous. There's no need to be mean or rude. 38 | Respect that people have differences of opinion and that every design or 39 | implementation choice carries a trade-off and numerous costs. There is seldom 40 | a right answer, merely an optimal answer given a set of values and 41 | circumstances. 42 | 43 | Please keep unstructured critique to a minimum. If you have solid ideas you 44 | want to experiment with, make a fork and see how it works. 45 | 46 | We will exclude you from interaction if you insult, demean or harass anyone. 47 | That is not welcome behavior. We interpret the term "harassment" as 48 | including the definition in the 49 | [Citizen Code of Conduct](http://citizencodeofconduct.org/); 50 | if you have any lack of clarity about what might be included in that concept, 51 | please read their definition. In particular, we don't tolerate behavior that 52 | excludes people in socially marginalized groups. 53 | 54 | Private harassment is also unacceptable. No matter who you are, if you feel 55 | you have been or are being harassed or made uncomfortable by a community 56 | member, please contact one of the 57 | [hellofresh](https://github.com/orgs/hellofresh/people) core team 58 | immediately. Whether you're a regular contributor or a newcomer, we care about 59 | making this community a safe place for you and we've got your back. 60 | 61 | Likewise any spamming, trolling, flaming, baiting or other attention-stealing 62 | behavior is not welcome. 63 | 64 | # Communication 65 | 66 | GitHub issues are the primary way for communicating about specific proposed 67 | changes to this project. 68 | 69 | In both contexts, please follow the conduct guidelines above. Language issues 70 | are often contentious and we'd like to keep discussion brief, civil and focused 71 | on what we're actually doing, not wandering off into too much imaginary stuff. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 HelloFresh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 | # hellofresh/engine 8 | 9 | [![Build Status](https://travis-ci.org/hellofresh/engine.svg?branch=master)](https://travis-ci.org/hellofresh/engine) 10 | [![Total Downloads](https://poser.pugx.org/hellofresh/engine/downloads)](https://packagist.org/packages/hellofresh/engine) 11 | 12 | Welcome to HelloFresh Engine!! 13 | 14 | Engine provides you all the capabilities to build an Event sourced application. 15 | 16 | ## Components 17 | 18 | Engine is divided in a few small independent components. 19 | 20 | * [CommandBus](src/CommandBus/README.md) 21 | * [EventBus](src/EventBus/README.md) 22 | * [EventDispatcher](src/EventDispatcher/README.md) 23 | * [EventSourcing](src/EventSourcing/README.md) 24 | * [EventStore](src/EventStore/README.md) 25 | * [Serializer](src/Serializer/README.md) 26 | 27 | ## Install 28 | 29 | ```sh 30 | composer require hellofresh/engine 31 | ``` 32 | 33 | ## Usage 34 | 35 | Here you can check a small tutorial of how to use this component in an orders scenario. 36 | 37 | [Tutorial](docs/01-how_to.md) 38 | 39 | ## Contributing 40 | 41 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 42 | 43 | ## License 44 | 45 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 46 | 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hellofresh/engine", 3 | "description": "Engine to build event sourced applications", 4 | "keywords": [ 5 | "cqrs", 6 | "event sourcing", 7 | "domain-driven design", 8 | "ddd" 9 | ], 10 | "license": "MIT", 11 | "type": "library", 12 | "require": { 13 | "php": ">=7.0", 14 | "ramsey/uuid": "^3.0", 15 | "easyframework/collections": "^7.0.0" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^5.3", 19 | "codacy/coverage": "dev-master", 20 | "squizlabs/php_codesniffer": "^2.0", 21 | "symfony/var-dumper": "^3.0", 22 | "jms/serializer": "^1.1", 23 | "predis/predis": "^1.0", 24 | "mongodb/mongodb": "^1.0.0", 25 | "doctrine/dbal": "^2.5", 26 | "league/tactician": "^1.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "HelloFresh\\Engine\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "HelloFresh\\Tests\\Engine\\": "tests/" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docs/01-how_to.md: -------------------------------------------------------------------------------- 1 | ## How to use it? 2 | 3 | Let's take as an example a scenario were you have Orders and Customers. First thing we need to do is to create an order, 4 | this will be our *AggregateRoot*: 5 | 6 | ```php 7 | recordThat(new OrderCreated($id, $customer)); 55 | 56 | return $order; 57 | } 58 | 59 | /** 60 | * @return string 61 | */ 62 | public function getAggregateRootId() 63 | { 64 | return $this->id; 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | public function getStatus() : string 71 | { 72 | return $this->status; 73 | } 74 | 75 | /** 76 | * @return Customer 77 | */ 78 | public function getCustomer() : Customer 79 | { 80 | return $this->customer; 81 | } 82 | 83 | public function approve() 84 | { 85 | $this->recordThat(new OrderApproved($this->id)); 86 | } 87 | 88 | public function cancel() 89 | { 90 | $this->recordThat(new OrderCancelled($this->id)); 91 | } 92 | 93 | private function whenOrderCreated(OrderCreated $event) 94 | { 95 | $this->id = $event->getId(); 96 | $this->customer = $event->getCustomer(); 97 | } 98 | 99 | private function whenOrderApproved(OrderApproved $event) 100 | { 101 | $this->status = 'Approved'; 102 | } 103 | 104 | private function whenOrderOrderCancelled(OrderCancelled $event) 105 | { 106 | $this->status = 'Cancelled'; 107 | } 108 | } 109 | ``` 110 | 111 | And an Order id is important as well since this is an aggregate root: 112 | 113 | ```php 114 | use HelloFresh\Engine\Domain\AggregateId; 115 | 116 | class OrderId extends AggregateId 117 | { 118 | 119 | } 120 | ``` 121 | 122 | Now we need to take care of the events that we want to record (that's the goal of event sourcing), so let's create themm: 123 | 124 | ```php 125 | use HelloFresh\Engine\Domain\DomainEventInterface; 126 | 127 | abstract class AbstractOrderEvent implements DomainEventInterface 128 | { 129 | /** 130 | * @var OrderId 131 | */ 132 | private $id; 133 | 134 | /** 135 | * @var \DateTime 136 | */ 137 | private $occurredOn; 138 | 139 | /** 140 | * OrderCreated constructor. 141 | * @param OrderId $id 142 | */ 143 | public function __construct(OrderId $id) 144 | { 145 | $this->id = $id; 146 | $this->occurredOn = new \DateTime(); 147 | } 148 | 149 | /** 150 | * @return OrderId 151 | */ 152 | public function getId() : OrderId 153 | { 154 | return $this->id; 155 | } 156 | 157 | /** 158 | * @return \DateTime 159 | */ 160 | public function occurredOn() : \DateTime 161 | { 162 | return $this->occurredOn; 163 | } 164 | } 165 | 166 | ``` 167 | 168 | ```php 169 | final class CustomerAssigned extends AbstractOrderEvent 170 | { 171 | /** 172 | * @var Customer 173 | */ 174 | private $customer; 175 | 176 | public function __construct(OrderId $id, Customer $customer) 177 | { 178 | parent::__construct($id); 179 | $this->customer = $customer; 180 | } 181 | 182 | /** 183 | * @return Customer 184 | */ 185 | public function getCustomer() 186 | { 187 | return $this->customer; 188 | } 189 | } 190 | ``` 191 | 192 | ```php 193 | final class OrderCreated extends AbstractOrderEvent 194 | { 195 | /** 196 | * @var Customer 197 | */ 198 | private $customer; 199 | 200 | public function __construct(OrderId $id, Customer $customer) 201 | { 202 | parent::__construct($id); 203 | } 204 | 205 | /** 206 | * @return Customer 207 | */ 208 | public function getCustomer() 209 | { 210 | return $this->customer; 211 | } 212 | } 213 | ``` 214 | 215 | ```php 216 | final class OrderApproved extends AbstractOrderEvent 217 | { 218 | 219 | } 220 | ``` 221 | 222 | ```php 223 | final class OrderCancelled extends AbstractOrderEvent 224 | { 225 | 226 | } 227 | ``` 228 | 229 | Finally after having all events we can start recording them with the `recordThat` method, as you saw on the `Order` 230 | class. 231 | Let's check how that works: 232 | 233 | ```php 234 | $customer = new Customer(CustomerId::generate()); //of course this won't be generated every time 235 | $order = Order::place(OrderId::generate(), $customer); 236 | 237 | var_dump($order->getUncommitedEvents()); //you'll get an EventStream with the events that happened 238 | ``` 239 | 240 | Now all of this needs to make sense for our application... We need to store these events somewhere. For this task 241 | we have the `EventSourcingRepository` class, that takes care of this for us. Let's create a repository for our orders. 242 | 243 | 244 | ```php 245 | interface WriteOrderRepositoryInterface 246 | { 247 | public function nextIdentity() : OrderId; 248 | 249 | public function add(Order $order) : WriteOrderRepositoryInterface; 250 | } 251 | 252 | class WriteOrderRepository implements WriteOrderRepositoryInterface 253 | { 254 | /** 255 | * @var EventSourcingRepositoryInterface 256 | */ 257 | private $eventStoreRepo; 258 | 259 | /** 260 | * RedisWriteOrderRepository constructor. 261 | * @param EventSourcingRepositoryInterface $eventStoreRepo 262 | */ 263 | public function __construct(EventSourcingRepositoryInterface $eventStoreRepo) 264 | { 265 | $this->eventStoreRepo = $eventStoreRepo; 266 | } 267 | 268 | public function nextIdentity() : OrderId 269 | { 270 | return OrderId::generate(); 271 | } 272 | 273 | public function add(Order $order) : WriteOrderRepositoryInterface 274 | { 275 | $this->eventStoreRepo->save($order); 276 | 277 | return $this; 278 | } 279 | } 280 | ``` 281 | 282 | There we go, now let's see how to use it. 283 | 284 | ```php 285 | // We need to configure an event bus (this is how you can hook projections) 286 | $eventBus = new SimpleEventBus(); 287 | 288 | //For this example let's use the InMemoryAdapter (You can use Redis, Mongo and DBAL) 289 | $eventStore = new EventStore(new InMemoryAdapter()); 290 | 291 | //Creates the event sourcing repo 292 | $aggregateRepo = new EventSourcingRepository($eventStore, $eventBus); 293 | 294 | //Creates the order repository and saves it 295 | $writeOrderRepo = new WriteOrderRepository($aggregateRepo); 296 | $writeOrderRepo->add($order); 297 | ``` 298 | 299 | That's a bit of work to get it done, even if you use IoC, so let's use a factory to build this: 300 | 301 | ```php 302 | //Creates the event sourcing repo 303 | $factory = new AggregateRepositoryFactory([ 304 | 'event_store' => [ 305 | 'adapter' => 'in_memory' 306 | ] 307 | ]); 308 | 309 | $aggregateRepo = $factory->build(); 310 | 311 | //Creates the order repository and saves it 312 | $writeOrderRepo = new WriteOrderRepository($aggregateRepo); 313 | $writeOrderRepo->add($order); 314 | ``` 315 | 316 | Now it looks better, so you can use it both ways, to have flexibility (Specially with IoC) use the first one, to 317 | build something quickly use the second one. 318 | Ok we have our repository working, now let's bind this together with the power of *Command Pattern*. Let's build a 319 | command to create the order: 320 | 321 | ```php 322 | class CreateOrderCommand 323 | { 324 | /** 325 | * @var string 326 | */ 327 | private $customerId; 328 | 329 | /** 330 | * CreateOrderCommand constructor. 331 | * @param $customerId 332 | */ 333 | public function __construct($customerId) 334 | { 335 | $this->customerId = $customerId; 336 | } 337 | 338 | /** 339 | * @return mixed 340 | */ 341 | public function getCustomerId() : string 342 | { 343 | return $this->customerId; 344 | } 345 | } 346 | ``` 347 | 348 | And the handler will look something like this: 349 | 350 | ```php 351 | class CreateOrderHandler 352 | { 353 | /** 354 | * @var WriteOrderRepositoryInterface 355 | */ 356 | private $repo; 357 | 358 | /** 359 | * CreateOrderHandler constructor. 360 | * @param WriteOrderRepositoryInterface $repo 361 | */ 362 | public function __construct(WriteOrderRepositoryInterface $repo) 363 | { 364 | $this->repo = $repo; 365 | } 366 | 367 | public function handle(CreateOrderCommand $command) 368 | { 369 | $customerId = CustomerId::fromString($command->getCustomerId()); 370 | $customer = new Customer($customerId); 371 | $order = Order::placeOrder($this->repo->nextIdentity(), $customer); 372 | 373 | $this->repo->add($order); 374 | } 375 | } 376 | ``` 377 | 378 | And to use it: 379 | 380 | ```php 381 | $commandBus = new SimpleCommandBus(); 382 | $commandBus->subscribe(CreateOrderCommand::class, new CreateOrderHandler($writeRepo)); 383 | 384 | $command = new CreateOrderCommand(CustomerId::generate()); // Again the customer id will probably come from somewhere 385 | else 386 | $commandBus->execute($command); 387 | ``` 388 | 389 | And that's it, with this small tutorial you have all the power of event sourcing in you application. 390 | -------------------------------------------------------------------------------- /docs/02-projections.md: -------------------------------------------------------------------------------- 1 | ## Projections? 2 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests 17 | 18 | 19 | 20 | 21 | 22 | ./src 23 | 24 | ./tests 25 | ./vendor 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ruleset.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Coding standard based on PSR-2 with some additions. 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/CommandBus/CommandBusInterface.php: -------------------------------------------------------------------------------- 1 | commandBus = $commandBus; 25 | $this->dispatcher = $dispatcher; 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | public function execute($command) 32 | { 33 | try { 34 | $this->commandBus->execute($command); 35 | $this->dispatcher->dispatch(self::EVENT_COMMAND_SUCCESS, ['command' => $command]); 36 | } catch (\Exception $e) { 37 | $this->dispatcher->dispatch( 38 | self::EVENT_COMMAND_FAILURE, 39 | ['command' => $command, 'exception' => $e] 40 | ); 41 | throw $e; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/CommandBus/Exception/CanNotInvokeHandlerException.php: -------------------------------------------------------------------------------- 1 | command = $command; 33 | 34 | return $exception; 35 | } 36 | 37 | /** 38 | * Returns the command that could not be invoked 39 | * 40 | * @return mixed 41 | */ 42 | public function getCommand() 43 | { 44 | return $this->command; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CommandBus/Exception/MissingHandlerException.php: -------------------------------------------------------------------------------- 1 | commandName = $commandName; 24 | 25 | return $exception; 26 | } 27 | 28 | /** 29 | * @return string 30 | */ 31 | public function getCommandName() 32 | { 33 | return $this->commandName; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/CommandBus/Handler/HandlerLocatorInterface.php: -------------------------------------------------------------------------------- 1 | addHandlers($commandClassToHandlerMap); 20 | } 21 | 22 | /** 23 | * Bind a handler instance to receive all commands with a certain class 24 | * @param string $commandClassName Command class e.g. "My\TaskAddedCommand" 25 | * @param object $handler Handler to receive class 26 | */ 27 | public function addHandler($commandClassName, $handler) 28 | { 29 | if (!is_string($commandClassName)) { 30 | throw new \InvalidArgumentException('The command name should be a string'); 31 | } 32 | 33 | $this->handlers[$commandClassName] = $handler; 34 | } 35 | 36 | /** 37 | * Allows you to add multiple handlers at once. 38 | * @param array $commandClassToHandlerMap 39 | */ 40 | protected function addHandlers(array $commandClassToHandlerMap) 41 | { 42 | foreach ($commandClassToHandlerMap as $commandClass => $handler) { 43 | $this->addHandler($commandClass, $handler); 44 | } 45 | } 46 | 47 | /** 48 | * Returns the handler bound to the command's class name. 49 | * 50 | * @param string $commandName 51 | * 52 | * @return object 53 | */ 54 | public function getHandlerForCommand($commandName) 55 | { 56 | if (!isset($this->handlers[$commandName])) { 57 | throw MissingHandlerException::forCommand($commandName); 58 | } 59 | 60 | return $this->handlers[$commandName]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/CommandBus/README.md: -------------------------------------------------------------------------------- 1 | # CommandBus Component 2 | 3 | Primitives to use commands in your application. 4 | 5 | ## Command bus 6 | 7 | An interface and two simple implementations of a command bus where commands can 8 | be dispatched on. 9 | 10 | ## Command handler 11 | 12 | An interface and convenient base class that command handlers can extend. 13 | 14 | The base class provided by this component uses a convention to find out whether 15 | the command handler can execute a command or not. To signal that your command 16 | handler can handle a command `ExampleCommand`, just implement the 17 | `handle` method. 18 | 19 | ```php 20 | $commandBus = new SimpleCommandBus(); 21 | $commandBus->subscribe(ExampleCommand::class, new ExampleHandler()); 22 | 23 | $command = new ExampleCommand('hello world'); 24 | $commandBus->execute($command); 25 | ``` 26 | 27 | ## Testing 28 | 29 | A helper to implement scenario based tests for command handlers that use an 30 | event store. 31 | 32 | -------------------------------------------------------------------------------- /src/CommandBus/SimpleCommandBus.php: -------------------------------------------------------------------------------- 1 | queue = new Queue(); 33 | $this->handlerLocator = $handlerLocator; 34 | } 35 | 36 | /** 37 | * {@inheritDoc} 38 | */ 39 | public function execute($command) 40 | { 41 | $this->queue->enqueue($command); 42 | 43 | if (!$this->isDispatching) { 44 | $this->isDispatching = true; 45 | try { 46 | while (!$this->queue->isEmpty()) { 47 | $this->processCommand($this->queue->pop()); 48 | } 49 | } finally { 50 | $this->isDispatching = false; 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * @param $command 57 | */ 58 | private function processCommand($command) 59 | { 60 | $handler = $this->handlerLocator->getHandlerForCommand(get_class($command)); 61 | $methodName = 'handle'; 62 | 63 | if (!is_callable([$handler, $methodName])) { 64 | throw CanNotInvokeHandlerException::forCommand( 65 | $command, 66 | "Method '{$methodName}' does not exist on handler" 67 | ); 68 | } 69 | 70 | $handler->handle($command); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/CommandBus/TacticianCommandBus.php: -------------------------------------------------------------------------------- 1 | commandBus = $commandBus; 24 | } 25 | 26 | /** 27 | * {@inheritDoc} 28 | */ 29 | public function execute($command) 30 | { 31 | try { 32 | $this->commandBus->handle($command); 33 | } catch (TacticianException\MissingHandlerException $e) { 34 | throw new MissingHandlerException($e->getMessage(), $e->getCode(), $e); 35 | } catch (TacticianException\CanNotInvokeHandlerException $e) { 36 | throw new CanNotInvokeHandlerException($e->getMessage(), $e->getCode(), $e); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Domain/AggregateId.php: -------------------------------------------------------------------------------- 1 | value = $value; 20 | } 21 | 22 | /** 23 | * This is what gets saved to data stores. 24 | * 25 | * @return string 26 | */ 27 | public function __toString() 28 | { 29 | return $this->value->toString(); 30 | } 31 | 32 | /** 33 | * Rebuild an instance based on a string 34 | * 35 | * @param string $string 36 | * @return AggregateIdInterface 37 | */ 38 | public static function fromString($string) 39 | { 40 | return new static(Uuid::fromString($string)); 41 | } 42 | 43 | public static function generate() 44 | { 45 | return new static(Uuid::uuid4()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Domain/AggregateIdInterface.php: -------------------------------------------------------------------------------- 1 | id = (string) $id; 39 | $this->payload = $payload; 40 | $this->recordedOn = $recordedOn->setTimezone(new \DateTimeZone('UTC')); 41 | $this->version = $version; 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | public function getId() 48 | { 49 | return $this->id; 50 | } 51 | 52 | /** 53 | * @return DomainEventInterface 54 | */ 55 | public function getPayload() 56 | { 57 | return $this->payload; 58 | } 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | public function getRecordedOn() 64 | { 65 | return $this->recordedOn; 66 | } 67 | 68 | /** 69 | * @inheritDoc 70 | */ 71 | public function getType() 72 | { 73 | return get_class($this->payload); 74 | } 75 | 76 | /** 77 | * @param string $id 78 | * @param $version 79 | * @param DomainEventInterface $payload 80 | * @return DomainMessage 81 | */ 82 | public static function recordNow($id, $version, DomainEventInterface $payload) 83 | { 84 | $recordedOn = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))); 85 | 86 | return new DomainMessage($id, $version, $payload, $recordedOn); 87 | } 88 | 89 | /** 90 | * @return int 91 | */ 92 | public function getVersion() 93 | { 94 | return $this->version; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Domain/EventStream.php: -------------------------------------------------------------------------------- 1 | name = $name; 18 | } 19 | 20 | /** 21 | * @return StreamName 22 | */ 23 | public function getName() 24 | { 25 | return $this->name; 26 | } 27 | 28 | public function __toString() 29 | { 30 | return $this->name; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Domain/Exception/AggregateDoesNotExistException.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class StreamName 12 | { 13 | /** 14 | * @var string 15 | */ 16 | protected $name; 17 | 18 | /** 19 | * @param $name 20 | */ 21 | public function __construct($name) 22 | { 23 | if (!\is_string($name)) { 24 | throw new \InvalidArgumentException('StreamName must be a string!'); 25 | } 26 | $len = \strlen($name); 27 | if ($len === 0 || $len > 200) { 28 | throw new \InvalidArgumentException('StreamName must not be empty and not longer than 200 chars!'); 29 | } 30 | $this->name = $name; 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function toString() 37 | { 38 | return $this->name; 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | public function __toString() 45 | { 46 | return $this->toString(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/EventBus/EventBusInterface.php: -------------------------------------------------------------------------------- 1 | subscribe(new ExampleListener()); 11 | 12 | $eventBus->publish(new SomeEvent()); 13 | ``` 14 | -------------------------------------------------------------------------------- /src/EventBus/SimpleEventBus.php: -------------------------------------------------------------------------------- 1 | eventListeners = new Vector(); 36 | $this->queue = new Queue(); 37 | } 38 | 39 | /** 40 | * {@inheritDoc} 41 | */ 42 | public function subscribe(EventListenerInterface $eventListener) 43 | { 44 | $this->eventListeners->add($eventListener); 45 | } 46 | 47 | /** 48 | * {@inheritDoc} 49 | */ 50 | public function publish(DomainEventInterface $event) 51 | { 52 | $this->queue->enqueue($event); 53 | 54 | if (!$this->isPublishing && !$this->queue->isEmpty()) { 55 | $this->isPublishing = true; 56 | try { 57 | while (!$this->queue->isEmpty()) { 58 | $this->processEvent($this->queue->pop()); 59 | }; 60 | } finally { 61 | $this->isPublishing = false; 62 | } 63 | } 64 | } 65 | 66 | private function processEvent(DomainEventInterface $event) 67 | { 68 | $this->eventListeners->filter(function (EventListenerInterface $eventListener) use ($event) { 69 | return $eventListener->isSubscribedTo($event); 70 | })->each(function (EventListenerInterface $eventListener) use ($event) { 71 | $eventListener->handle($event); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/EventDispatcher/EventDispatcherInterface.php: -------------------------------------------------------------------------------- 1 | listeners = new Map(); 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | public function dispatch($eventName, ...$arguments) 32 | { 33 | if (!$this->listeners->containsKey($eventName)) { 34 | return; 35 | } 36 | 37 | foreach ($this->listeners->get($eventName) as $listener) { 38 | $listener(...$arguments); 39 | } 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | */ 45 | public function addListener($eventName, callable $callable) 46 | { 47 | if (!$this->listeners->containsKey($eventName)) { 48 | $this->listeners->add(new Pair($eventName, new Vector())); 49 | } 50 | 51 | $this->listeners->get($eventName)->add($callable); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/EventDispatcher/README.md: -------------------------------------------------------------------------------- 1 | # Event Dispatcher Component 2 | 3 | Event dispatcher component providing event dispatchers to your application. 4 | 5 | The component provides an event dispatcher interface and a simple 6 | implementation. 7 | 8 | ```php 9 | $dispatcher = new EventDispatcher(); 10 | $dispatcher->addListener(SomeEvent::class, function(SomeEvent $event){ 11 | echo $event->greetings() 12 | }); 13 | ``` 14 | -------------------------------------------------------------------------------- /src/EventSourcing/AggregateRepository.php: -------------------------------------------------------------------------------- 1 | eventStore = $eventStore; 47 | $this->eventBus = $eventBus; 48 | $this->snapshotter = $snapshotter; 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | */ 54 | public function load($id, $aggregateType, StreamName $streamName = null) 55 | { 56 | if ($this->snapshotter) { 57 | $aggregateRoot = $this->loadFromSnapshotStore($id, $streamName); 58 | 59 | if ($aggregateRoot) { 60 | return $aggregateRoot; 61 | } 62 | } 63 | 64 | $streamName = $this->determineStreamName($streamName); 65 | 66 | return $aggregateType::reconstituteFromHistory($this->eventStore->getEventsFor($streamName, $id)); 67 | } 68 | 69 | /** 70 | * {@inheritDoc} 71 | */ 72 | public function save(AggregateRootInterface $aggregate, StreamName $streamName = null) 73 | { 74 | $streamName = $this->determineStreamName($streamName); 75 | $eventStream = new EventStream($streamName, $aggregate->getUncommittedEvents()); 76 | 77 | $this->eventStore->append($eventStream); 78 | 79 | $eventStream->each(function (DomainMessage $domainMessage) { 80 | $this->eventBus->publish($domainMessage->getPayload()); 81 | })->each(function (DomainMessage $domainMessage) use ($streamName, $aggregate) { 82 | if ($this->snapshotter) { 83 | $this->snapshotter->take($streamName, $aggregate, $domainMessage); 84 | } 85 | }); 86 | } 87 | 88 | /** 89 | * {@inheritDoc} 90 | */ 91 | private function loadFromSnapshotStore(AggregateIdInterface $aggregateId, StreamName $streamName = null) 92 | { 93 | $snapshot = $this->snapshotter->get($aggregateId); 94 | 95 | if (null === $snapshot) { 96 | return null; 97 | } 98 | 99 | $streamName = $this->determineStreamName($streamName); 100 | $aggregateRoot = $snapshot->getAggregate(); 101 | $stream = $this->eventStore->fromVersion($streamName, $aggregateId, $snapshot->getVersion() + 1); 102 | 103 | if (!$stream->getIterator()->valid()) { 104 | return $aggregateRoot; 105 | } 106 | 107 | $aggregateRoot->replay($stream); 108 | 109 | return $aggregateRoot; 110 | } 111 | 112 | /** 113 | * Default stream name generation. 114 | * Override this method in an extending repository to provide a custom name 115 | * 116 | * @param StreamName $streamName 117 | * @return StreamName 118 | * @internal param null|string $aggregateId 119 | */ 120 | protected function determineStreamName(StreamName $streamName = null) 121 | { 122 | if (null === $streamName) { 123 | return new StreamName('event_stream'); 124 | } 125 | 126 | return $streamName; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/EventSourcing/AggregateRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | config = new Map([ 33 | 'event_bus' => [ 34 | 'service' => new SimpleEventBus() 35 | ], 36 | 'event_store' => [ 37 | 'adapter' => InMemoryAdapter::class 38 | ], 39 | 'snapshotter' => [ 40 | 'enabled' => false, 41 | 'store' => [ 42 | 'adapter' => InMemorySnapshotAdapter::class 43 | ], 44 | 'strategy' => [ 45 | 'name' => CountSnapshotStrategy::class, 46 | 'arguments' => [] 47 | ] 48 | ] 49 | ]); 50 | 51 | if ($config) { 52 | $this->config->concat($config); 53 | } 54 | } 55 | 56 | public function build() 57 | { 58 | $eventBus = $this->config->get('event_bus')->get('service'); 59 | $eventStore = $this->configureEventStore($this->config->get('event_store')); 60 | $snapshotter = null; 61 | 62 | /** @var MapInterface $snapshotterConfig */ 63 | $snapshotterConfig = $this->config->get('snapshotter'); 64 | 65 | if (true === $snapshotterConfig->get('enabled')) { 66 | $snapshotStore = $this->configureSnapshotStore($snapshotterConfig->get('store')); 67 | $strategy = $this->configureSnapshotStrategy($snapshotterConfig->get('strategy')); 68 | $snapshotter = new Snapshotter($snapshotStore, $strategy); 69 | } 70 | 71 | return new AggregateRepository($eventStore, $eventBus, $snapshotter); 72 | } 73 | 74 | private function configureEventStore(MapInterface $config) 75 | { 76 | $adapterName = $config->get('adapter'); 77 | $arguments = $config->get('arguments') ? $config->get('arguments') : []; 78 | 79 | $adapter = new $adapterName(...$arguments); 80 | 81 | return new EventStore($adapter); 82 | } 83 | 84 | private function configureSnapshotStore(MapInterface $config) 85 | { 86 | $adapterName = $config->get('adapter'); 87 | $arguments = $config->get('arguments') ? $config->get('arguments') : []; 88 | 89 | $adapter = new $adapterName(...$arguments); 90 | 91 | return new SnapshotStore($adapter); 92 | } 93 | 94 | private function configureSnapshotStrategy(MapInterface $config) 95 | { 96 | $adapterName = $config->get('name'); 97 | $arguments = $config->get('arguments') ? $config->get('arguments') : []; 98 | 99 | return new $adapterName(...$arguments); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/EventSourcing/AggregateRepositoryFactoryInterface.php: -------------------------------------------------------------------------------- 1 | replay($historyEvents); 25 | 26 | return $instance; 27 | } 28 | 29 | public function getUncommittedEvents() 30 | { 31 | $stream = $this->uncommittedEvents; 32 | $this->uncommittedEvents = []; 33 | 34 | return $stream; 35 | } 36 | 37 | /** 38 | * Replay past events 39 | * 40 | * @param EventStream $historyEvents 41 | * 42 | * @param null $version 43 | */ 44 | public function replay(EventStream $historyEvents, $version = null) 45 | { 46 | if (null !== $version) { 47 | $this->version = $version; 48 | } 49 | 50 | $historyEvents->each(function (DomainMessage $pastEvent) { 51 | $this->version = $pastEvent->getVersion(); 52 | $this->apply($pastEvent->getPayload()); 53 | }); 54 | } 55 | 56 | /** 57 | * Apply given event 58 | * 59 | * @param DomainEventInterface $event 60 | */ 61 | protected function apply(DomainEventInterface $event) 62 | { 63 | $handler = $this->determineEventHandlerMethodFor($event); 64 | 65 | if (!method_exists($this, $handler)) { 66 | return; 67 | } 68 | 69 | $this->{$handler}($event); 70 | } 71 | 72 | protected function recordThat(DomainEventInterface $event) 73 | { 74 | $this->version += 1; 75 | $this->apply($event); 76 | $this->record($event); 77 | 78 | return $this; 79 | } 80 | 81 | /** 82 | * Determine event name 83 | * 84 | * @param DomainEventInterface $event 85 | * @return string 86 | */ 87 | protected function determineEventHandlerMethodFor(DomainEventInterface $event) 88 | { 89 | $parts = explode('\\', get_class($event)); 90 | 91 | return 'when' . end($parts); 92 | } 93 | 94 | private function record(DomainEventInterface $event) 95 | { 96 | $this->uncommittedEvents[] = DomainMessage::recordNow( 97 | $this->getAggregateRootId(), 98 | $this->version, 99 | $event 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/EventSourcing/README.md: -------------------------------------------------------------------------------- 1 | # Event Sourcing Component 2 | 3 | Component building on top of other components to provide a full event sourcing experience. 4 | 5 | This component provides base classes for event sourced aggregate roots and entities, an event sourced repository 6 | implementation and testing helpers. 7 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/DbalAdapter.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 23 | $this->serializer = $serializer; 24 | } 25 | 26 | public function save(StreamName $streamName, DomainMessage $event) 27 | { 28 | $data = $this->createEventData($event); 29 | $this->connection->insert($streamName, $data); 30 | } 31 | 32 | public function getEventsFor(StreamName $streamName, $id) 33 | { 34 | $queryBuilder = $this->getQueryBuilder($streamName); 35 | $queryBuilder->where('aggregate_id = :id') 36 | ->addOrderBy('version') 37 | ->setParameter('id', (string)$id); 38 | 39 | $serializedEvents = $queryBuilder->execute(); 40 | 41 | return $this->processEvents($serializedEvents); 42 | } 43 | 44 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 45 | { 46 | $queryBuilder = $this->getQueryBuilder($streamName); 47 | $queryBuilder->where('aggregate_id = :id') 48 | ->andWhere('version >= :version') 49 | ->addOrderBy('version') 50 | ->setParameter('id', (string)$aggregateId) 51 | ->setParameter('version', $version); 52 | 53 | $serializedEvents = $queryBuilder->execute(); 54 | 55 | return $this->processEvents($serializedEvents); 56 | } 57 | 58 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 59 | { 60 | $queryBuilder = $this->getQueryBuilder($streamName) 61 | ->select('count(aggregate_id)') 62 | ->where('aggregate_id = :id') 63 | ->setParameter('id', (string)$aggregateId); 64 | 65 | return $queryBuilder->execute()->fetch()["count"]; 66 | } 67 | 68 | private function getQueryBuilder(StreamName $streamName) 69 | { 70 | $queryBuilder = $this->connection->createQueryBuilder(); 71 | 72 | return $queryBuilder 73 | ->select('*') 74 | ->from((string)$streamName); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/EventProcessorTrait.php: -------------------------------------------------------------------------------- 1 | (string)$event->getId(), 20 | 'version' => $event->getVersion(), 21 | 'type' => $event->getType(), 22 | 'payload' => $this->serializer->serialize($event->getPayload(), 'json'), 23 | 'recorded_on' => $event->getRecordedOn()->format('Y-m-d\TH:i:s.u'), 24 | ]; 25 | } 26 | 27 | private function processEvents($serializedEvents) 28 | { 29 | if (!$serializedEvents) { 30 | throw new EventStreamNotFoundException('The event stream doesn\'t exists'); 31 | } 32 | 33 | $eventStream = []; 34 | 35 | foreach ($serializedEvents as $eventData) { 36 | if (is_string($eventData)) { 37 | $eventData = $this->serializer->deserialize($eventData, 'array', 'json'); 38 | } 39 | 40 | $payload = $this->serializer->deserialize($eventData['payload'], $eventData['type'], 'json'); 41 | 42 | $eventStream[] = new DomainMessage( 43 | $eventData['aggregate_id'], 44 | $eventData['version'], 45 | $payload, 46 | \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.u', $eventData['recorded_on']) 47 | ); 48 | } 49 | 50 | return $eventStream; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/EventStoreAdapterInterface.php: -------------------------------------------------------------------------------- 1 | events = new Map(); 25 | } 26 | 27 | public function save(StreamName $streamName, DomainMessage $event) 28 | { 29 | $id = (string)$event->getId(); 30 | $name = (string)$streamName; 31 | $events = $this->events->get($name); 32 | 33 | if (null === $events) { 34 | $events = new Map(); 35 | $this->events->add(new Pair($name, $events)); 36 | } 37 | 38 | if (!$events->containsKey($id)) { 39 | $events->add(new Pair($id, new Vector())); 40 | } 41 | 42 | $events->get($id)->add($event); 43 | } 44 | 45 | public function getEventsFor(StreamName $streamName, $id) 46 | { 47 | $id = (string)$id; 48 | $name = (string)$streamName; 49 | /** @var MapInterface $events */ 50 | try { 51 | $events = $this->events->at($name); 52 | } catch (\OutOfBoundsException $e) { 53 | throw new EventStreamNotFoundException("Stream $name not found", $e->getCode(), $e); 54 | } 55 | 56 | if (!$events->containsKey($id)) { 57 | throw new EventStreamNotFoundException(); 58 | } 59 | 60 | return $events->get($id); 61 | } 62 | 63 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 64 | { 65 | $name = (string)$streamName; 66 | /** @var MapInterface $events */ 67 | $events = $this->events->get($name); 68 | /** @var VectorInterface $aggregateEvents */ 69 | $aggregateEvents = $events->get((string)$aggregateId); 70 | 71 | return $aggregateEvents->filter(function (DomainMessage $message) use ($aggregateId) { 72 | return $message->getId() === $aggregateId; 73 | })->filter(function (DomainMessage $message) use ($version) { 74 | return $message->getVersion() >= $version; 75 | }); 76 | } 77 | 78 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 79 | { 80 | $name = (string)$streamName; 81 | /** @var MapInterface $events */ 82 | $events = $this->events->get($name); 83 | /** @var MapInterface $stream */ 84 | $stream = $events->get((string)$aggregateId); 85 | 86 | return $stream->count(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/MongoAdapter.php: -------------------------------------------------------------------------------- 1 | client = $client; 30 | $this->serializer = $serializer; 31 | $this->dbName = $dbName; 32 | } 33 | 34 | public function save(StreamName $streamName, DomainMessage $event) 35 | { 36 | $this->createIndexes($streamName); 37 | $data = $this->createEventData($event); 38 | $this->getCollection($streamName)->insert($data); 39 | } 40 | 41 | public function getEventsFor(StreamName $streamName, $id) 42 | { 43 | $query['aggregate_id'] = (string)$id; 44 | 45 | $collection = $this->getCollection($streamName); 46 | $serializedEvents = $collection->find($query)->sort(['version' => \MongoCollection::ASCENDING]); 47 | 48 | return $this->processEvents($serializedEvents); 49 | } 50 | 51 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 52 | { 53 | $query['aggregate_id'] = (string)$aggregateId; 54 | 55 | if (null !== $version) { 56 | $query['version'] = ['$gte' => $version]; 57 | } 58 | 59 | $collection = $this->getCollection($streamName); 60 | $serializedEvents = $collection 61 | ->find($query) 62 | ->sort(['version' => \MongoCollection::ASCENDING]); 63 | 64 | return $this->processEvents($serializedEvents); 65 | } 66 | 67 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 68 | { 69 | $query['aggregate_id'] = (string)$aggregateId; 70 | $collection = $this->getCollection($streamName); 71 | 72 | return $collection->count($query); 73 | } 74 | 75 | /** 76 | * Get mongo db stream collection 77 | * 78 | * @param StreamName $streamName 79 | * @return \MongoCollection 80 | */ 81 | private function getCollection(StreamName $streamName) 82 | { 83 | return $this->client->selectCollection($this->dbName, (string)$streamName); 84 | } 85 | 86 | /** 87 | * @param StreamName $streamName 88 | */ 89 | private function createIndexes(StreamName $streamName) 90 | { 91 | $collection = $this->getCollection($streamName); 92 | $collection->createIndex( 93 | [ 94 | 'aggregate_id' => 1, 95 | 'version' => 1, 96 | ], 97 | [ 98 | 'unique' => true, 99 | ] 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/MongoDbAdapter.php: -------------------------------------------------------------------------------- 1 | client = $client; 37 | $this->serializer = $serializer; 38 | $this->dbName = $dbName; 39 | } 40 | 41 | public function save(StreamName $streamName, DomainMessage $event) 42 | { 43 | $this->createIndexes($streamName); 44 | $data = $this->createEventData($event); 45 | $this->getCollection($streamName)->insertOne($data); 46 | } 47 | 48 | public function getEventsFor(StreamName $streamName, $id) 49 | { 50 | $query['aggregate_id'] = (string)$id; 51 | 52 | $collection = $this->getCollection($streamName); 53 | $serializedEvents = $collection->find($query, ['sort' => ['version' => 1]]); 54 | 55 | return $this->processEvents($serializedEvents); 56 | } 57 | 58 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 59 | { 60 | $query['aggregate_id'] = (string)$aggregateId; 61 | 62 | if (null !== $version) { 63 | $query['version'] = ['$gte' => $version]; 64 | } 65 | 66 | $collection = $this->getCollection($streamName); 67 | $serializedEvents = $collection->find($query, ['sort' => ['version' => 1]]); 68 | 69 | return $this->processEvents($serializedEvents); 70 | } 71 | 72 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 73 | { 74 | $query['aggregate_id'] = (string)$aggregateId; 75 | $collection = $this->getCollection($streamName); 76 | 77 | return $collection->count($query); 78 | } 79 | 80 | /** 81 | * Get mongo db stream collection 82 | * 83 | * @param StreamName $streamName 84 | * @return Collection 85 | */ 86 | private function getCollection(StreamName $streamName) 87 | { 88 | return $this->client->selectCollection($this->dbName, (string)$streamName); 89 | } 90 | 91 | /** 92 | * @param StreamName $streamName 93 | */ 94 | private function createIndexes(StreamName $streamName) 95 | { 96 | $collection = $this->getCollection($streamName); 97 | $collection->createIndex( 98 | [ 99 | 'aggregate_id' => 1, 100 | 'version' => 1, 101 | ], 102 | [ 103 | 'unique' => true, 104 | ] 105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/RedisAdapter.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 24 | $this->serializer = $serializer; 25 | } 26 | 27 | public function save(StreamName $streamName, DomainMessage $event) 28 | { 29 | $data = $this->serializer->serialize($this->createEventData($event), 'json'); 30 | 31 | $this->redis->lpush($this->getNamespaceKey($streamName, $event->getId()), $data); 32 | $this->redis->rpush('published_events', $data); 33 | } 34 | 35 | public function getEventsFor(StreamName $streamName, $id) 36 | { 37 | if (!$this->redis->exists($this->getNamespaceKey($streamName, $id))) { 38 | throw new EventStreamNotFoundException($id); 39 | } 40 | 41 | $serializedEvents = $this->redis->lrange($this->getNamespaceKey($streamName, $id), 0, -1); 42 | 43 | return $this->processEvents($serializedEvents); 44 | } 45 | 46 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 47 | { 48 | if (!$this->redis->exists($this->getNamespaceKey($streamName, $aggregateId))) { 49 | throw new EventStreamNotFoundException($aggregateId); 50 | } 51 | 52 | $serializedEvents = $this->redis->lrange($this->getNamespaceKey($streamName, $aggregateId), 0, $version); 53 | 54 | return $this->processEvents($serializedEvents); 55 | } 56 | 57 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 58 | { 59 | return count($this->redis->lrange($this->getNamespaceKey($streamName, $aggregateId), 0, -1)); 60 | } 61 | 62 | private function getNamespaceKey(StreamName $streamName, $aggregateId) 63 | { 64 | return sprintf('events:%s:%s', $streamName, $aggregateId); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/EventStore/Adapter/Schema/DbalSchema.php: -------------------------------------------------------------------------------- 1 | getSchemaManager(); 19 | $fromSchema = $sm->createSchema(); 20 | 21 | $toSchema = clone $fromSchema; 22 | static::addToSchema($toSchema, static::TABLE_NAME); 23 | 24 | 25 | $sqls = $fromSchema->getMigrateToSql($toSchema, $connection->getDatabasePlatform()); 26 | 27 | foreach ($sqls as $sql) { 28 | $connection->executeQuery($sql); 29 | } 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @param string $table 35 | * @throws \Doctrine\DBAL\Schema\SchemaException 36 | */ 37 | public static function addToSchema(Schema $schema, $table) 38 | { 39 | if ($schema->hasTable($table)) { 40 | $table = $schema->getTable($table); 41 | } else { 42 | $table = $schema->createTable($table); 43 | } 44 | 45 | if (!$table->hasColumn('event_id')) { 46 | $id = $table->addColumn('event_id', 'integer', ['unsigned' => true]); 47 | $id->setAutoincrement(true); 48 | $table->setPrimaryKey(['event_id']); 49 | } 50 | 51 | if (!$table->hasColumn('aggregate_id')) { 52 | $table->addColumn('aggregate_id', 'string', ['length' => 50]); 53 | } 54 | 55 | if (!$table->hasColumn('version')) { 56 | $table->addColumn('version', 'integer'); 57 | } 58 | 59 | if (!$table->hasColumn('type')) { 60 | $table->addColumn('type', 'string', ['length' => 100]); 61 | } 62 | 63 | if (!$table->hasColumn('payload')) { 64 | $table->addColumn('payload', 'text'); 65 | } 66 | 67 | if (!$table->hasColumn('recorded_on')) { 68 | $table->addColumn('recorded_on', 'string', ['length' => 50]); 69 | } 70 | 71 | $table->addUniqueIndex(['aggregate_id', 'version']); 72 | } 73 | 74 | /** 75 | * @param Connection $connection 76 | * @throws \Doctrine\DBAL\DBALException 77 | */ 78 | public static function dropSchema(Connection $connection) 79 | { 80 | $sm = $connection->getSchemaManager(); 81 | $fromSchema = $sm->createSchema(); 82 | $toSchema = clone $fromSchema; 83 | 84 | $toSchema->dropTable(static::TABLE_NAME); 85 | $sqls = $fromSchema->getMigrateToSql($toSchema, $connection->getDatabasePlatform()); 86 | 87 | foreach ($sqls as $sql) { 88 | $connection->executeQuery($sql); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/EventStore/EventStore.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 25 | } 26 | 27 | public function append(EventStream $events) 28 | { 29 | $streamName = $events->getName(); 30 | $events->each(function (DomainMessage $event) use ($streamName) { 31 | $this->adapter->save($streamName, $event); 32 | }); 33 | } 34 | 35 | public function getEventsFor(StreamName $streamName, $id) 36 | { 37 | $stream = $this->adapter->getEventsFor($streamName, $id); 38 | 39 | return new EventStream($streamName, $stream); 40 | } 41 | 42 | public function fromVersion(StreamName $streamName, AggregateIdInterface $aggregateId, $version) 43 | { 44 | $stream = $this->adapter->fromVersion($streamName, $aggregateId, $version); 45 | 46 | return new EventStream($streamName, $stream); 47 | } 48 | 49 | public function countEventsFor(StreamName $streamName, AggregateIdInterface $aggregateId) 50 | { 51 | return $this->adapter->countEventsFor($streamName, $aggregateId); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/EventStore/EventStoreInterface.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 27 | $this->serializer = $serializer; 28 | $this->tableName = $tableName; 29 | } 30 | 31 | /** 32 | * @inheritdoc 33 | */ 34 | public function byId(AggregateIdInterface $id) 35 | { 36 | $queryBuilder = $this->getQueryBuilder() 37 | ->where('aggregate_id = :id') 38 | ->addOrderBy('version') 39 | ->setMaxResults(1) 40 | ->setParameter('id', (string)$id); 41 | 42 | $snapshot = $queryBuilder->execute()->fetch(); 43 | 44 | return $this->processSnapshot($snapshot); 45 | } 46 | 47 | /** 48 | * @inheritdoc 49 | */ 50 | public function save(Snapshot $snapshot) 51 | { 52 | $data = $this->createEventData($snapshot); 53 | $this->connection->insert($this->tableName, $data); 54 | } 55 | 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | public function has(AggregateIdInterface $id, $version) 61 | { 62 | $queryBuilder = $this->getQueryBuilder() 63 | ->where('aggregate_id = :id') 64 | ->setMaxResults(1) 65 | ->addOrderBy('version') 66 | ->setParameter('id', (string)$id); 67 | 68 | $metadata = $queryBuilder->execute()->fetch(); 69 | 70 | if (empty($metadata)) { 71 | return false; 72 | } 73 | 74 | return $metadata['version'] === $version; 75 | } 76 | 77 | private function getQueryBuilder() 78 | { 79 | $queryBuilder = $this->connection->createQueryBuilder(); 80 | 81 | return $queryBuilder 82 | ->select('*') 83 | ->from($this->tableName); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Adapter/InMemorySnapshotAdapter.php: -------------------------------------------------------------------------------- 1 | snapshots = new Map(); 21 | } 22 | 23 | public function byId(AggregateIdInterface $id) 24 | { 25 | return $this->snapshots->get((string)$id); 26 | } 27 | 28 | public function save(Snapshot $snapshot) 29 | { 30 | $this->snapshots->add(new Pair((string)$snapshot->getAggregateId(), $snapshot)); 31 | } 32 | 33 | public function has(AggregateIdInterface $id, $version) 34 | { 35 | return $this->snapshots->containsKey((string)$id); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Adapter/RedisSnapshotAdapter.php: -------------------------------------------------------------------------------- 1 | redis = $redis; 28 | $this->serializer = $serializer; 29 | } 30 | 31 | /** 32 | * @inheritdoc 33 | */ 34 | public function byId(AggregateIdInterface $id) 35 | { 36 | if (!$this->redis->hexists(static::KEY_NAMESPACE, (string)$id)) { 37 | return null; 38 | } 39 | 40 | $metadata = $this->serializer->deserialize( 41 | $this->redis->hget(static::KEY_NAMESPACE, (string)$id), 42 | 'array', 43 | 'json' 44 | ); 45 | 46 | if (!is_array($metadata)) { 47 | return null; 48 | } 49 | 50 | /** @var AggregateRootInterface $aggregate */ 51 | $aggregate = $this->serializer->deserialize( 52 | $metadata['snapshot']['payload'], 53 | $metadata['snapshot']['type'], 54 | 'json' 55 | ); 56 | 57 | $createdAt = \DateTimeImmutable::createFromFormat('U.u', $metadata['created_at']); 58 | $createdAt->setTimezone(new \DateTimeZone('UTC')); 59 | 60 | return new Snapshot( 61 | $aggregate->getAggregateRootId(), 62 | $aggregate, 63 | $metadata['version'], 64 | $createdAt 65 | ); 66 | } 67 | 68 | /** 69 | * @inheritdoc 70 | */ 71 | public function save(Snapshot $snapshot) 72 | { 73 | $data = [ 74 | 'version' => $snapshot->getVersion(), 75 | 'created_at' => $snapshot->getCreatedAt()->format('U.u'), 76 | 'snapshot' => [ 77 | 'type' => $snapshot->getType(), 78 | 'payload' => $this->serializer->serialize($snapshot->getAggregate(), 'json') 79 | ] 80 | ]; 81 | 82 | $this->redis->hset( 83 | static::KEY_NAMESPACE, 84 | (string)$snapshot->getAggregateId(), 85 | $this->serializer->serialize($data, 'json') 86 | ); 87 | } 88 | 89 | 90 | /** 91 | * @inheritdoc 92 | */ 93 | public function has(AggregateIdInterface $id, $version) 94 | { 95 | if (!$this->redis->hexists(static::KEY_NAMESPACE, (string)$id)) { 96 | return false; 97 | } 98 | 99 | $snapshot = $this->serializer->deserialize( 100 | $this->redis->hget(static::KEY_NAMESPACE, (string)$id), 101 | 'array', 102 | 'json' 103 | ); 104 | 105 | return $snapshot['version'] === $version; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Adapter/Schema/SnapshotSchema.php: -------------------------------------------------------------------------------- 1 | getSchemaManager(); 19 | $fromSchema = $sm->createSchema(); 20 | 21 | $toSchema = clone $fromSchema; 22 | static::addToSchema($toSchema, static::TABLE_NAME); 23 | 24 | 25 | $sqls = $fromSchema->getMigrateToSql($toSchema, $connection->getDatabasePlatform()); 26 | 27 | foreach ($sqls as $sql) { 28 | $connection->executeQuery($sql); 29 | } 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * @param string $table 35 | * @throws \Doctrine\DBAL\Schema\SchemaException 36 | */ 37 | public static function addToSchema(Schema $schema, $table) 38 | { 39 | if ($schema->hasTable($table)) { 40 | $table = $schema->getTable($table); 41 | } else { 42 | $table = $schema->createTable($table); 43 | } 44 | 45 | if (!$table->hasColumn('snapshot_id')) { 46 | $id = $table->addColumn('snapshot_id', 'integer', ['unsigned' => true]); 47 | $id->setAutoincrement(true); 48 | $table->setPrimaryKey(['snapshot_id']); 49 | } 50 | 51 | if (!$table->hasColumn('aggregate_id')) { 52 | $table->addColumn('aggregate_id', 'string', ['length' => 50]); 53 | } 54 | 55 | if (!$table->hasColumn('version')) { 56 | $table->addColumn('version', 'integer'); 57 | } 58 | 59 | if (!$table->hasColumn('type')) { 60 | $table->addColumn('type', 'string', ['length' => 100]); 61 | } 62 | 63 | if (!$table->hasColumn('payload')) { 64 | $table->addColumn('payload', 'text'); 65 | } 66 | 67 | if (!$table->hasColumn('created_at')) { 68 | $table->addColumn('created_at', 'string', ['length' => 50]); 69 | } 70 | } 71 | 72 | /** 73 | * @param Connection $connection 74 | * @throws \Doctrine\DBAL\DBALException 75 | */ 76 | public static function dropSchema(Connection $connection) 77 | { 78 | $sm = $connection->getSchemaManager(); 79 | $fromSchema = $sm->createSchema(); 80 | $toSchema = clone $fromSchema; 81 | 82 | $toSchema->dropTable(static::TABLE_NAME); 83 | $sqls = $fromSchema->getMigrateToSql($toSchema, $connection->getDatabasePlatform()); 84 | 85 | foreach ($sqls as $sql) { 86 | $connection->executeQuery($sql); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Adapter/SnapshotProcessorTrait.php: -------------------------------------------------------------------------------- 1 | $snapshot->getAggregateId(), 21 | 'version' => $snapshot->getVersion(), 22 | 'created_at' => $snapshot->getCreatedAt()->getTimestamp(), 23 | 'type' => $snapshot->getType(), 24 | 'payload' => $this->serializer->serialize($snapshot->getAggregate(), 'json') 25 | ]; 26 | } 27 | 28 | private function processSnapshot($metadata) 29 | { 30 | if (false === $metadata) { 31 | return null; 32 | } 33 | 34 | /** @var AggregateRootInterface $aggregate */ 35 | $aggregate = $this->serializer->deserialize( 36 | $metadata['payload'], 37 | $metadata['type'], 38 | 'json' 39 | ); 40 | 41 | return new Snapshot( 42 | AggregateId::fromString($metadata['aggregate_id']), 43 | $aggregate, 44 | $metadata['version'], 45 | new \DateTimeImmutable("@" . $metadata['created_at']) 46 | ); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Adapter/SnapshotStoreAdapterInterface.php: -------------------------------------------------------------------------------- 1 | aggregateId = $aggregateId; 44 | $this->aggregate = $aggregate; 45 | $this->version = $version; 46 | $this->createdAt = $createdAt->setTimezone(new \DateTimeZone('UTC')); 47 | } 48 | 49 | /** 50 | * Take a snapshot 51 | * @param AggregateIdInterface $aggregateId 52 | * @param AggregateRootInterface $aggregate 53 | * @param int $version 54 | * @return static 55 | */ 56 | public static function take(AggregateIdInterface $aggregateId, AggregateRootInterface $aggregate, $version) 57 | { 58 | $dateTime = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))); 59 | 60 | return new static($aggregateId, $aggregate, $version, $dateTime); 61 | } 62 | 63 | /** 64 | * @return int 65 | */ 66 | public function getVersion() 67 | { 68 | return $this->version; 69 | } 70 | 71 | /** 72 | * @return AggregateRootInterface 73 | */ 74 | public function getAggregate() 75 | { 76 | return $this->aggregate; 77 | } 78 | 79 | /** 80 | * @return AggregateIdInterface 81 | */ 82 | public function getAggregateId() 83 | { 84 | return $this->aggregateId; 85 | } 86 | 87 | /** 88 | * @return \DateTimeImmutable 89 | */ 90 | public function getCreatedAt() 91 | { 92 | return $this->createdAt; 93 | } 94 | 95 | /** 96 | * @return string 97 | */ 98 | public function getType() 99 | { 100 | return get_class($this->aggregate); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/SnapshotStore.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 22 | } 23 | 24 | /** 25 | * @inheritdoc 26 | */ 27 | public function byId(AggregateIdInterface $id) 28 | { 29 | return $this->adapter->byId($id); 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function has(AggregateIdInterface $id, $version) 36 | { 37 | return $this->adapter->has($id, $version); 38 | } 39 | 40 | /** 41 | * @inheritdoc 42 | */ 43 | public function save(Snapshot $snapshot) 44 | { 45 | return $this->adapter->save($snapshot); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/SnapshotStoreInterface.php: -------------------------------------------------------------------------------- 1 | snapshotStore = $snapshotStore; 31 | $this->strategy = $strategy; 32 | } 33 | 34 | /** 35 | * Takes a snapshot 36 | * @param StreamName $streamName 37 | * @param AggregateRootInterface $aggregate 38 | * @param DomainMessage $message - The domain message 39 | * @return bool 40 | */ 41 | public function take(StreamName $streamName, AggregateRootInterface $aggregate, DomainMessage $message) 42 | { 43 | $id = $aggregate->getAggregateRootId(); 44 | 45 | if (!$this->strategy->isFulfilled($streamName, $aggregate)) { 46 | return false; 47 | } 48 | 49 | if (!$this->snapshotStore->has($id, $message->getVersion())) { 50 | $this->snapshotStore->save(Snapshot::take($id, $aggregate, $message->getVersion())); 51 | } 52 | 53 | return true; 54 | } 55 | 56 | /** 57 | * @param AggregateIdInterface $id 58 | * @return Snapshot 59 | */ 60 | public function get(AggregateIdInterface $id) 61 | { 62 | return $this->snapshotStore->byId($id); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Strategy/CountSnapshotStrategy.php: -------------------------------------------------------------------------------- 1 | count = $count; 29 | $this->eventStore = $eventStore; 30 | } 31 | 32 | /** 33 | * @inheritdoc 34 | */ 35 | public function isFulfilled(StreamName $streamName, AggregateRootInterface $aggregate) 36 | { 37 | $countOfEvents = $this->eventStore->countEventsFor($streamName, $aggregate->getAggregateRootId()); 38 | 39 | return $countOfEvents && (($countOfEvents % $this->count) === 0); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/EventStore/Snapshot/Strategy/SnapshotStrategyInterface.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 41 | } 42 | 43 | /** 44 | * Registers services on the given container. 45 | * 46 | * This method should only be used to configure services and parameters. 47 | * It should not get services. 48 | * 49 | * @param Container $pimple A container instance 50 | */ 51 | public function register(Container $pimple) 52 | { 53 | $pimple["$this->prefix.config"] = $this->defineDefaultConfig(); 54 | $pimple["$this->prefix.command_bus"] = $this->setUpCommandBus($pimple); 55 | $pimple["$this->prefix.event_bus"] = $this->setUpEventBus(); 56 | $pimple["$this->prefix.event_store"] = $this->setUpEventStore($pimple); 57 | $pimple["$this->prefix.snapshot_store"] = $this->setUpSnapshotStore($pimple); 58 | $pimple["$this->prefix.snapshotter"] = $this->setUpSnapshotter($pimple); 59 | $pimple["$this->prefix.repository.aggregate"] = $this->setUpAggregateRepository(); 60 | $pimple["$this->prefix.serializer"] = $this->setUpSerializer($pimple); 61 | } 62 | 63 | private function defineDefaultConfig() 64 | { 65 | return [ 66 | 'command_bus' => [ 67 | 'adapter' => 'simple' 68 | ], 69 | 'event_store' => [ 70 | 'adapter' => 'in_memory' 71 | ], 72 | 'snapshot_store' => [ 73 | 'adapter' => 'in_memory' 74 | ], 75 | 'snapshotter' => [ 76 | 'strategy' => 'count', 77 | 'strategies' => [ 78 | 'count' => [ 79 | 'events' => 100 80 | ] 81 | ] 82 | ], 83 | 'serializer' => [ 84 | 'adapter' => 'php_json' 85 | ] 86 | ]; 87 | } 88 | 89 | private function setUpCommandBus(Container $pimple) 90 | { 91 | $pimple["$this->prefix.command_bus.locator"] = function () { 92 | return new InMemoryLocator(); 93 | }; 94 | 95 | $pimple["$this->prefix.command_bus.simple"] = function (Container $c) { 96 | $locator = $c["$this->prefix.command_bus.locator"]; 97 | $mapper = $c["$this->prefix.command_bus.handlers"]; 98 | 99 | foreach ($mapper as $commandName => $handler) { 100 | $locator->addHandler($handler, $commandName); 101 | } 102 | 103 | return new SimpleCommandBus($c["$this->prefix.command_bus.locator"]); 104 | }; 105 | 106 | $pimple["$this->prefix.command_bus.tactician"] = function (Container $c) { 107 | $locator = $c['tactician.locator']; 108 | $mapper = $c["$this->prefix.command_bus.handlers"]; 109 | 110 | foreach ($mapper as $commandName => $handler) { 111 | $locator->addHandler($handler, $commandName); 112 | } 113 | 114 | return new TacticianCommandBus($c['tactician.command_bus']); 115 | }; 116 | 117 | return function (Container $c) { 118 | $adapterName = $c["$this->prefix.config"]['command_bus']['adapter']; 119 | $service = "$this->prefix.command_bus.$adapterName"; 120 | 121 | if (!isset($c[$service])) { 122 | throw new \InvalidArgumentException('Invalid event store adapter provided'); 123 | } 124 | 125 | return $c[$service]; 126 | }; 127 | } 128 | 129 | private function setUpEventBus() 130 | { 131 | return function (Container $c) { 132 | $eventBus = new SimpleEventBus(); 133 | $listeners = isset($c["$this->prefix.event_bus.listeners"]) ? $c["$this->prefix.event_bus.listeners"] : []; 134 | 135 | foreach ($listeners as $listener) { 136 | $eventBus->subscribe($listener); 137 | } 138 | 139 | return $eventBus; 140 | }; 141 | } 142 | 143 | private function setUpEventStore(Container $pimple) 144 | { 145 | $pimple["$this->prefix.event_store.adapter.in_memory"] = function () { 146 | return new InMemoryAdapter(); 147 | }; 148 | 149 | $pimple["$this->prefix.event_store.adapter.redis"] = function (Container $c) { 150 | $predisClient = $c["$this->prefix.config"]['event_store']['adapters']['redis']['client']; 151 | 152 | if (!isset($c[$predisClient])) { 153 | throw new \InvalidArgumentException('Invalid event store predis client provided'); 154 | } 155 | 156 | return new RedisAdapter($c[$predisClient], $c["$this->prefix.serializer"]); 157 | }; 158 | 159 | $pimple["$this->prefix.event_store.adapter.dbal"] = function (Container $c) { 160 | $connection = $c["$this->prefix.config"]['event_store']['adapters']['dbal']['connection']; 161 | 162 | if (!isset($c[$connection])) { 163 | throw new \InvalidArgumentException('Invalid event store doctrine connection provided'); 164 | } 165 | 166 | return new DbalAdapter($c[$connection], $c["$this->prefix.serializer"]); 167 | }; 168 | 169 | $pimple["$this->prefix.event_store.adapter.mongo"] = function (Container $c) { 170 | $mongoClient = $c["$this->prefix.config"]['event_store']['adapters']['mongo']['client']; 171 | $dbName = $c["$this->prefix.config"]['event_store']['adapters']['mongo']['db_name']; 172 | 173 | if (!isset($c[$mongoClient])) { 174 | throw new \InvalidArgumentException('Invalid event store mongo client provided'); 175 | } 176 | 177 | return new MongoAdapter($c[$mongoClient], $c["$this->prefix.serializer"], $dbName); 178 | }; 179 | 180 | $pimple["$this->prefix.event_store.adapter.mongodb"] = function (Container $c) { 181 | $mongodbClient = $c["$this->prefix.config"]['event_store']['adapters']['mongodb']['client']; 182 | $dbName = $c["$this->prefix.config"]['event_store']['adapters']['mongodb']['db_name']; 183 | 184 | if (!isset($c[$mongodbClient])) { 185 | throw new \InvalidArgumentException('Invalid event store mongodb client provided'); 186 | } 187 | 188 | return new MongoDbAdapter($c[$mongodbClient], $c["$this->prefix.serializer"], $dbName); 189 | }; 190 | 191 | return function (Container $c) { 192 | $adapterName = $c["$this->prefix.config"]['event_store']['adapter']; 193 | $service = "$this->prefix.event_store.adapter.$adapterName"; 194 | 195 | if (!isset($c[$service])) { 196 | throw new \InvalidArgumentException('Invalid event store adapter provided'); 197 | } 198 | 199 | return new EventStore($c[$service]); 200 | }; 201 | } 202 | 203 | private function setUpSnapshotStore(Container $pimple) 204 | { 205 | $pimple["$this->prefix.snapshot_store.adapter.in_memory"] = function () { 206 | return new InMemorySnapshotAdapter(); 207 | }; 208 | 209 | $pimple["$this->prefix.snapshot_store.adapter.redis"] = function (Container $c) { 210 | $predisClient = $c["$this->prefix.config"]['snapshot_store']['adapters']['redis']['client']; 211 | 212 | if (!isset($c[$predisClient])) { 213 | throw new \InvalidArgumentException('Invalid snapshot predis client provided'); 214 | } 215 | 216 | return new RedisSnapshotAdapter($c[$predisClient], $c["$this->prefix.serializer"]); 217 | }; 218 | 219 | $pimple["$this->prefix.snapshot_store.adapter.dbal"] = function (Container $c) { 220 | $connection = $c["$this->prefix.config"]['snapshot_store']['adapters']['dbal']['connection']; 221 | $tableName = $c["$this->prefix.config"]['snapshot_store']['adapters']['dbal']['table_name']; 222 | 223 | if (!isset($c[$connection])) { 224 | throw new \InvalidArgumentException('Invalid snapshot doctrine connection provided'); 225 | } 226 | 227 | return new DbalSnapshotAdapter($c[$connection], $c["$this->prefix.serializer"], $tableName); 228 | }; 229 | 230 | return function (Container $c) { 231 | $adapterName = $c["$this->prefix.config"]['snapshot_store']['adapter']; 232 | $service = "$this->prefix.snapshot_store.adapter.$adapterName"; 233 | 234 | if (!isset($c[$service])) { 235 | throw new \InvalidArgumentException('Invalid snapshot adapter provided'); 236 | } 237 | 238 | return new SnapshotStore($c[$service]); 239 | }; 240 | } 241 | 242 | private function setUpSnapshotter(Container $pimple) 243 | { 244 | $pimple["$this->prefix.snapshotter.strategy.count"] = function (Container $c) { 245 | $count = $c["$this->prefix.config"]['snapshotter']['strategies']['count']['events']; 246 | 247 | return new CountSnapshotStrategy($c["$this->prefix.event_store"], $count); 248 | }; 249 | 250 | return function (Container $c) { 251 | $strategyName = $c["$this->prefix.config"]['snapshotter']["strategy"]; 252 | $service = "$this->prefix.snapshotter.strategy.$strategyName"; 253 | 254 | if (!isset($c[$service])) { 255 | throw new \InvalidArgumentException('Invalid snapshotter strategy provided'); 256 | } 257 | 258 | return new Snapshotter($c["$this->prefix.snapshot_store"], $c[$service]); 259 | }; 260 | } 261 | 262 | private function setUpAggregateRepository() 263 | { 264 | return function (Container $c) { 265 | return new AggregateRepository( 266 | $c["$this->prefix.event_store"], 267 | $c["$this->prefix.event_bus"], 268 | $c["$this->prefix.snapshotter"] 269 | ); 270 | }; 271 | } 272 | 273 | private function setUpSerializer(Container $pimple) 274 | { 275 | $pimple["$this->prefix.serializer.adapter.jms"] = function (Container $c) { 276 | $jmsClient = $c["$this->prefix.config"]['serializer']['adapters']['jms']['client']; 277 | 278 | if (!isset($c[$jmsClient])) { 279 | throw new \InvalidArgumentException('Invalid JMS client provided'); 280 | } 281 | 282 | return new JmsSerializerAdapter($c[$jmsClient]); 283 | }; 284 | 285 | $pimple["$this->prefix.serializer.adapter.symfony"] = function (Container $c) { 286 | $symfonySerializer = $c["$this->prefix.config"]['serializer']['adapters']['symfony']['client']; 287 | 288 | if (!isset($c[$symfonySerializer])) { 289 | throw new \InvalidArgumentException('Invalid symfony serializer client provided'); 290 | } 291 | 292 | return new JmsSerializerAdapter($c[$symfonySerializer]); 293 | }; 294 | 295 | $pimple["$this->prefix.serializer.adapter.php_json"] = function () { 296 | return new PhpJsonSerializerAdapter(); 297 | }; 298 | 299 | $pimple["$this->prefix.serializer.adapter.php"] = function () { 300 | return new PhpSerializerAdapter(); 301 | }; 302 | 303 | return function (Container $c) { 304 | $adapterName = $c["$this->prefix.config"]['serializer']['adapter']; 305 | $service = "$this->prefix.serializer.adapter.$adapterName"; 306 | 307 | if (!isset($c[$service])) { 308 | throw new \InvalidArgumentException('Invalid serializer adapter provided'); 309 | } 310 | 311 | return $c[$service]; 312 | }; 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/Serializer/Adapter/JmsSerializerAdapter.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 20 | } 21 | 22 | /** 23 | * @inheritdoc 24 | */ 25 | public function serialize($data, $format, $groups = null) 26 | { 27 | $context = null; 28 | 29 | if ($groups) { 30 | $context = SerializationContext::create()->setGroups($groups); 31 | } 32 | 33 | return $this->serializer->serialize($data, $format, $context); 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function deserialize($data, $type, $format, $groups = null) 40 | { 41 | $context = null; 42 | 43 | if ($groups) { 44 | $context = DeserializationContext::create()->setGroups($groups); 45 | } 46 | 47 | return $this->serializer->deserialize($data, $type, $format, $context); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Serializer/Adapter/PhpJsonSerializerAdapter.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 19 | } 20 | 21 | /** 22 | * @inheritdoc 23 | */ 24 | public function serialize($data, $format, $groups = null) 25 | { 26 | return $this->serializer->serialize($data, $format, $groups); 27 | } 28 | 29 | /** 30 | * @inheritdoc 31 | */ 32 | public function deserialize($data, $type, $format, $groups = null) 33 | { 34 | return $this->serializer->deserialize($data, $type, $format, $groups); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Serializer/Exception/DeserializationInvalidValueException.php: -------------------------------------------------------------------------------- 1 | getMessage()), 18 | 0, 19 | $exception 20 | ); 21 | $this->fieldPath = $fieldPath; 22 | } 23 | 24 | /** 25 | * @return string 26 | */ 27 | public function getFieldPath() 28 | { 29 | return $this->fieldPath; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Serializer/Exception/InvalidUuidException.php: -------------------------------------------------------------------------------- 1 | invalidUuid = $invalidUuid; 21 | } 22 | 23 | /** 24 | * @return string 25 | */ 26 | public function getInvalidUuid() 27 | { 28 | return $this->invalidUuid; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Serializer/README.md: -------------------------------------------------------------------------------- 1 | # Event Store Component 2 | 3 | Serializer component provides serializers to your application. 4 | 5 | The component provides a simple serializer interface and a serializer implementation based on "handwritten" 6 | serializers. 7 | 8 | The available adapters are: 9 | 10 | * JMS Serializer 11 | * Symfony Serializer 12 | * PHP Serializer 13 | 14 | ## Usage 15 | 16 | ```php 17 | use JMS\Serializer\Handler\HandlerRegistry; 18 | use JMS\Serializer\SerializerBuilder; 19 | use HelloFresh\Engine\Serializer\Adapter\JmsSerializerAdapter; 20 | 21 | $jmsSerializer = SerializerBuilder::create() 22 | ->setMetadataDirs(['' => __DIR__ . '/metadata']) 23 | ->configureHandlers(function (HandlerRegistry $registry) { 24 | $registry->registerSubscribingHandler(new VectorHandler()); 25 | $registry->registerSubscribingHandler(new UuidSerializerHandler()); 26 | }) 27 | ->addDefaultHandlers() 28 | ->build(); 29 | 30 | $serializer = new JmsSerializerAdapter($jmsSerializer); 31 | ``` 32 | -------------------------------------------------------------------------------- /src/Serializer/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | 'DateTimeImmutable', 27 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 28 | 'format' => $format, 29 | ); 30 | 31 | foreach ($types as $type) { 32 | $methods[] = array( 33 | 'type' => $type, 34 | 'format' => $format, 35 | 'direction' => GraphNavigator::DIRECTION_SERIALIZATION, 36 | 'method' => 'serialize' . $type, 37 | ); 38 | } 39 | } 40 | 41 | return $methods; 42 | } 43 | 44 | public function __construct($defaultFormat = \DateTime::ISO8601, $defaultTimezone = 'UTC', $xmlCData = true) 45 | { 46 | $this->defaultFormat = $defaultFormat; 47 | $this->defaultTimezone = new \DateTimeZone($defaultTimezone); 48 | $this->xmlCData = $xmlCData; 49 | } 50 | 51 | public function serializeDateTimeImmutable( 52 | VisitorInterface $visitor, 53 | \DateTimeInterface $date, 54 | array $type, 55 | Context $context 56 | ) { 57 | if ($visitor instanceof XmlSerializationVisitor && false === $this->xmlCData) { 58 | return $visitor->visitSimpleString($date->format($this->getFormat($type)), $type, $context); 59 | } 60 | 61 | return $visitor->visitString($date->format($this->getFormat($type)), $type, $context); 62 | } 63 | 64 | public function deserializeDateTimeImmutableFromXml(XmlDeserializationVisitor $visitor, $data, array $type) 65 | { 66 | $attributes = $data->attributes('xsi', true); 67 | if (isset($attributes['nil'][0]) && (string)$attributes['nil'][0] === 'true') { 68 | return null; 69 | } 70 | 71 | return $this->parseDateTime($data, $type); 72 | } 73 | 74 | public function deserializeDateTimeImmutableFromJson(JsonDeserializationVisitor $visitor, $data, array $type) 75 | { 76 | if (null === $data) { 77 | return null; 78 | } 79 | 80 | return $this->parseDateTime($data, $type); 81 | } 82 | 83 | private function parseDateTime($data, array $type) 84 | { 85 | $timezone = isset($type['params'][1]) ? new \DateTimeZone($type['params'][1]) : $this->defaultTimezone; 86 | $format = $this->getFormat($type); 87 | $datetime = \DateTimeImmutable::createFromFormat($format, (string)$data, $timezone); 88 | if (false === $datetime) { 89 | throw new \RuntimeException(sprintf('Invalid datetime "%s", expected format %s.', $data, $format)); 90 | } 91 | 92 | return $datetime; 93 | } 94 | 95 | /** 96 | * @return string 97 | * @param array $type 98 | */ 99 | private function getFormat(array $type) 100 | { 101 | return isset($type['params'][0]) ? $type['params'][0] : $this->defaultFormat; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Serializer/Type/LocalizedDateTimeHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigator::DIRECTION_SERIALIZATION, 17 | 'format' => 'json', 18 | 'type' => 'LocalizedDateTime', 19 | 'method' => 'serializeLocalizedDateTimeToJson', 20 | ), 21 | ); 22 | } 23 | 24 | public function serializeLocalizedDateTimeToJson( 25 | JsonSerializationVisitor $visitor, 26 | \DateTime $date, 27 | array $type, 28 | Context $context 29 | ) { 30 | $df = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT); 31 | 32 | return $df->format($date); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Serializer/Type/MapHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigator::DIRECTION_SERIALIZATION, 19 | 'format' => 'json', 20 | 'type' => 'Map', 21 | 'method' => 'serializeCollection' 22 | ], 23 | [ 24 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 25 | 'format' => 'json', 26 | 'type' => 'Map', 27 | 'method' => 'deserializeCollection' 28 | ] 29 | ]; 30 | 31 | } 32 | 33 | public function serializeCollection( 34 | VisitorInterface $visitor, 35 | MapInterface $collection, 36 | array $type, 37 | Context $context 38 | ) { 39 | // We change the base type, and pass through possible parameters. 40 | $type['name'] = 'array'; 41 | 42 | return $visitor->visitArray($collection->toArray(), $type, $context); 43 | } 44 | 45 | public function deserializeCollection(VisitorInterface $visitor, $data, array $type, Context $context) 46 | { 47 | // See above. 48 | $type['name'] = 'array'; 49 | 50 | return new Map($data, $type, $context); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Serializer/Type/MoneyHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigator::DIRECTION_SERIALIZATION, 30 | 'type' => self::TYPE_MONEY, 31 | 'format' => $format, 32 | 'method' => 'serializeMoney', 33 | ]; 34 | $methods[] = [ 35 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 36 | 'type' => self::TYPE_MONEY, 37 | 'format' => $format, 38 | 'method' => 'deserializeMoney', 39 | ]; 40 | } 41 | 42 | return $methods; 43 | } 44 | 45 | /** 46 | * @param \JMS\Serializer\VisitorInterface $visitor 47 | * @param mixed $data 48 | * @param mixed[] $type 49 | * @param \JMS\Serializer\Context $context 50 | * @return \Ramsey\Uuid\UuidInterface 51 | */ 52 | public function deserializeMoney(VisitorInterface $visitor, $data, array $type, Context $context) 53 | { 54 | $parts = explode(' ', $data); 55 | 56 | return new Money((int)$parts[0], new Currency($parts[1])); 57 | } 58 | 59 | /** 60 | * @param \JMS\Serializer\VisitorInterface $visitor 61 | * @param Money $money 62 | * @param mixed[] $type 63 | * @param \JMS\Serializer\Context $context 64 | * @return string 65 | */ 66 | public function serializeMoney(VisitorInterface $visitor, Money $money, array $type, Context $context) 67 | { 68 | return (string)$money->getAmount() . ' ' . $money->getCurrency()->getName(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Serializer/Type/UuidSerializerHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigator::DIRECTION_SERIALIZATION, 35 | 'type' => self::TYPE_UUID, 36 | 'format' => $format, 37 | 'method' => 'serializeUuid', 38 | ]; 39 | $methods[] = [ 40 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 41 | 'type' => self::TYPE_UUID, 42 | 'format' => $format, 43 | 'method' => 'deserializeUuid', 44 | ]; 45 | } 46 | 47 | return $methods; 48 | } 49 | 50 | /** 51 | * @param \JMS\Serializer\VisitorInterface $visitor 52 | * @param mixed $data 53 | * @param mixed[] $type 54 | * @param \JMS\Serializer\Context $context 55 | * @return \Ramsey\Uuid\UuidInterface 56 | */ 57 | public function deserializeUuid(VisitorInterface $visitor, $data, array $type, Context $context) 58 | { 59 | try { 60 | return $this->deserializeUuidValue($data); 61 | } catch (InvalidUuidException $e) { 62 | throw new DeserializationInvalidValueException( 63 | $this->getFieldPath($visitor, $context), 64 | $e 65 | ); 66 | } 67 | } 68 | 69 | /** 70 | * @param string $uuidString 71 | * @return \Ramsey\Uuid\UuidInterface 72 | */ 73 | private function deserializeUuidValue($uuidString) 74 | { 75 | if (!Uuid::isValid($uuidString)) { 76 | throw new InvalidUuidException($uuidString); 77 | } 78 | 79 | return Uuid::fromString($uuidString); 80 | } 81 | 82 | /** 83 | * @param \JMS\Serializer\VisitorInterface $visitor 84 | * @param \Ramsey\Uuid\UuidInterface $uuid 85 | * @param mixed[] $type 86 | * @param \JMS\Serializer\Context $context 87 | * @return string 88 | */ 89 | public function serializeUuid(VisitorInterface $visitor, UuidInterface $uuid, array $type, Context $context) 90 | { 91 | return $uuid->toString(); 92 | } 93 | 94 | /** 95 | * @param \JMS\Serializer\VisitorInterface $visitor 96 | * @param \JMS\Serializer\Context $context 97 | * @return string 98 | */ 99 | private function getFieldPath(VisitorInterface $visitor, Context $context) 100 | { 101 | $path = ''; 102 | foreach ($context->getMetadataStack() as $element) { 103 | if ($element instanceof PropertyMetadata) { 104 | $name = ($element->serializedName !== null) ? $element->serializedName : $element->name; 105 | if ($visitor instanceof AbstractVisitor) { 106 | $name = $visitor->getNamingStrategy()->translateName($element); 107 | } 108 | $path = $name . self::PATH_FIELD_SEPARATOR . $path; 109 | } 110 | } 111 | $path = rtrim($path, self::PATH_FIELD_SEPARATOR); 112 | 113 | return $path; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Serializer/Type/VectorHandler.php: -------------------------------------------------------------------------------- 1 | GraphNavigator::DIRECTION_SERIALIZATION, 29 | 'type' => $type, 30 | 'format' => $format, 31 | 'method' => 'serializeCollection', 32 | ]; 33 | 34 | $methods[] = [ 35 | 'direction' => GraphNavigator::DIRECTION_DESERIALIZATION, 36 | 'type' => $type, 37 | 'format' => $format, 38 | 'method' => 'deserializeCollection', 39 | ]; 40 | } 41 | } 42 | 43 | return $methods; 44 | } 45 | 46 | public function serializeCollection( 47 | VisitorInterface $visitor, 48 | VectorInterface $collection, 49 | array $type, 50 | Context $context 51 | ) { 52 | // We change the base type, and pass through possible parameters. 53 | $type['name'] = 'array'; 54 | 55 | return $visitor->visitArray($collection->toArray(), $type, $context); 56 | } 57 | 58 | public function deserializeCollection(VisitorInterface $visitor, $data, array $type, Context $context) 59 | { 60 | // See above. 61 | $type['name'] = 'array'; 62 | 63 | // When there is not root set for the visitor we need to handle the vector result setting 64 | // manually this is related to https://github.com/schmittjoh/serializer/issues/95 65 | $isRoot = null === $visitor->getResult(); 66 | if ($isRoot && $visitor instanceof GenericDeserializationVisitor) { 67 | $metadata = new ClassMetadata(Vector::class); 68 | $vector = new Vector(); 69 | 70 | $visitor->startVisitingObject($metadata, $vector, $type, $context); 71 | 72 | $array = $visitor->visitArray($data, $type, $context); 73 | $vector->setAll($array); 74 | 75 | $visitor->endVisitingObject($metadata, $vector, $type, $context); 76 | 77 | return $vector; 78 | } 79 | 80 | // No a root so just return the vector 81 | return new Vector($visitor->visitArray($data, $type, $context)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/CommandBus/EventDispatchingCommandBusTest.php: -------------------------------------------------------------------------------- 1 | locator = new InMemoryLocator(); 30 | $simpleCommandBus = new SimpleCommandBus($this->locator); 31 | $eventDispatcher = new InMemoryDispatcher(); 32 | $this->commandBus = new EventDispatchingCommandBus($simpleCommandBus, $eventDispatcher); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function itExecutesAMessage() 39 | { 40 | $handler = new TestHandler(); 41 | $this->locator->addHandler(TestCommand::class, $handler); 42 | 43 | $command = new TestCommand("hey"); 44 | $this->commandBus->execute($command); 45 | $this->commandBus->execute($command); 46 | $this->commandBus->execute($command); 47 | 48 | $this->assertSame(3, $handler->getCounter()); 49 | } 50 | 51 | /** 52 | * @test 53 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\MissingHandlerException 54 | */ 55 | public function itLosesMessageWhenThereIsNoHandlers() 56 | { 57 | $command = new TestCommand("hey"); 58 | $this->commandBus->execute($command); 59 | 60 | $handler = new TestHandler(); 61 | $this->assertSame(0, $handler->getCounter()); 62 | } 63 | 64 | /** 65 | * @test 66 | * @expectedException \InvalidArgumentException 67 | */ 68 | public function itFailsWhenHaveInvalidSubscriber() 69 | { 70 | $command = new TestCommand("hey"); 71 | $handler = new TestHandler(); 72 | 73 | $this->locator->addHandler($command, $handler); 74 | $this->commandBus->execute($command); 75 | } 76 | 77 | /** 78 | * @test 79 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\CanNotInvokeHandlerException 80 | */ 81 | public function itFailsWhenHandlerHasAnInvalidHandleMethod() 82 | { 83 | $handler = new InvalidHandler(); 84 | $this->locator->addHandler(TestCommand::class, $handler); 85 | 86 | $command = new TestCommand("hey"); 87 | $this->commandBus->execute($command); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/CommandBus/SimpleCommandBusTest.php: -------------------------------------------------------------------------------- 1 | locator = new InMemoryLocator(); 27 | $this->commandBus = new SimpleCommandBus($this->locator); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | public function itExecutesAMessage() 34 | { 35 | $handler = new TestHandler(); 36 | $this->locator->addHandler(TestCommand::class, $handler); 37 | 38 | $command = new TestCommand("hey"); 39 | $this->commandBus->execute($command); 40 | $this->commandBus->execute($command); 41 | $this->commandBus->execute($command); 42 | 43 | $this->assertSame(3, $handler->getCounter()); 44 | } 45 | 46 | /** 47 | * @test 48 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\MissingHandlerException 49 | */ 50 | public function itFailsWhenThereIsNoHandlers() 51 | { 52 | $command = new TestCommand("hey"); 53 | $this->commandBus->execute($command); 54 | } 55 | 56 | /** 57 | * @test 58 | * @expectedException \InvalidArgumentException 59 | */ 60 | public function itFailsWhenHaveInvalidSubscriber() 61 | { 62 | $command = new TestCommand("hey"); 63 | $handler = new TestHandler(); 64 | 65 | $this->locator->addHandler($command, $handler); 66 | } 67 | 68 | /** 69 | * @test 70 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\CanNotInvokeHandlerException 71 | */ 72 | public function itFailsWhenHandlerHasAnInvalidHandleMethod() 73 | { 74 | $handler = new InvalidHandler(); 75 | $this->locator->addHandler(TestCommand::class, $handler); 76 | 77 | $command = new TestCommand("hey"); 78 | $this->commandBus->execute($command); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/CommandBus/TacticianCommandBusTest.php: -------------------------------------------------------------------------------- 1 | locator = new InMemoryLocator(); 35 | $this->internalCommandBus = self::createASimpleBus($this->locator); 36 | 37 | $this->commandBus = new TacticianCommandBus($this->internalCommandBus); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function itExecutesAMessage() 44 | { 45 | $handler = new TestHandler(); 46 | $this->locator->addHandler($handler, TestCommand::class); 47 | 48 | $command = new TestCommand("hey"); 49 | $this->commandBus->execute($command); 50 | $this->commandBus->execute($command); 51 | $this->commandBus->execute($command); 52 | 53 | $this->assertSame(3, $handler->getCounter()); 54 | } 55 | 56 | /** 57 | * @test 58 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\MissingHandlerException 59 | */ 60 | public function itFailsWhenThereIsNoHandlers() 61 | { 62 | $command = new TestCommand("hey"); 63 | $this->commandBus->execute($command); 64 | 65 | $handler = new TestHandler(); 66 | $this->assertSame(0, $handler->getCounter()); 67 | } 68 | 69 | /** 70 | * @test 71 | * @expectedException \HelloFresh\Engine\CommandBus\Exception\CanNotInvokeHandlerException 72 | */ 73 | public function itFailsWhenHandlerHasAnInvalidHandleMethod() 74 | { 75 | $handler = new InvalidHandler(); 76 | $this->locator->addHandler($handler, TestCommand::class); 77 | 78 | $command = new TestCommand("hey"); 79 | $this->commandBus->execute($command); 80 | } 81 | 82 | /** 83 | * Create a tactician command bus that uses the same convention as SimpleCommandBus. 84 | * 85 | * @param HandlerLocator $commandLocator 86 | * @return CommandBus 87 | */ 88 | public static function createASimpleBus(HandlerLocator $commandLocator) 89 | { 90 | return new CommandBus([ 91 | new LockingMiddleware(), 92 | new CommandHandlerMiddleware( 93 | new ClassNameExtractor(), 94 | $commandLocator, 95 | new HandleInflector() 96 | ) 97 | ]); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Domain/DomainMessageTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(DomainMessage::class, $message); 29 | $this->assertSame((string) $aggregateId, $message->getId()); 30 | $this->assertSame($version, $message->getVersion()); 31 | $this->assertSame($payload, $message->getPayload()); 32 | $this->assertEquals($date, $message->getRecordedOn()); 33 | $this->assertEquals(new \DateTimeZone('UTC'), $message->getRecordedOn()->getTimezone()); 34 | } 35 | 36 | /** 37 | * @test 38 | * @dataProvider messageProvider 39 | * @param AggregateIdInterface $aggregateId 40 | * @param $version 41 | * @param $payload 42 | * @param \DateTimeImmutable $date 43 | */ 44 | public function itShouldCreateAUuidFromNamedConstructor( 45 | AggregateIdInterface $aggregateId, 46 | $version, 47 | $payload, 48 | \DateTimeImmutable $date 49 | ) { 50 | $message = DomainMessage::recordNow($aggregateId, $version, $payload); 51 | 52 | $this->assertInstanceOf(DomainMessage::class, $message); 53 | 54 | $this->assertNotEmpty((int)$message->getRecordedOn()->format('u'), 'Expected microseconds to be set'); 55 | $this->assertEquals(new \DateTimeZone('UTC'), $message->getRecordedOn()->getTimezone()); 56 | } 57 | 58 | public function messageProvider() 59 | { 60 | return [ 61 | [AggregateId::generate(), 1, new SomethingHappened(), new \DateTimeImmutable()], 62 | [AggregateId::generate(), 100, new SomethingHappened(), new \DateTimeImmutable()], 63 | [AggregateId::generate(), 9999999, new SomethingHappened(), new \DateTimeImmutable()], 64 | [ 65 | AggregateId::generate(), 66 | 9999999, 67 | new SomethingHappened(), 68 | \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))) 69 | ] 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/EventBus/SimpleEventBusTest.php: -------------------------------------------------------------------------------- 1 | eventBus = new SimpleEventBus(); 22 | } 23 | 24 | /** 25 | * @test 26 | */ 27 | public function itListensToAMessage() 28 | { 29 | $listener = new SomethingHappenedListener(); 30 | $this->eventBus->subscribe($listener); 31 | 32 | $event = new SomethingHappened(); 33 | $this->eventBus->publish($event); 34 | $this->eventBus->publish($event); 35 | $this->eventBus->publish($event); 36 | 37 | $this->assertSame(3, $listener->getCounter()); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function itListensToAllEvents() 44 | { 45 | $listener = new AllEventsListener(); 46 | $this->eventBus->subscribe($listener); 47 | 48 | $event1 = new SomethingHappened(); 49 | $event2 = new SomethingDone(); 50 | $this->eventBus->publish($event1); 51 | $this->eventBus->publish($event2); 52 | $this->eventBus->publish($event1); 53 | $this->eventBus->publish($event2); 54 | 55 | $this->assertSame(4, $listener->getCounter()); 56 | } 57 | 58 | 59 | /** 60 | * @test 61 | */ 62 | public function itLosesMessageWhenThereIsNoHandlers() 63 | { 64 | $listener = new SomethingHappenedListener(); 65 | 66 | $event = new SomethingHappened(); 67 | $this->eventBus->publish($event); 68 | $this->eventBus->publish($event); 69 | $this->eventBus->publish($event); 70 | 71 | $this->assertSame(0, $listener->getCounter()); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /tests/EventDispatcher/EventDispatcherTest.php: -------------------------------------------------------------------------------- 1 | dispatcher = new InMemoryDispatcher(); 21 | $this->listener1 = new TracableEventListener(); 22 | $this->listener2 = new TracableEventListener(); 23 | $this->assertFalse($this->listener1->isCalled()); 24 | $this->assertFalse($this->listener2->isCalled()); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | public function itCallsSubscribedListeners() 31 | { 32 | $this->dispatcher->addListener('event', [$this->listener1, 'handleEvent']); 33 | $this->dispatcher->addListener('event', [$this->listener2, 'handleEvent']); 34 | $this->dispatcher->dispatch('event', 'value1', 'value2'); 35 | $this->assertTrue($this->listener1->isCalled()); 36 | $this->assertTrue($this->listener2->isCalled()); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function itOnlyCallsTheListenerSubscribedToAGivenEvent() 43 | { 44 | $this->dispatcher->addListener('event1', [$this->listener1, 'handleEvent']); 45 | $this->dispatcher->addListener('event2', [$this->listener2, 'handleEvent']); 46 | $this->dispatcher->dispatch('event1', 'value1', 'value2'); 47 | $this->assertTrue($this->listener1->isCalled()); 48 | $this->assertFalse($this->listener2->isCalled()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/EventSourcing/AggregateRepositoryFactoryTest.php: -------------------------------------------------------------------------------- 1 | build(); 20 | $this->assertInstanceOf(AggregateRepositoryInterface::class, $repo); 21 | } 22 | 23 | /** 24 | * @test 25 | */ 26 | public function itShouldCreateARepositoryOnlyWithEventStore() 27 | { 28 | $factory = new AggregateRepositoryFactory([ 29 | 'event_store' => [ 30 | 'adapter' => InMemoryAdapter::class 31 | ] 32 | ]); 33 | $repo = $factory->build(); 34 | $this->assertInstanceOf(AggregateRepositoryInterface::class, $repo); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function itShouldCreateARepositoryWithEventStoreAndSnapshotStore() 41 | { 42 | $eventStore = $this->prophesize(EventStoreInterface::class); 43 | 44 | $factory = new AggregateRepositoryFactory([ 45 | 'event_store' => [ 46 | 'adapter' => InMemoryAdapter::class 47 | ], 48 | 'snapshotter' => [ 49 | 'enabled' => true, 50 | 'store' => [ 51 | 'adapter' => InMemorySnapshotAdapter::class 52 | ], 53 | 'strategy' => [ 54 | 'arguments' => [ 55 | $eventStore->reveal() 56 | ] 57 | ] 58 | ] 59 | ]); 60 | 61 | $repo = $factory->build(); 62 | $this->assertInstanceOf(AggregateRepositoryInterface::class, $repo); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/EventSourcing/EventSourcingRepositoryTest.php: -------------------------------------------------------------------------------- 1 | getEventStream(); 46 | $this->setUpForEventStream($stream); 47 | 48 | $repo = new AggregateRepository($this->eventStore->reveal(), $this->eventBus->reveal()); 49 | $repo->save($aggregateRoot); 50 | } 51 | 52 | /** 53 | * @param AggregateRoot $aggregateRoot 54 | * @test 55 | * @dataProvider aggregateRootProvider 56 | */ 57 | public function itShouldSaveWithSnapshot(AggregateRoot $aggregateRoot) 58 | { 59 | $stream = $aggregateRoot->getEventStream(); 60 | $this->setUpForEventStream($stream); 61 | 62 | $snapshotStore = $this->prophesize(SnapshotStoreInterface::class); 63 | 64 | $strategy = $this->prophesize(SnapshotStrategyInterface::class); 65 | $strategy->isFulfilled(new StreamName('event_stream'), $aggregateRoot)->shouldBeCalled()->willReturn(true); 66 | $snapshotter = new Snapshotter($snapshotStore->reveal(), $strategy->reveal()); 67 | 68 | $repo = new AggregateRepository( 69 | $this->eventStore->reveal(), 70 | $this->eventBus->reveal(), 71 | $snapshotter 72 | ); 73 | $repo->save($aggregateRoot); 74 | } 75 | 76 | /** 77 | * @param AggregateRoot $aggregateRoot 78 | * @test 79 | * @dataProvider aggregateRootProvider 80 | */ 81 | public function itShouldTakeASnapshot(AggregateRoot $aggregateRoot) 82 | { 83 | $stream = $aggregateRoot->getEventStream(); 84 | $this->setUpForEventStream($stream); 85 | 86 | $snapshotStore = $this->prophesize(SnapshotStoreInterface::class); 87 | 88 | $stream->each(function (DomainMessage $domainMessage) use ($snapshotStore, $aggregateRoot) { 89 | $snapshotStore->has( 90 | $aggregateRoot->getAggregateRootId(), 91 | $domainMessage->getVersion() 92 | )->shouldBeCalled()->willReturn(false); 93 | 94 | $snapshotStore->save(Argument::type(Snapshot::class))->shouldBeCalled(); 95 | }); 96 | 97 | $strategy = $this->prophesize(SnapshotStrategyInterface::class); 98 | $strategy->isFulfilled(new StreamName('event_stream'), $aggregateRoot)->shouldBeCalled()->willReturn(true); 99 | 100 | $snapshotter = new Snapshotter($snapshotStore->reveal(), $strategy->reveal()); 101 | 102 | $repo = new AggregateRepository( 103 | $this->eventStore->reveal(), 104 | $this->eventBus->reveal(), 105 | $snapshotter 106 | ); 107 | $repo->save($aggregateRoot); 108 | } 109 | 110 | /** 111 | * @param AggregateRoot $aggregateRoot 112 | * @test 113 | * @dataProvider aggregateRootProvider 114 | */ 115 | public function itShouldLoadFromSnapshot(AggregateRoot $aggregateRoot) 116 | { 117 | $stream = $aggregateRoot->getEventStream(); 118 | $this->eventStore = $this->prophesize(EventStoreInterface::class); 119 | $this->eventBus = $this->prophesize(EventBusInterface::class); 120 | 121 | $version = 100; 122 | $snapshot = $this->prophesize(Snapshot::class); 123 | $snapshot->getVersion()->shouldBeCalled()->willReturn($version); 124 | $snapshot->getAggregate()->shouldBeCalled()->willReturn($aggregateRoot); 125 | 126 | $snapshotStore = $this->prophesize(SnapshotStoreInterface::class); 127 | $snapshotStore->byId($aggregateRoot->getAggregateRootId())->shouldBeCalled()->willReturn($snapshot); 128 | 129 | $strategy = $this->prophesize(SnapshotStrategyInterface::class); 130 | $snapshotter = new Snapshotter($snapshotStore->reveal(), $strategy->reveal()); 131 | 132 | $this->eventStore->fromVersion( 133 | new StreamName('event_stream'), 134 | $aggregateRoot->getAggregateRootId(), 135 | $version + 1 136 | )->shouldBeCalled()->willReturn($stream); 137 | 138 | $repo = new AggregateRepository( 139 | $this->eventStore->reveal(), 140 | $this->eventBus->reveal(), 141 | $snapshotter 142 | ); 143 | $repo->load($aggregateRoot->getAggregateRootId(), AggregateRoot::class); 144 | } 145 | 146 | private function setUpForEventStream(EventStream $stream) 147 | { 148 | $this->eventStore = $this->prophesize(EventStoreInterface::class); 149 | $this->eventStore->append($stream)->shouldBeCalled(); 150 | $this->eventBus = $this->prophesize(EventBusInterface::class); 151 | 152 | $stream->each(function (DomainMessage $domainMessage) { 153 | $this->eventBus->publish($domainMessage->getPayload())->shouldBeCalled(); 154 | }); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/EventStore/Aggregate/AggregateIdTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(AggregateIdInterface::class, $aggregateId); 18 | } 19 | 20 | /** 21 | * @test 22 | */ 23 | public function itShouldCreateAUuidFromNamedConstructor() 24 | { 25 | $aggregateId = AggregateId::generate(); 26 | $this->assertInstanceOf(AggregateIdInterface::class, $aggregateId); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function itShouldCreateAUuidFromAString() 33 | { 34 | $aggregateIdString = Uuid::uuid4(); 35 | $aggregateId = AggregateId::fromString($aggregateIdString); 36 | 37 | $this->assertInstanceOf(AggregateIdInterface::class, $aggregateId); 38 | } 39 | 40 | /** 41 | * @test 42 | * @expectedException \InvalidArgumentException 43 | */ 44 | public function itShouldFailWhenCreatingAnIdFromInvalidString() 45 | { 46 | $aggregateIdString = 'invalidUuid'; 47 | $aggregateId = AggregateId::fromString($aggregateIdString); 48 | 49 | $this->assertInstanceOf(AggregateIdInterface::class, $aggregateId); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/EventStore/EventStoreTest.php: -------------------------------------------------------------------------------- 1 | createDomainMessage($id, 0), 28 | $this->createDomainMessage($id, 1), 29 | $this->createDomainMessage($id, 2), 30 | $this->createDomainMessage($id, 3), 31 | ]); 32 | 33 | $this->eventStore->append($domainEventStream); 34 | $this->assertEquals($domainEventStream, $this->eventStore->getEventsFor(new StreamName('event_stream'), $id)); 35 | } 36 | 37 | /** 38 | * @test 39 | * @dataProvider idDataProvider 40 | */ 41 | public function it_appends_to_an_already_existing_stream($id) 42 | { 43 | $dateTime = new \DateTimeImmutable("now"); 44 | 45 | $domainEventStream = new EventStream(new StreamName('event_stream'), [ 46 | $this->createDomainMessage($id, 0, $dateTime), 47 | $this->createDomainMessage($id, 1, $dateTime), 48 | $this->createDomainMessage($id, 2, $dateTime), 49 | ]); 50 | $this->eventStore->append($domainEventStream); 51 | $appendedEventStream = new EventStream(new StreamName('event_stream'), [ 52 | $this->createDomainMessage($id, 3, $dateTime), 53 | $this->createDomainMessage($id, 4, $dateTime), 54 | $this->createDomainMessage($id, 5, $dateTime), 55 | ]); 56 | $this->eventStore->append($appendedEventStream); 57 | $expected = new EventStream(new StreamName('event_stream'), [ 58 | $this->createDomainMessage($id, 0, $dateTime), 59 | $this->createDomainMessage($id, 1, $dateTime), 60 | $this->createDomainMessage($id, 2, $dateTime), 61 | $this->createDomainMessage($id, 3, $dateTime), 62 | $this->createDomainMessage($id, 4, $dateTime), 63 | $this->createDomainMessage($id, 5, $dateTime), 64 | ]); 65 | $this->assertEquals($expected, $this->eventStore->getEventsFor(new StreamName('event_stream'), $id)); 66 | } 67 | 68 | /** 69 | * @test 70 | * @dataProvider idDataProvider 71 | * @expectedException \HelloFresh\Engine\EventStore\Exception\EventStreamNotFoundException 72 | */ 73 | public function it_throws_an_exception_when_requesting_the_stream_of_a_non_existing_aggregate($id) 74 | { 75 | $this->eventStore->getEventsFor(new StreamName('event_stream'), $id); 76 | } 77 | 78 | public function idDataProvider() 79 | { 80 | return [ 81 | 'Simple String' => [ 82 | 'Yolntbyaac', // You only live nine times because you are a cat 83 | ], 84 | 'Identitiy' => [ 85 | AggregateId::generate(), 86 | ], 87 | 'UUID String' => [ 88 | Uuid::uuid4()->toString(), // test UUID 89 | ], 90 | ]; 91 | } 92 | 93 | protected function createDomainMessage($id, $version, $recordedOn = null) 94 | { 95 | return new DomainMessage( 96 | $id, 97 | $version, 98 | new SomethingHappened($recordedOn), 99 | $recordedOn ? $recordedOn : new \DateTimeImmutable("now") 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/EventStore/InMemoryEventStoreTest.php: -------------------------------------------------------------------------------- 1 | eventStore = new EventStore(new InMemoryAdapter()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/EventStore/RedisEventStore.php: -------------------------------------------------------------------------------- 1 | redis = $this->setUpPredis(); 25 | $this->eventStore = new EventStore(new RedisAdapter($this->redis, $this->setUpSerializer())); 26 | } 27 | 28 | protected function tearDown() 29 | { 30 | $this->redis->flushall(); 31 | } 32 | 33 | private function setUpPredis() 34 | { 35 | $host = getenv('REDIS_HOST'); 36 | $port = getenv('REDIS_PORT') ?: "6379"; 37 | 38 | return new Client("tcp://$host:$port"); 39 | } 40 | 41 | private function setUpSerializer() 42 | { 43 | $jmsSerializer = SerializerBuilder::create() 44 | ->setMetadataDirs(['' => realpath(__DIR__ . '/../Mock/Config')]) 45 | ->configureHandlers(function (HandlerRegistry $registry) { 46 | $registry->registerSubscribingHandler(new VectorHandler()); 47 | $registry->registerSubscribingHandler(new UuidSerializerHandler()); 48 | }) 49 | ->addDefaultHandlers() 50 | ->build(); 51 | 52 | return new JmsSerializerAdapter($jmsSerializer); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/EventStore/Snapshot/Adapter/RedisSnapshotAdapterTest.php: -------------------------------------------------------------------------------- 1 | client = $this->prophesize(PredisClient::class); 28 | $this->serializer = $this->prophesize(SerializerInterface::class); 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function itCanSaveASnapshot() 35 | { 36 | $id = AggregateId::generate(); 37 | $aggregate = AggregateRoot::create($id, 'test'); 38 | 39 | $snapshot = Snapshot::take($id, $aggregate, '10'); 40 | 41 | $expectedSerializedAggregate = sprintf('["serialized": "%s"]', spl_object_hash($snapshot)); 42 | $expectedStorageArray = [ 43 | 'version' => '10', 44 | 'created_at' => $snapshot->getCreatedAt()->format('U.u'), 45 | 'snapshot' => [ 46 | 'type' => AggregateRoot::class, 47 | 'payload' => $expectedSerializedAggregate, 48 | ] 49 | ]; 50 | $expectedStoredData = '["version etc..."]'; 51 | 52 | $this->serializer->serialize($aggregate, 'json') 53 | ->willReturn($expectedSerializedAggregate) 54 | ->shouldBeCalledTimes(1); 55 | $this->serializer->serialize($expectedStorageArray, 'json') 56 | ->willReturn($expectedStoredData) 57 | ->shouldBeCalledTimes(1); 58 | 59 | $this->client->hset(RedisSnapshotAdapter::KEY_NAMESPACE, (string)$id, $expectedStoredData) 60 | ->shouldBeCalledTimes(1); 61 | 62 | $adapter = $this->createAdapter(); 63 | $adapter->save($snapshot); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function aSnapshotCanBeRetrievedById() 70 | { 71 | $id = AggregateId::generate(); 72 | 73 | $expectedAggregate = AggregateRoot::create($id, 'testing'); 74 | 75 | $snapshotMetadata = [ 76 | 'version' => '15', 77 | 'created_at' => '1468847497.332610', 78 | 'snapshot' => [ 79 | 'type' => AggregateRoot::class, 80 | 'payload' => 'aggregate_data', 81 | ] 82 | ]; 83 | 84 | $this->mockRedisHasAndGetData($id, $snapshotMetadata); 85 | 86 | $this->serializer->deserialize('aggregate_data', AggregateRoot::class, 'json') 87 | ->willReturn($expectedAggregate); 88 | 89 | $adapter = $this->createAdapter(); 90 | $result = $adapter->byId($id); 91 | 92 | $this->assertInstanceOf(Snapshot::class, $result); 93 | $this->assertSame($id, $result->getAggregateId()); 94 | $this->assertSame($expectedAggregate, $result->getAggregate()); 95 | $this->assertSame('15', $result->getVersion()); 96 | $this->assertSame('1468847497.332610', $result->getCreatedAt()->format('U.u')); 97 | $this->assertEquals(new \DateTimeZone('UTC'), $result->getCreatedAt()->getTimezone()); 98 | } 99 | 100 | /** 101 | * @test 102 | */ 103 | public function aSnapshotCanNotBeRetrievedWhenTheIdIsUnknown() 104 | { 105 | $id = AggregateId::generate(); 106 | 107 | $this->client->hexists(RedisSnapshotAdapter::KEY_NAMESPACE, (string)$id) 108 | ->willReturn(false) 109 | ->shouldBeCalledTimes(1); 110 | 111 | $adapter = $this->createAdapter(); 112 | $result = $adapter->byId($id); 113 | 114 | $this->assertNull($result); 115 | } 116 | 117 | /** 118 | * @test 119 | */ 120 | public function itIndicatedIfASnapshotOfAggregateWithVersionExists() 121 | { 122 | $id = AggregateId::generate(); 123 | $expectedDeserializedRedisData = ['version' => 20]; 124 | 125 | $this->mockRedisHasAndGetData($id, $expectedDeserializedRedisData); 126 | 127 | $adapter = $this->createAdapter(); 128 | $result = $adapter->has($id, 20); 129 | 130 | $this->assertTrue($result); 131 | } 132 | 133 | /** 134 | * @test 135 | */ 136 | public function itIndicatedThatASnapshotOfAggregateIsUnknown() 137 | { 138 | $id = AggregateId::generate(); 139 | 140 | $this->client->hexists(RedisSnapshotAdapter::KEY_NAMESPACE, (string)$id) 141 | ->willReturn(false) 142 | ->shouldBeCalledTimes(1); 143 | 144 | $adapter = $this->createAdapter(); 145 | $result = $adapter->has($id, 15); 146 | 147 | $this->assertFalse($result); 148 | } 149 | 150 | /** 151 | * @test 152 | */ 153 | public function itIndicatedThatASnapshotOfAggregateIsUnknownWhenTheVersionIsIncorrect() 154 | { 155 | $id = AggregateId::generate(); 156 | 157 | $this->mockRedisHasAndGetData($id, 20); 158 | 159 | $adapter = $this->createAdapter(); 160 | $result = $adapter->has($id, 15); 161 | 162 | $this->assertFalse($result); 163 | } 164 | 165 | 166 | /** 167 | * @return RedisSnapshotAdapter 168 | */ 169 | protected function createAdapter() 170 | { 171 | $adapter = new RedisSnapshotAdapter($this->client->reveal(), $this->serializer->reveal()); 172 | return $adapter; 173 | } 174 | 175 | /** 176 | * @param $id 177 | * @param $expectedDeserializedRedisData 178 | */ 179 | protected function mockRedisHasAndGetData($id, $expectedDeserializedRedisData) 180 | { 181 | $this->client->hexists(RedisSnapshotAdapter::KEY_NAMESPACE, (string)$id) 182 | ->willReturn(true) 183 | ->shouldBeCalledTimes(1); 184 | 185 | $this->client->hget(RedisSnapshotAdapter::KEY_NAMESPACE, (string)$id) 186 | ->willReturn('redis_data') 187 | ->shouldBeCalledTimes(1); 188 | 189 | $this->serializer->deserialize('redis_data', 'array', 'json') 190 | ->willReturn($expectedDeserializedRedisData); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/EventStore/Snapshot/SnapshotTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Snapshot::class, $message); 32 | $this->assertSame($aggregateId, $message->getAggregateId()); 33 | $this->assertSame($aggregate, $message->getAggregate()); 34 | $this->assertSame($version, $message->getVersion()); 35 | $this->assertSame(get_class($aggregate), $message->getType()); 36 | $this->assertEquals($date, $message->getCreatedAt()); 37 | $this->assertEquals(new \DateTimeZone('UTC'), $message->getCreatedAt()->getTimezone()); 38 | } 39 | 40 | /** 41 | * @test 42 | * @dataProvider messageProvider 43 | * @param AggregateIdInterface $aggregateId 44 | * @param AggregateRootInterface $aggregate 45 | * @param string $version 46 | */ 47 | public function itShouldCreateASnapshotFromNamedConstructor( 48 | AggregateIdInterface $aggregateId, 49 | AggregateRootInterface $aggregate, 50 | $version 51 | ) { 52 | $snapshot = Snapshot::take($aggregateId, $aggregate, $version); 53 | 54 | $this->assertInstanceOf(Snapshot::class, $snapshot); 55 | $this->assertNotEmpty((int)$snapshot->getCreatedAt()->format('u'), 'Expected microseconds to be set'); 56 | $this->assertEquals(new \DateTimeZone('UTC'), $snapshot->getCreatedAt()->getTimezone()); 57 | } 58 | 59 | public function messageProvider() 60 | { 61 | return [ 62 | [ 63 | AggregateId::generate(), 64 | AggregateRoot::create(AggregateId::generate(), 'v1000'), 65 | '1000', 66 | new \DateTimeImmutable() 67 | ], 68 | [ 69 | AggregateId::generate(), 70 | AggregateRoot::create(AggregateId::generate(), 'v1'), 71 | '1', 72 | \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))) 73 | ] 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/EventStoreIntegrationTest.php: -------------------------------------------------------------------------------- 1 | getDoctrineConnection(); 46 | DbalSchema::createSchema($connection); 47 | SnapshotSchema::createSchema($connection); 48 | } 49 | 50 | protected function tearDown() 51 | { 52 | DbalSchema::dropSchema($this->connection); 53 | SnapshotSchema::dropSchema($this->connection); 54 | } 55 | 56 | /** 57 | * @test 58 | * @dataProvider eventStoreProvider 59 | * @param $eventStoreAdapter 60 | * @param $snapshotAdapter 61 | */ 62 | public function isShouldStoreEvents($eventStoreAdapter, $snapshotAdapter) 63 | { 64 | $locator = new InMemoryLocator(); 65 | $commandBus = new SimpleCommandBus($locator); 66 | $eventBus = new SimpleEventBus(); 67 | 68 | $eventStore = new EventStore($eventStoreAdapter); 69 | $snapshotStore = new SnapshotStore($snapshotAdapter); 70 | $snapshotter = new Snapshotter($snapshotStore, new CountSnapshotStrategy($eventStore, 5)); 71 | 72 | $aggregateRepo = new AggregateRepository($eventStore, $eventBus, $snapshotter); 73 | 74 | $locator->addHandler(AssignNameCommand::class, new AssignNameHandler($aggregateRepo)); 75 | 76 | $aggregateRoot = AggregateRoot::create(AggregateId::generate(), 'test1'); 77 | $aggregateRepo->save($aggregateRoot); 78 | 79 | $command = new AssignNameCommand($aggregateRoot->getAggregateRootId(), 'test2'); 80 | $commandBus->execute($command); 81 | $commandBus->execute($command); 82 | $commandBus->execute($command); 83 | $commandBus->execute($command); 84 | 85 | $this->assertEquals(6, 86 | $eventStore->countEventsFor(new StreamName('event_stream'), $aggregateRoot->getAggregateRootId())); 87 | } 88 | 89 | public function eventStoreProvider() 90 | { 91 | //Setup serializer 92 | $serializer = $this->configureSerializer(); 93 | $redis = $this->configureRedis(); 94 | 95 | if (version_compare(PHP_VERSION, '7.0', '>=')) { 96 | $mongodb = $this->configureMongoDB(); 97 | $mongodbAdapter = new MongoDbAdapter($mongodb, $serializer, 'chassis'); 98 | } else { 99 | $mongodb = $this->configureMongo(); 100 | $mongodbAdapter = new MongoAdapter($mongodb, $serializer, 'chassis'); 101 | } 102 | 103 | return [ 104 | [new InMemoryAdapter(), new InMemorySnapshotAdapter()], 105 | [new RedisAdapter($redis, $serializer), new RedisSnapshotAdapter($redis, $serializer)], 106 | [$mongodbAdapter, new RedisSnapshotAdapter($redis, $serializer)], 107 | [ 108 | new DbalAdapter($this->getDoctrineConnection(), $serializer, DbalSchema::TABLE_NAME), 109 | new DbalSnapshotAdapter($this->getDoctrineConnection(), $serializer, SnapshotSchema::TABLE_NAME) 110 | ] 111 | ]; 112 | } 113 | 114 | private function configureSerializer() 115 | { 116 | $jmsSerializer = SerializerBuilder::create() 117 | ->setMetadataDirs(['' => realpath(__DIR__ . '/Mock/Config')]) 118 | ->configureHandlers(function (HandlerRegistry $registry) { 119 | $registry->registerSubscribingHandler(new VectorHandler()); 120 | $registry->registerSubscribingHandler(new UuidSerializerHandler()); 121 | }) 122 | ->addDefaultHandlers() 123 | ->build(); 124 | 125 | return new JmsSerializerAdapter($jmsSerializer); 126 | } 127 | 128 | private function configureMongoDB() 129 | { 130 | $host = getenv('MONGO_HOST'); 131 | $port = getenv('MONGO_PORT') ?: "27017"; 132 | 133 | return new MongoClient("mongodb://$host:$port"); 134 | } 135 | 136 | private function configureMongo() 137 | { 138 | $host = getenv('MONGO_HOST'); 139 | $port = getenv('MONGO_PORT') ?: "27017"; 140 | 141 | return new \MongoClient("mongodb://$host:$port"); 142 | } 143 | 144 | private function configureRedis() 145 | { 146 | $host = getenv('REDIS_HOST'); 147 | $port = getenv('REDIS_PORT') ?: "6379"; 148 | 149 | return new RedisClient("tcp://$host:$port"); 150 | } 151 | 152 | private function getDoctrineConnection() 153 | { 154 | if ($this->connection) { 155 | return $this->connection; 156 | } 157 | 158 | $connectionParams = [ 159 | 'dbname' => getenv('DB_NAME'), 160 | 'user' => getenv('DB_USER'), 161 | 'password' => getenv('DB_PASSWORD'), 162 | 'host' => getenv('DB_HOST'), 163 | 'driver' => 'pdo_pgsql', 164 | ]; 165 | 166 | $this->connection = \Doctrine\DBAL\DriverManager::getConnection($connectionParams); 167 | 168 | return $this->connection; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Mock/AggregateRoot.php: -------------------------------------------------------------------------------- 1 | recordThat(new AggregateRootCreated($aggregateId, $name)); 34 | $aggregate->recordThat(new SomethingDone()); 35 | 36 | return $aggregate; 37 | } 38 | 39 | /** 40 | * @return AggregateIdInterface 41 | */ 42 | public function getAggregateRootId() 43 | { 44 | return $this->aggregateId; 45 | } 46 | 47 | /** 48 | * @return string 49 | */ 50 | public function getName() 51 | { 52 | return $this->name; 53 | } 54 | 55 | public function assignName($name) 56 | { 57 | $this->recordThat(new NameAssigned($this->aggregateId, $name)); 58 | } 59 | 60 | public function whenAggregateRootCreated(AggregateRootCreated $event) 61 | { 62 | $this->aggregateId = $event->getAggregateId(); 63 | $this->name = $event->getName(); 64 | } 65 | 66 | public function whenNameAssigned(NameAssigned $event) 67 | { 68 | $this->name = $event->getName(); 69 | } 70 | 71 | public function getEventStream() 72 | { 73 | return new EventStream(new StreamName('event_stream'), $this->uncommittedEvents); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Mock/AggregateRootCreated.php: -------------------------------------------------------------------------------- 1 | occurredOn = new \DateTime(); 33 | $this->aggregateId = $aggregateId; 34 | $this->name = $name; 35 | } 36 | 37 | /** 38 | * @return \DateTime 39 | */ 40 | public function occurredOn() 41 | { 42 | return $this->occurredOn; 43 | } 44 | 45 | /** 46 | * @return AggregateIdInterface 47 | */ 48 | public function getAggregateId() 49 | { 50 | return $this->aggregateId; 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | public function getName() 57 | { 58 | return $this->name; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Mock/AllEventsListener.php: -------------------------------------------------------------------------------- 1 | counter++; 18 | } 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function isSubscribedTo(DomainEventInterface $event) 24 | { 25 | return true; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Mock/AssignNameCommand.php: -------------------------------------------------------------------------------- 1 | aggregateId = $aggregateId; 26 | $this->name = $name; 27 | } 28 | 29 | /** 30 | * @return string 31 | */ 32 | public function getAggregateId() 33 | { 34 | return $this->aggregateId; 35 | } 36 | 37 | /** 38 | * @return string 39 | */ 40 | public function getName() 41 | { 42 | return $this->name; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Mock/AssignNameHandler.php: -------------------------------------------------------------------------------- 1 | repo = $repo; 22 | } 23 | 24 | public function handle(AssignNameCommand $command) 25 | { 26 | /** @var AggregateRoot $aggregateRoot */ 27 | $aggregateRoot = $this->repo->load($command->getAggregateId(), AggregateRoot::class); 28 | $aggregateRoot->assignName($command->getName()); 29 | 30 | $this->repo->save($aggregateRoot); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Engine.Domain.AggregateId.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Engine\Domain\AggregateId: 3 | properties: 4 | value: 5 | type: uuid 6 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Tests.Engine.Mock.AggregateRoot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Tests\Engine\Mock\AggregateRoot: 3 | properties: 4 | aggregateId: 5 | type: HelloFresh\Engine\Domain\AggregateId 6 | name: 7 | type: string 8 | uncommittedEvents: 9 | exposed: false 10 | type: array 11 | version: 12 | exposed: false 13 | type: integer 14 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Tests.Engine.Mock.AggregateRootCreated.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Tests\Engine\Mock\AggregateRootCreated: 3 | properties: 4 | occurredOn: 5 | type: DateTime 6 | aggregateId: 7 | type: HelloFresh\Engine\Domain\AggregateId 8 | name: 9 | type: string 10 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Tests.Engine.Mock.NameAssigned.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Tests\Engine\Mock\NameAssigned: 3 | properties: 4 | aggregateId: 5 | type: HelloFresh\Engine\Domain\AggregateId 6 | name: 7 | type: string 8 | occurredOn: 9 | type: DateTime 10 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Tests.Engine.Mock.SomethingDone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Tests\Engine\Mock\SomethingDone: 3 | properties: 4 | occurredOn: 5 | type: DateTime 6 | -------------------------------------------------------------------------------- /tests/Mock/Config/HelloFresh.Tests.Engine.Mock.SomethingHappened.yml: -------------------------------------------------------------------------------- 1 | --- 2 | HelloFresh\Tests\Engine\Mock\SomethingHappened: 3 | properties: 4 | occurredOn: 5 | type: DateTime 6 | -------------------------------------------------------------------------------- /tests/Mock/CounterTrait.php: -------------------------------------------------------------------------------- 1 | counter; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Mock/InvalidHandler.php: -------------------------------------------------------------------------------- 1 | counter++; 15 | } 16 | 17 | /** 18 | * @return int 19 | */ 20 | public function getCounter() 21 | { 22 | return $this->counter; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Mock/NameAssigned.php: -------------------------------------------------------------------------------- 1 | occurredOn = new \DateTime(); 33 | $this->aggregateId = $aggregateId; 34 | $this->name = $name; 35 | } 36 | 37 | /** 38 | * @return \DateTime 39 | */ 40 | public function occurredOn() 41 | { 42 | return $this->occurredOn; 43 | } 44 | 45 | /** 46 | * @return AggregateIdInterface 47 | */ 48 | public function getAggregateId() 49 | { 50 | return $this->aggregateId; 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | public function getName() 57 | { 58 | return $this->name; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Mock/PredisClient.php: -------------------------------------------------------------------------------- 1 | occurredOn = new \DateTime(); 20 | } 21 | 22 | /** 23 | * @return \DateTime 24 | */ 25 | public function occurredOn() 26 | { 27 | return $this->occurredOn; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Mock/SomethingHappened.php: -------------------------------------------------------------------------------- 1 | occurredOn = $dateTime === null ? $dateTime : new \DateTime(); 20 | } 21 | 22 | /** 23 | * @return \DateTime 24 | */ 25 | public function occurredOn() 26 | { 27 | return $this->occurredOn; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Mock/SomethingHappenedListener.php: -------------------------------------------------------------------------------- 1 | counter++; 18 | } 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function isSubscribedTo(DomainEventInterface $event) 24 | { 25 | return get_class($event) === SomethingHappened::class; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Mock/TestCommand.php: -------------------------------------------------------------------------------- 1 | message = $message; 19 | } 20 | 21 | /** 22 | * @return string 23 | */ 24 | public function getMessage() 25 | { 26 | return $this->message; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Mock/TestHandler.php: -------------------------------------------------------------------------------- 1 | counter++; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/Mock/TracableEventListener.php: -------------------------------------------------------------------------------- 1 | isCalled; 15 | } 16 | 17 | public function handleEvent($value1, $value2) 18 | { 19 | $this->isCalled = true; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Serializer/Type/JMSSerializerHandlerTestCase.php: -------------------------------------------------------------------------------- 1 | serializer = $this->createSerializer(); 21 | } 22 | 23 | /** 24 | * Create a serializer instance. 25 | * 26 | * @return Serializer 27 | */ 28 | protected function createSerializer() 29 | { 30 | $builder = new SerializerBuilder(); 31 | $builder->addDefaultHandlers(); 32 | $builder->addDefaultDeserializationVisitors(); 33 | $builder->addDefaultSerializationVisitors(); 34 | 35 | $this->configureBuilder($builder); 36 | 37 | return $builder->build(); 38 | } 39 | 40 | /** 41 | * Configure the serializer builder for the test case. 42 | * 43 | * @param SerializerBuilder $builder 44 | * @return void 45 | */ 46 | abstract protected function configureBuilder(SerializerBuilder $builder); 47 | } 48 | -------------------------------------------------------------------------------- /tests/Serializer/Type/VectorHandlerTest.php: -------------------------------------------------------------------------------- 1 | assertJsonStringEqualsJsonString( 22 | $expectedJson, 23 | $this->serializer->serialize($expectedVector, 'json') 24 | ); 25 | 26 | $this->assertEquals( 27 | $expectedVector, 28 | $this->serializer->deserialize($expectedJson, $type, 'json') 29 | ); 30 | } 31 | 32 | /** 33 | * @dataProvider providerTypes 34 | * @param string $type 35 | */ 36 | public function testJsonSerializationAndDeserializationChildLevel($type) 37 | { 38 | $expectedVector = [ 'details' => new Vector(['foo', 'bar']) ]; 39 | $expectedJson = '{ "details": ["foo", "bar"] }'; 40 | 41 | $this->assertJsonStringEqualsJsonString( 42 | $expectedJson, 43 | $this->serializer->serialize($expectedVector, 'json') 44 | ); 45 | 46 | $this->assertEquals( 47 | $expectedVector, 48 | $this->serializer->deserialize($expectedJson, sprintf('array', $type), 'json') 49 | ); 50 | } 51 | 52 | public function providerTypes() 53 | { 54 | return [ 55 | ['Vector'], 56 | [Vector::class], 57 | ]; 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | protected function configureBuilder(SerializerBuilder $builder) 64 | { 65 | $builder->configureHandlers(function (HandlerRegistryInterface $registry) { 66 | $registry->registerSubscribingHandler(new VectorHandler()); 67 | }); 68 | } 69 | } 70 | --------------------------------------------------------------------------------