Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/Store/SubQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ public function empty(): bool

public function includes(SubQuery $other): bool
{
// Only the last matching event is loaded. That event is only the last event
// of the other query as well, if both queries match exactly the same events.
if ($this->onlyLastEvent) {
return $other->onlyLastEvent
&& $this->streamName === $other->streamName
&& self::isSameSet($this->tags, $other->tags)
&& self::isSameSet($this->events, $other->events);
}

if ($this->streamName !== null && $this->streamName !== $other->streamName) {
return false;
}
Expand All @@ -65,11 +74,16 @@ public function includes(SubQuery $other): bool
// events is an allow list: an empty list matches everything, so it is the
// broadest filter. Otherwise this query only covers the other one when
// every event the other query allows is also allowed here.
if ($this->events !== [] && ($other->events === [] || !self::isSubset($other->events, $this->events))) {
return false;
}
return $this->events === [] || ($other->events !== [] && self::isSubset($other->events, $this->events));
}

return !$this->onlyLastEvent || $other->onlyLastEvent;
/**
* @param list<string> $a
* @param list<string> $b
*/
private static function isSameSet(array $a, array $b): bool
{
return self::isSubset($a, $b) && self::isSubset($b, $a);
}

/**
Expand Down
25 changes: 25 additions & 0 deletions tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
use Patchlevel\EventSourcing\Store\AppendCondition;
use Patchlevel\EventSourcing\Store\AppendStore;
use Patchlevel\EventSourcing\Store\Header\TagsHeader;
use Patchlevel\EventSourcing\Store\InMemoryStore;
use Patchlevel\EventSourcing\Store\Query;
use Patchlevel\EventSourcing\Store\SubQuery;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\IncrementProjection;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\LastEmailProjection;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId;
use PHPUnit\Framework\Attributes\CoversClass;
Expand Down Expand Up @@ -67,6 +69,29 @@ public function testWithProjections(): void
self::assertEquals(new AppendCondition($expectedQuery, 1), $state->appendCondition);
}

public function testOnlyLastEventProjectionsWithOverlappingTags(): void
{
$store = new InMemoryStore();
$store->save(
Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('first@patchlevel.de')))
->withHeader(new TagsHeader(['a', 'b'])),
Message::create(new ProfileCreated(ProfileId::fromString('2'), Email::fromString('second@patchlevel.de')))
->withHeader(new TagsHeader(['a'])),
);

$builder = new StoreDecisionModelBuilder($store);

$state = $builder->build([
'a' => new LastEmailProjection(['a']),
'ab' => new LastEmailProjection(['a', 'b']),
]);

self::assertEquals(
['a' => 'second@patchlevel.de', 'ab' => 'first@patchlevel.de'],
$state->state,
);
}

public function testEmptyStream(): void
{
$store = $this->createMock(AppendStore::class);
Expand Down
39 changes: 39 additions & 0 deletions tests/Unit/Fixture/LastEmailProjection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Unit\Fixture;

use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Projection\BasicProjection;

final class LastEmailProjection extends BasicProjection
{
/** @param list<string> $tags */
public function __construct(
private readonly array $tags = [],
) {
}

#[Apply]
public function applyProfileCreated(string|null $state, ProfileCreated $event): string
{
return $event->email->toString();
}

public function initialState(): string|null
{
return null;
}

/** @return list<string> */
public function tagFilter(): array
{
return $this->tags;
}

protected function lastEventIsEnough(): bool
{
return true;
}
}
42 changes: 42 additions & 0 deletions tests/Unit/Store/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,48 @@ public static function providerForOptimize(): Generator
new Query(),
];

yield 'only last event with fewer tags keeps narrower only last event' => [
new Query(
new SubQuery(['a'], onlyLastEvent: true),
new SubQuery(['a', 'b'], onlyLastEvent: true),
),
new Query(
new SubQuery(['a'], onlyLastEvent: true),
new SubQuery(['a', 'b'], onlyLastEvent: true),
),
];

yield 'only last event with more events keeps narrower only last event' => [
new Query(
new SubQuery(['a'], [ProfileCreated::class], onlyLastEvent: true),
new SubQuery(['a'], [ProfileCreated::class, ProfileVisited::class], onlyLastEvent: true),
),
new Query(
new SubQuery(['a'], [ProfileCreated::class], onlyLastEvent: true),
new SubQuery(['a'], [ProfileCreated::class, ProfileVisited::class], onlyLastEvent: true),
),
];

yield 'equal only last event subqueries' => [
new Query(
new SubQuery(['a'], [ProfileCreated::class], onlyLastEvent: true),
new SubQuery(['a'], [ProfileCreated::class], onlyLastEvent: true),
),
new Query(
new SubQuery(['a'], [ProfileCreated::class], onlyLastEvent: true),
),
];

yield 'full subquery absorbs narrower only last event' => [
new Query(
new SubQuery(['a']),
new SubQuery(['a', 'b'], onlyLastEvent: true),
),
new Query(
new SubQuery(['a']),
),
];

yield 'empty sub query with only last event' => [
new Query(
new SubQuery(
Expand Down
36 changes: 36 additions & 0 deletions tests/Unit/Store/SubQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,42 @@ public static function providerForIncludes(): Generator
new SubQuery(onlyLastEvent: true),
true,
];

yield 'only last event with same filters includes only last event' => [
new SubQuery(['tag1', 'tag2'], [ProfileCreated::class, ProfileVisited::class], 'foo', true),
new SubQuery(['tag2', 'tag1'], [ProfileVisited::class, ProfileCreated::class], 'foo', true),
true,
];

yield 'only last event with fewer tags does not include only last event' => [
new SubQuery(['tag1'], onlyLastEvent: true),
new SubQuery(['tag1', 'tag2'], onlyLastEvent: true),
false,
];

yield 'only last event with more events does not include only last event' => [
new SubQuery(['tag1'], [ProfileCreated::class, ProfileVisited::class], onlyLastEvent: true),
new SubQuery(['tag1'], [ProfileCreated::class], onlyLastEvent: true),
false,
];

yield 'only last event with all events does not include only last event' => [
new SubQuery(['tag1'], onlyLastEvent: true),
new SubQuery(['tag1'], [ProfileCreated::class], onlyLastEvent: true),
false,
];

yield 'only last event without stream name does not include only last event' => [
new SubQuery(['tag1'], onlyLastEvent: true),
new SubQuery(['tag1'], streamName: 'foo', onlyLastEvent: true),
false,
];

yield 'empty only last event does not include only last event' => [
new SubQuery(onlyLastEvent: true),
new SubQuery(['tag1'], onlyLastEvent: true),
false,
];
}

/**
Expand Down
Loading