From cd26db3674cb0e6eea2945ad970fcc5b65b85c28 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 3 Oct 2026 17:36:28 +0200 Subject: [PATCH 1/2] Fix optimize dropping only-last-event sub queries SubQuery::includes() treated an onlyLastEvent sub query with broader filters as covering a narrower onlyLastEvent one. The last event tagged `a` is not necessarily the last event tagged `a` and `b`, so Query::optimize() removed the narrower sub query and the projection behind it never got its event. An onlyLastEvent sub query now only includes another one if both match exactly the same events. --- src/Store/SubQuery.php | 12 +++++- .../StoreDecisionModelBuilderTest.php | 25 +++++++++++ tests/Unit/Fixture/LastEmailProjection.php | 39 +++++++++++++++++ tests/Unit/Store/QueryTest.php | 42 +++++++++++++++++++ tests/Unit/Store/SubQueryTest.php | 36 ++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Fixture/LastEmailProjection.php diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index c74dbcf71..f9e8364fc 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -69,7 +69,17 @@ public function includes(SubQuery $other): bool return false; } - return !$this->onlyLastEvent || $other->onlyLastEvent; + if (!$this->onlyLastEvent) { + return true; + } + + // 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. + return $other->onlyLastEvent + && $this->streamName === $other->streamName + && self::isSubset($other->tags, $this->tags) + && ($this->events === []) === ($other->events === []) + && self::isSubset($this->events, $other->events); } /** 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, + ]; } /** From 8b816970df6c66d3f89f92b1cb5e23f64a2cdbe2 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 3 Oct 2026 18:33:59 +0200 Subject: [PATCH 2/2] Check only-last-event sub queries for equal filters upfront --- src/Store/SubQuery.php | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index f9e8364fc..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,21 +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; - } - - if (!$this->onlyLastEvent) { - return true; - } + return $this->events === [] || ($other->events !== [] && self::isSubset($other->events, $this->events)); + } - // 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. - return $other->onlyLastEvent - && $this->streamName === $other->streamName - && self::isSubset($other->tags, $this->tags) - && ($this->events === []) === ($other->events === []) - && self::isSubset($this->events, $other->events); + /** + * @param list $a + * @param list $b + */ + private static function isSameSet(array $a, array $b): bool + { + return self::isSubset($a, $b) && self::isSubset($b, $a); } /**