├── .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 | [](https://travis-ci.org/hellofresh/engine)
10 | [](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 |
--------------------------------------------------------------------------------