diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index c74dbcf71..313e8ff37 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -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; } @@ -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 $a + * @param list $b + */ + private static function isSameSet(array $a, array $b): bool + { + return self::isSubset($a, $b) && self::isSubset($b, $a); } /** diff --git a/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php b/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php index fe379c3ca..5de35fa13 100644 --- a/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php +++ b/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php @@ -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; @@ -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); diff --git a/tests/Unit/Fixture/LastEmailProjection.php b/tests/Unit/Fixture/LastEmailProjection.php new file mode 100644 index 000000000..f1b574425 --- /dev/null +++ b/tests/Unit/Fixture/LastEmailProjection.php @@ -0,0 +1,39 @@ + $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 */ + public function tagFilter(): array + { + return $this->tags; + } + + protected function lastEventIsEnough(): bool + { + return true; + } +} diff --git a/tests/Unit/Store/QueryTest.php b/tests/Unit/Store/QueryTest.php index acc59c247..7a51fa21b 100644 --- a/tests/Unit/Store/QueryTest.php +++ b/tests/Unit/Store/QueryTest.php @@ -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( diff --git a/tests/Unit/Store/SubQueryTest.php b/tests/Unit/Store/SubQueryTest.php index 98dd2c580..eb61af61b 100644 --- a/tests/Unit/Store/SubQueryTest.php +++ b/tests/Unit/Store/SubQueryTest.php @@ -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, + ]; } /**