Skip to content

Commit 0249929

Browse files
committed
refactor query optimization & add tests
1 parent 30b77b1 commit 0249929

12 files changed

+1326
-43
lines changed

docs/pages/dynamic_consistency_boundary.md

Lines changed: 527 additions & 0 deletions
Large diffs are not rendered by default.

src/DCB/AttributeEventTagExtractor.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,12 @@
77
use Patchlevel\EventSourcing\Attribute\EventTag;
88
use Patchlevel\EventSourcing\Stringable;
99
use ReflectionClass;
10-
use RuntimeException;
1110
use Stringable as NativeStringable;
1211

1312
use function array_keys;
14-
use function get_debug_type;
1513
use function hash;
1614
use function is_int;
1715
use function is_string;
18-
use function sprintf;
1916

2017
/** @experimental */
2118
final class AttributeEventTagExtractor implements EventTagExtractor
@@ -48,8 +45,10 @@ public function extract(object $event): array
4845
}
4946

5047
if (!is_string($value)) {
51-
throw new RuntimeException(
52-
sprintf('Event tag value must be stringable, %s given', get_debug_type($value)),
48+
throw EventTagExtractorError::invalidValueType(
49+
$event::class,
50+
$property->getName(),
51+
$value,
5352
);
5453
}
5554

src/DCB/CompositeProjection.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ public function __construct(
2020

2121
public function query(): Query
2222
{
23-
$query = new Query();
24-
25-
foreach ($this->projections as $projection) {
26-
$query = $query->add($projection->subQuery());
27-
}
28-
29-
return $query;
23+
$query = new Query(
24+
...array_map(
25+
static fn (Projection $projection) => $projection->subQuery(),
26+
$this->projections,
27+
),
28+
);
29+
30+
return $query->optimize();
3031
}
3132

3233
/** @return array<string, mixed> */

src/DCB/EventTagExtractorError.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\DCB;
6+
7+
use RuntimeException;
8+
9+
use function get_debug_type;
10+
use function sprintf;
11+
12+
final class EventTagExtractorError extends RuntimeException
13+
{
14+
/** @param class-string $class */
15+
public static function invalidValueType(string $class, string $property, mixed $value): self
16+
{
17+
return new self(
18+
sprintf(
19+
'Event tag value for property "%s" in class "%s" must be stringable, %s given',
20+
$property,
21+
$class,
22+
get_debug_type($value),
23+
),
24+
);
25+
}
26+
}

src/Store/Query.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Patchlevel\EventSourcing\Store;
66

7+
use function array_merge;
78
use function array_values;
89

910
/** @experimental */
@@ -20,12 +21,33 @@ public function __construct(
2021

2122
public function add(SubQuery $subQuery): self
2223
{
23-
foreach ($this->subQueries as $query) {
24-
if ($query->equals($subQuery)) {
25-
return $this;
24+
return new self(...array_merge($this->subQueries, [$subQuery]));
25+
}
26+
27+
/**
28+
* Optimize the query by removing sub-queries that are included in other sub-queries.
29+
*/
30+
public function optimize(): Query
31+
{
32+
$queries = $this->subQueries;
33+
34+
foreach ($queries as $key => $a) {
35+
foreach ($queries as $b) {
36+
if ($a === $b) {
37+
continue;
38+
}
39+
40+
if ($b->empty() && !$b->onlyLastEvent) {
41+
return new self();
42+
}
43+
44+
if ($b->includes($a)) {
45+
unset($queries[$key]);
46+
continue 2;
47+
}
2648
}
2749
}
2850

29-
return new self($subQuery, ...$this->subQueries);
51+
return new self(...$queries);
3052
}
3153
}

src/Store/SubQuery.php

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
use function array_diff;
1212
use function in_array;
13-
use function sort;
1413

1514
/** @experimental */
1615
final class SubQuery
@@ -25,52 +24,57 @@ public function __construct(
2524
public readonly string|null $streamName = null,
2625
public readonly bool $onlyLastEvent = false,
2726
) {
28-
sort($tags);
29-
sort($events);
3027
}
3128

3229
public function match(Message $message): bool
3330
{
34-
if ($this->tags === [] && $this->events === []) {
35-
return true;
31+
if (
32+
$this->streamName !== null
33+
&& (!$message->hasHeader(StreamNameHeader::class)
34+
|| $message->header(StreamNameHeader::class)->streamName !== $this->streamName)
35+
) {
36+
return false;
3637
}
3738

38-
if (!$message->hasHeader(TagsHeader::class)) {
39+
if (
40+
$this->tags !== []
41+
&& (!$message->hasHeader(TagsHeader::class)
42+
|| !self::isSubset($this->tags, $message->header(TagsHeader::class)->tags))
43+
) {
3944
return false;
4045
}
4146

42-
if ($this->streamName !== null && $message->header(StreamNameHeader::class)->streamName !== $this->streamName) {
47+
return $this->events === [] || in_array($message->event()::class, $this->events, true);
48+
}
49+
50+
public function empty(): bool
51+
{
52+
return $this->streamName === null && $this->tags === [] && $this->events === [];
53+
}
54+
55+
public function includes(SubQuery $other): bool
56+
{
57+
if ($this->streamName !== null && $this->streamName !== $other->streamName) {
4358
return false;
4459
}
4560

46-
if ($this->events !== [] && !in_array($message->event()::class, $this->events, true)) {
61+
if (!self::isSubset($this->tags, $other->tags)) {
4762
return false;
4863
}
4964

50-
return $this->isSubset($this->tags, $message->header(TagsHeader::class)->tags);
51-
}
65+
if (!self::isSubset($this->events, $other->events)) {
66+
return false;
67+
}
5268

53-
public function equals(self $queryComponent): bool
54-
{
55-
return $this->streamName === $queryComponent->streamName
56-
&& $this->tags === $queryComponent->tags
57-
&& $this->events === $queryComponent->events
58-
&& $this->onlyLastEvent === $queryComponent->onlyLastEvent;
69+
return !$this->onlyLastEvent || $other->onlyLastEvent;
5970
}
6071

6172
/**
62-
* @param list<string> $needle
63-
* @param list<string> $haystack
73+
* @param list<string> $subject
74+
* @param list<string> $off
6475
*/
65-
private function isSubset(array $needle, array $haystack): bool
66-
{
67-
return empty(array_diff($needle, $haystack));
68-
}
69-
70-
public function empty(): bool
76+
private static function isSubset(array $subject, array $off): bool
7177
{
72-
return $this->streamName === null
73-
&& $this->tags === []
74-
&& $this->events === [];
78+
return empty(array_diff($subject, $off));
7579
}
7680
}

tests/Unit/DCB/AttributeEventTagExtractorTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Patchlevel\EventSourcing\Tests\Unit\DCB;
66

7+
use Patchlevel\EventSourcing\Aggregate\CustomId;
78
use Patchlevel\EventSourcing\Attribute\EventTag;
89
use Patchlevel\EventSourcing\DCB\AttributeEventTagExtractor;
910
use PHPUnit\Framework\TestCase;
@@ -22,6 +23,22 @@ public function testExtractEmpty(): void
2223
self::assertSame([], $tags);
2324
}
2425

26+
public function testExtractClassWithoutAttributes(): void
27+
{
28+
$extractor = new AttributeEventTagExtractor();
29+
30+
$event = new class {
31+
public function __construct(
32+
public string $name = 'baz',
33+
) {
34+
}
35+
};
36+
37+
$tags = $extractor->extract($event);
38+
39+
self::assertSame([], $tags);
40+
}
41+
2542
public function testExtract(): void
2643
{
2744
$extractor = new AttributeEventTagExtractor();
@@ -41,6 +58,23 @@ public function __construct(
4158
self::assertSame(['foo', 'bar:baz'], $tags);
4259
}
4360

61+
public function testExtractStringable(): void
62+
{
63+
$extractor = new AttributeEventTagExtractor();
64+
65+
$event = new class (new CustomId('foo')) {
66+
public function __construct(
67+
#[EventTag]
68+
public CustomId $id,
69+
) {
70+
}
71+
};
72+
73+
$tags = $extractor->extract($event);
74+
75+
self::assertSame(['foo'], $tags);
76+
}
77+
4478
public function testExtractWithHash(): void
4579
{
4680
$extractor = new AttributeEventTagExtractor();
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Tests\Unit\DCB;
6+
7+
use Patchlevel\EventSourcing\DCB\CompositeProjection;
8+
use Patchlevel\EventSourcing\Message\Message;
9+
use Patchlevel\EventSourcing\Store\Header\StreamNameHeader;
10+
use Patchlevel\EventSourcing\Store\Header\TagsHeader;
11+
use Patchlevel\EventSourcing\Store\Query;
12+
use Patchlevel\EventSourcing\Store\SubQuery;
13+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email;
14+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\IncrementProjection;
15+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated;
16+
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId;
17+
use PHPUnit\Framework\TestCase;
18+
19+
final class CompositeProjectionTest extends TestCase
20+
{
21+
public function testQueryAggregatesSubQueries(): void
22+
{
23+
$p1 = new IncrementProjection(0, ['tag:a']);
24+
$p2 = new IncrementProjection(0, ['tag:b'], 'main');
25+
26+
$composite = new CompositeProjection([
27+
'a' => $p1,
28+
'b' => $p2,
29+
]);
30+
31+
self::assertEquals(new Query(
32+
new SubQuery(
33+
['tag:a'],
34+
[ProfileCreated::class],
35+
),
36+
new SubQuery(
37+
['tag:b'],
38+
[ProfileCreated::class],
39+
'main',
40+
),
41+
), $composite->query());
42+
}
43+
44+
public function testInitialStateBuildsMap(): void
45+
{
46+
$composite = new CompositeProjection([
47+
'alpha' => new IncrementProjection(1),
48+
'beta' => new IncrementProjection(5),
49+
]);
50+
51+
$state = $composite->initialState();
52+
53+
self::assertSame(['alpha' => 1, 'beta' => 5], $state);
54+
}
55+
56+
public function testApplyDelegatesToEachProjection(): void
57+
{
58+
$composite = new CompositeProjection([
59+
'x' => new IncrementProjection(0, ['match']),
60+
'y' => new IncrementProjection(10, ['match']),
61+
]);
62+
63+
$state = $composite->initialState();
64+
65+
$message = Message::create(new ProfileCreated(
66+
ProfileId::fromString('test'),
67+
Email::fromString('[email protected]'),
68+
))
69+
->withHeader(new StreamNameHeader('main'))
70+
->withHeader(new TagsHeader(['match']));
71+
72+
$newState = $composite->apply($state, $message);
73+
74+
// both should have been incremented by 1
75+
self::assertSame(1, $newState['x']);
76+
self::assertSame(11, $newState['y']);
77+
}
78+
79+
public function testApplySkipsNonMatchingProjection(): void
80+
{
81+
$composite = new CompositeProjection([
82+
'm' => new IncrementProjection(2, ['match']),
83+
'n' => new IncrementProjection(3, ['other']),
84+
]);
85+
86+
$state = $composite->initialState();
87+
88+
$message = Message::create(new ProfileCreated(
89+
ProfileId::fromString('test'),
90+
Email::fromString('[email protected]'),
91+
))
92+
->withHeader(new StreamNameHeader('main'))
93+
->withHeader(new TagsHeader(['match']));
94+
95+
$newState = $composite->apply($state, $message);
96+
97+
// 'm' matches and increments; 'n' does not match and stays the same
98+
self::assertSame(3, $newState['m']);
99+
self::assertSame(3, $newState['n']);
100+
}
101+
}

0 commit comments

Comments
 (0)