Skip to content

Commit 5adaa31

Browse files
committed
dcb poc
1 parent 48eb901 commit 5adaa31

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1559
-140
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
}
2020
],
2121
"require": {
22-
"php": "~8.2.0 || ~8.3.0 || ~8.4.0",
22+
"php": "~8.4.0",
2323
"doctrine/dbal": "^4.0.0",
2424
"doctrine/migrations": "^3.3.2",
2525
"patchlevel/hydrator": "^1.8.0",

composer.lock

Lines changed: 88 additions & 85 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Dynamic Consistency Boundary
2+

src/Attribute/EventTag.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class EventTag
11+
{
12+
public function __construct(
13+
public readonly string|null $prefix = null,
14+
) {
15+
}
16+
}

src/DCB/AppendCondition.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
/** @experimental */
8+
final class AppendCondition
9+
{
10+
/** @param list<list<string>> $tags */
11+
public function __construct(
12+
public readonly array $tags,
13+
public readonly HighestSequenceNumber|null $expectedHighestSequenceNumber = null,
14+
) {
15+
}
16+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
use Patchlevel\EventSourcing\Aggregate\AggregateRootId;
8+
use Patchlevel\EventSourcing\Attribute\EventTag;
9+
use ReflectionClass;
10+
use RuntimeException;
11+
12+
use function array_keys;
13+
use function get_debug_type;
14+
use function is_int;
15+
use function is_string;
16+
use function sprintf;
17+
18+
/** @experimental */
19+
final class AttributeEventTagExtractor implements EventTagExtractor
20+
{
21+
/** @return list<string> */
22+
public function extract(object $event): array
23+
{
24+
$reflectionClass = new ReflectionClass($event);
25+
26+
$tags = [];
27+
28+
foreach ($reflectionClass->getProperties() as $property) {
29+
$attributes = $property->getAttributes(EventTag::class);
30+
31+
if ($attributes === []) {
32+
continue;
33+
}
34+
35+
$attribute = $attributes[0]->newInstance();
36+
37+
$value = $property->getValue($event);
38+
39+
if ($value instanceof AggregateRootId) {
40+
$value = $value->toString();
41+
}
42+
43+
if (!is_string($value) && !is_int($value)) {
44+
throw new RuntimeException(
45+
sprintf('Event tag value must be a string or an int, %s given', get_debug_type($value)),
46+
);
47+
}
48+
49+
if ($attribute->prefix) {
50+
$value = $attribute->prefix . ':' . $value;
51+
}
52+
53+
$tags[(string)$value] = true;
54+
}
55+
56+
return array_keys($tags);
57+
}
58+
}

src/DCB/CompositeProjection.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
use Patchlevel\EventSourcing\Message\Message;
8+
use Patchlevel\EventSourcing\Store\Header\TagsHeader;
9+
10+
use function array_diff;
11+
use function array_map;
12+
use function array_merge;
13+
use function array_unique;
14+
use function array_values;
15+
use function in_array;
16+
use function sort;
17+
18+
/**
19+
* @experimental
20+
* @extends Projection<array<string, mixed>>
21+
*/
22+
final class CompositeProjection extends Projection
23+
{
24+
/** @param array<string, Projection> $projections */
25+
public function __construct(
26+
private readonly array $projections,
27+
) {
28+
}
29+
30+
/** @return list<string> */
31+
public function tagFilter(): array
32+
{
33+
$tags = [];
34+
35+
foreach ($this->projections as $projection) {
36+
$tags = array_merge($tags, $projection->tagFilter());
37+
}
38+
39+
return array_values(array_unique($tags));
40+
}
41+
42+
/** @return list<list<string>> */
43+
public function groupedTagFilter(): array
44+
{
45+
$result = [];
46+
47+
foreach ($this->projections as $projection) {
48+
$tags = $projection->tagFilter();
49+
50+
sort($tags);
51+
52+
if (in_array($tags, $result, true)) {
53+
continue;
54+
}
55+
56+
$result[] = $tags;
57+
}
58+
59+
return $result;
60+
}
61+
62+
/** @return array<string, mixed> */
63+
public function initialState(): array
64+
{
65+
return array_map(static function (Projection $projection) {
66+
return $projection->initialState();
67+
}, $this->projections);
68+
}
69+
70+
public function apply(mixed $state, Message $message): mixed
71+
{
72+
$tags = $message->header(TagsHeader::class)->tags;
73+
74+
foreach ($this->projections as $name => $projection) {
75+
$neededTags = $projection->tagFilter();
76+
77+
if (!$this->isSubset($neededTags, $tags)) {
78+
continue;
79+
}
80+
81+
$state[$name] = $projection->apply($state[$name], $message);
82+
}
83+
84+
return $state;
85+
}
86+
87+
/**
88+
* @param list<string> $needle
89+
* @param list<string> $haystack
90+
*/
91+
private function isSubset(array $needle, array $haystack): bool
92+
{
93+
return empty(array_diff($needle, $haystack));
94+
}
95+
}

src/DCB/DecisionModel.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
use ArrayAccess;
8+
use LogicException;
9+
use OutOfBoundsException;
10+
11+
use function array_key_exists;
12+
13+
/**
14+
* @experimental
15+
* @psalm-immutable
16+
* @implements ArrayAccess<string, mixed>
17+
*/
18+
final class DecisionModel implements ArrayAccess
19+
{
20+
/** @param array<string, mixed> $state */
21+
public function __construct(
22+
public readonly array $state,
23+
public readonly AppendCondition $appendCondition,
24+
) {
25+
}
26+
27+
public function offsetExists(mixed $offset): bool
28+
{
29+
return array_key_exists($offset, $this->state);
30+
}
31+
32+
public function offsetGet(mixed $offset): mixed
33+
{
34+
if (!$this->offsetExists($offset)) {
35+
throw new OutOfBoundsException("Offset '$offset' does not exist in the state.");
36+
}
37+
38+
return $this->state[$offset];
39+
}
40+
41+
public function offsetSet(mixed $offset, mixed $value): void
42+
{
43+
throw new LogicException('State is immutable, cannot set value.');
44+
}
45+
46+
public function offsetUnset(mixed $offset): void
47+
{
48+
throw new LogicException('State is immutable, cannot unset value.');
49+
}
50+
}

src/DCB/DecisionModelBuilder.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
/** @experimental */
8+
interface DecisionModelBuilder
9+
{
10+
/** @param array<string, Projection> $projections */
11+
public function build(
12+
array $projections,
13+
): DecisionModel;
14+
}

src/DCB/EventAppender.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
/** @experimental */
8+
interface EventAppender
9+
{
10+
/** @param iterable<object> $events */
11+
public function append(iterable $events, AppendCondition|null $appendCondition = null): void;
12+
}

0 commit comments

Comments
 (0)