From 1615cbc61c6b42379f268d8ff67ad4c9ccd681bd Mon Sep 17 00:00:00 2001 From: Bastian Waidelich Date: Wed, 24 Jul 2024 12:07:00 +0200 Subject: [PATCH] TASK: Remove EventTypesCriterion and TagsCriterion They are now both integrated to `EventTypesAndTagsCriterion` --- src/Helpers/InMemoryEventStore.php | 83 +++++++----- src/Types/EventEnvelope.php | 5 + src/Types/EventEnvelopes.php | 98 ++++++++++++++ .../Criteria/EventTypesAndTagsCriterion.php | 66 +++++++++- .../Criteria/EventTypesCriterion.php | 28 ---- .../StreamQuery/Criteria/TagsCriterion.php | 28 ---- .../StreamQuery/StreamQuerySerializer.php | 43 ++---- .../EventStoreConcurrencyTestBase.php | 29 ++-- tests/Integration/EventStoreTestBase.php | 124 ++++++++++++++---- .../StreamQuery/StreamQuerySerializerTest.php | 14 +- 10 files changed, 332 insertions(+), 186 deletions(-) create mode 100644 src/Types/EventEnvelopes.php delete mode 100644 src/Types/StreamQuery/Criteria/EventTypesCriterion.php delete mode 100644 src/Types/StreamQuery/Criteria/TagsCriterion.php diff --git a/src/Helpers/InMemoryEventStore.php b/src/Helpers/InMemoryEventStore.php index b5ba6f2..614ed4c 100644 --- a/src/Helpers/InMemoryEventStore.php +++ b/src/Helpers/InMemoryEventStore.php @@ -12,12 +12,11 @@ use Wwwision\DCBEventStore\Types\AppendCondition; use Wwwision\DCBEventStore\Types\Event; use Wwwision\DCBEventStore\Types\EventEnvelope; +use Wwwision\DCBEventStore\Types\EventEnvelopes; use Wwwision\DCBEventStore\Types\Events; use Wwwision\DCBEventStore\Types\ReadOptions; use Wwwision\DCBEventStore\Types\SequenceNumber; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\Criterion; use Wwwision\DCBEventStore\Types\StreamQuery\CriterionHashes; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; @@ -36,13 +35,11 @@ */ final class InMemoryEventStore implements EventStore { - /** - * @var array - */ - private array $events = []; + private EventEnvelopes $eventEnvelopes; private function __construct() { + $this->eventEnvelopes = EventEnvelopes::none(); } public static function create(): self @@ -52,31 +49,44 @@ public static function create(): self public function read(StreamQuery $query, ?ReadOptions $options = null): InMemoryEventStream { - $options ??= ReadOptions::create(); + $matchingCriterionHashesBySequenceNumber = []; + $eventEnvelopes = $this->eventEnvelopes; + foreach ($query->criteria as $criterion) { + $onlyLastEvent = $criterion instanceof EventTypesAndTagsCriterion && $criterion->onlyLastEvent; + if ($onlyLastEvent) { + $eventEnvelopes = EventEnvelopes::fromArray(array_reverse(iterator_to_array($eventEnvelopes))); + } + foreach ($eventEnvelopes as $eventEnvelope) { + if (!self::criterionMatchesEvent($criterion, $eventEnvelope->event)) { + continue; + } + $sequenceNumber = $eventEnvelope->sequenceNumber->value; + if (!array_key_exists($sequenceNumber, $matchingCriterionHashesBySequenceNumber)) { + $matchingCriterionHashesBySequenceNumber[$sequenceNumber] = []; + } + $matchingCriterionHashesBySequenceNumber[$sequenceNumber][] = $criterion->hash(); + if ($onlyLastEvent) { + continue 2; + } + } + } + $matchingEventEnvelopes = []; - $events = $this->events; + $eventEnvelopes = $this->eventEnvelopes; + $options ??= ReadOptions::create(); if ($options->backwards) { - $events = array_reverse($events); + $eventEnvelopes = EventEnvelopes::fromArray(array_reverse(iterator_to_array($eventEnvelopes))); } - foreach ($events as $event) { - if ($options->from !== null && (($options->backwards && $event['sequenceNumber'] > $options->from->value) || (!$options->backwards && $event['sequenceNumber'] < $options->from->value))) { + foreach ($eventEnvelopes as $eventEnvelope) { + $sequenceNumber = $eventEnvelope->sequenceNumber->value; + if ($options->from !== null && (($options->backwards && $sequenceNumber > $options->from->value) || (!$options->backwards && $sequenceNumber < $options->from->value))) { continue; } - $matchedCriterionHashes = []; - if (!$query->isWildcard()) { - foreach ($query->criteria as $criterion) { - if (array_key_exists($criterion->hash()->value, $matchedCriterionHashes)) { - continue; - } - if (self::criterionMatchesEvent($criterion, $event['event'])) { - $matchedCriterionHashes[$criterion->hash()->value] = true; - } - } - if ($matchedCriterionHashes === []) { - continue; - } + if (!array_key_exists($sequenceNumber, $matchingCriterionHashesBySequenceNumber) && !$query->isWildcard()) { + continue; } - $matchingEventEnvelopes[] = new EventEnvelope(SequenceNumber::fromInteger($event['sequenceNumber']), $event['recordedAt'], CriterionHashes::fromArray(array_keys($matchedCriterionHashes)), $event['event']); + + $matchingEventEnvelopes[] = $eventEnvelope->withCriterionHashes(CriterionHashes::fromArray($matchingCriterionHashesBySequenceNumber[$sequenceNumber] ?? [])); } return InMemoryEventStream::create(...$matchingEventEnvelopes); } @@ -89,9 +99,7 @@ public function readAll(?ReadOptions $options = null): EventStream private static function criterionMatchesEvent(Criterion $criterion, Event $event): bool { return match ($criterion::class) { - EventTypesAndTagsCriterion::class => $event->tags->containEvery($criterion->tags) && $criterion->eventTypes->contain($event->type), - EventTypesCriterion::class => $criterion->eventTypes->contain($event->type), - TagsCriterion::class => $event->tags->containEvery($criterion->tags), + EventTypesAndTagsCriterion::class => ($criterion->tags === null || $event->tags->containEvery($criterion->tags)) && ($criterion->eventTypes === null || $criterion->eventTypes->contain($event->type)), default => throw new RuntimeException(sprintf('The criterion type "%s" is not supported by the %s', $criterion::class, self::class), 1700302540), }; } @@ -110,14 +118,19 @@ public function append(Events $events, AppendCondition $condition): void throw ConditionalAppendFailed::becauseHighestExpectedSequenceNumberDoesNotMatch($condition->expectedHighestSequenceNumber); } } - $sequenceNumber = count($this->events); + $sequenceNumber = SequenceNumber::fromInteger(count($this->eventEnvelopes) + 1); + $newEventEnvelopes = EventEnvelopes::none(); foreach ($events as $event) { - $sequenceNumber++; - $this->events[] = [ - 'sequenceNumber' => $sequenceNumber, - 'recordedAt' => new DateTimeImmutable(), - 'event' => $event, - ]; + $newEventEnvelopes = $newEventEnvelopes->append( + new EventEnvelope( + $sequenceNumber, + new DateTimeImmutable(), + CriterionHashes::none(), + $event, + ) + ); + $sequenceNumber = $sequenceNumber->next(); } + $this->eventEnvelopes = $this->eventEnvelopes->append($newEventEnvelopes); } } diff --git a/src/Types/EventEnvelope.php b/src/Types/EventEnvelope.php index adb2266..2876ffa 100644 --- a/src/Types/EventEnvelope.php +++ b/src/Types/EventEnvelope.php @@ -19,4 +19,9 @@ public function __construct( public readonly Event $event, ) { } + + public function withCriterionHashes(CriterionHashes $criterionHashes): self + { + return new self($this->sequenceNumber, $this->recordedAt, $criterionHashes, $this->event); + } } diff --git a/src/Types/EventEnvelopes.php b/src/Types/EventEnvelopes.php new file mode 100644 index 0000000..d1054ae --- /dev/null +++ b/src/Types/EventEnvelopes.php @@ -0,0 +1,98 @@ + + */ +final class EventEnvelopes implements IteratorAggregate, JsonSerializable, Countable +{ + /** + * @var array + */ + private readonly array $eventEnvelopes; + + private function __construct(EventEnvelope ...$eventEnvelopes) + { + $this->eventEnvelopes = array_values($eventEnvelopes); + } + + public static function single(EventEnvelope $eventEnvelope): self + { + return new self($eventEnvelope); + } + + /** + * @param EventEnvelope[] $eventEnvelopes + */ + public static function fromArray(array $eventEnvelopes): self + { + return new self(...$eventEnvelopes); + } + + public static function none(): self + { + return new self(); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->eventEnvelopes); + } + + public function at(int $index): EventEnvelope + { + if (!array_key_exists($index, $this->eventEnvelopes)) { + throw new \InvalidArgumentException(sprintf('no EventEnvelope at index %d', $index), 1719995162); + } + return $this->eventEnvelopes[$index]; + } + + /** + * @param Closure(EventEnvelope $event): mixed $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->eventEnvelopes); + } + + public function filter(Closure $callback): self + { + return self::fromArray(array_filter($this->eventEnvelopes, $callback)); + } + + public function append(EventEnvelope|self $eventEnvelopes): self + { + if ($eventEnvelopes instanceof EventEnvelope) { + $eventEnvelopes = self::fromArray([$eventEnvelopes]); + } + return self::fromArray([...$this->eventEnvelopes, ...$eventEnvelopes]); + } + + public function count(): int + { + return count($this->eventEnvelopes); + } + + /** + * @return EventEnvelope[] + */ + public function jsonSerialize(): array + { + return $this->eventEnvelopes; + } +} diff --git a/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php b/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php index 73953c8..897715c 100644 --- a/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php +++ b/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php @@ -4,23 +4,79 @@ namespace Wwwision\DCBEventStore\Types\StreamQuery\Criteria; +use InvalidArgumentException; +use Wwwision\DCBEventStore\Types\EventType; use Wwwision\DCBEventStore\Types\EventTypes; use Wwwision\DCBEventStore\Types\StreamQuery\Criterion; use Wwwision\DCBEventStore\Types\StreamQuery\CriterionHash; +use Wwwision\DCBEventStore\Types\Tag; use Wwwision\DCBEventStore\Types\Tags; final class EventTypesAndTagsCriterion implements Criterion { private readonly CriterionHash $hash; - public function __construct( - public readonly EventTypes $eventTypes, - public readonly Tags $tags, + private function __construct( + public readonly EventTypes|null $eventTypes, + public readonly Tags|null $tags, + public readonly bool $onlyLastEvent, ) { $this->hash = CriterionHash::fromParts( substr(substr(self::class, 0, -9), strrpos(self::class, '\\') + 1), - implode(',', $this->eventTypes->toStringArray()), - implode(',', $this->tags->toSimpleArray()), + implode(',', $eventTypes?->toStringArray() ?? []), + implode(',', $tags?->toSimpleArray() ?? []), + $onlyLastEvent ? 'onlyLastEvent' : '', + ); + } + + /** + * @param EventTypes|array|string|null $eventTypes + * @param Tags|array|string|null $tags + */ + public static function create( + EventTypes|array|string|null $eventTypes = null, + Tags|array|string|null $tags = null, + bool|null $onlyLastEvent = null, + ): self { + if (is_string($eventTypes)) { + $eventTypes = EventTypes::fromStrings($eventTypes); + } elseif (is_array($eventTypes)) { + $eventTypes = EventTypes::fromArray($eventTypes); + } + if (is_string($tags)) { + $tags = Tags::create(Tag::parse($tags)); + } elseif (is_array($tags)) { + $tags = Tags::fromArray($tags); + } + if ($eventTypes === null && $tags === null) { + throw new InvalidArgumentException('one of eventTypes or tags must not be null!', 1716131425); + } + return new self($eventTypes, $tags, $onlyLastEvent ?? false); + } + + /** + * @param EventTypes|array|string|null $eventTypes + * @param Tags|array|string|null $tags + */ + public function with( + EventTypes|array|string|null $eventTypes = null, + Tags|array|string|null $tags = null, + bool|null $onlyLastEvent = null, + ): self { + if (is_string($eventTypes)) { + $eventTypes = EventTypes::fromStrings($eventTypes); + } elseif (is_array($eventTypes)) { + $eventTypes = EventTypes::fromArray($eventTypes); + } + if (is_string($tags)) { + $tags = Tags::create(Tag::parse($tags)); + } elseif (is_array($tags)) { + $tags = Tags::fromArray($tags); + } + return new self( + $eventTypes ?? $this->eventTypes, + $tags ?? $this->tags, + $onlyLastEvent ?? $this->onlyLastEvent, ); } diff --git a/src/Types/StreamQuery/Criteria/EventTypesCriterion.php b/src/Types/StreamQuery/Criteria/EventTypesCriterion.php deleted file mode 100644 index ae5da5a..0000000 --- a/src/Types/StreamQuery/Criteria/EventTypesCriterion.php +++ /dev/null @@ -1,28 +0,0 @@ -hash = CriterionHash::fromParts( - substr(substr(self::class, 0, -9), strrpos(self::class, '\\') + 1), - implode(',', $this->eventTypes->toStringArray()), - ); - } - - public function hash(): CriterionHash - { - return $this->hash; - } -} diff --git a/src/Types/StreamQuery/Criteria/TagsCriterion.php b/src/Types/StreamQuery/Criteria/TagsCriterion.php deleted file mode 100644 index 0e5b73a..0000000 --- a/src/Types/StreamQuery/Criteria/TagsCriterion.php +++ /dev/null @@ -1,28 +0,0 @@ -hash = CriterionHash::fromParts( - substr(substr(self::class, 0, -9), strrpos(self::class, '\\') + 1), - implode(',', $this->tags->toSimpleArray()), - ); - } - - public function hash(): CriterionHash - { - return $this->hash; - } -} diff --git a/src/Types/StreamQuery/StreamQuerySerializer.php b/src/Types/StreamQuery/StreamQuerySerializer.php index 0f32012..ebb6668 100644 --- a/src/Types/StreamQuery/StreamQuerySerializer.php +++ b/src/Types/StreamQuery/StreamQuerySerializer.php @@ -8,19 +8,12 @@ use JsonException; use RuntimeException; use Webmozart\Assert\Assert; -use Wwwision\DCBEventStore\Types\EventTypes; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; -use Wwwision\DCBEventStore\Types\Tags; - -use function get_debug_type; use function json_decode; use function json_encode; use function sprintf; use function strrpos; use function substr; - use const JSON_PRETTY_PRINT; final class StreamQuerySerializer @@ -66,7 +59,7 @@ private static function serializeCriterion(Criterion $criterion): array return [ 'type' => substr(substr($criterion::class, 0, -9), strrpos($criterion::class, '\\') + 1), 'hash' => $criterion->hash(), - 'properties' => get_object_vars($criterion), + 'properties' => array_filter(get_object_vars($criterion), static fn ($v) => $v !== null), ]; } @@ -81,8 +74,6 @@ private static function unserializeCriterion(array $criterion): Criterion $criterionClassName = 'Wwwision\\DCBEventStore\\Types\\StreamQuery\\Criteria\\' . $criterion['type'] . 'Criterion'; return match ($criterionClassName) { EventTypesAndTagsCriterion::class => self::unserializeEventTypesAndTagsCriterion($criterion['properties']), - EventTypesCriterion::class => self::unserializeEventTypesCriterion($criterion['properties']), - TagsCriterion::class => self::unserializeTagsCriterion($criterion['properties']), default => throw new RuntimeException(sprintf('Unsupported criterion type %s', $criterionClassName), 1687970877), }; } @@ -92,30 +83,12 @@ private static function unserializeCriterion(array $criterion): Criterion */ private static function unserializeEventTypesAndTagsCriterion(array $properties): EventTypesAndTagsCriterion { - Assert::keyExists($properties, 'eventTypes'); - Assert::isArray($properties['eventTypes']); - Assert::keyExists($properties, 'tags'); - Assert::isArray($properties['tags']); - return new EventTypesAndTagsCriterion(EventTypes::fromStrings(...$properties['eventTypes']), Tags::fromArray($properties['tags'])); - } - - /** - * @param array $properties - */ - private static function unserializeEventTypesCriterion(array $properties): EventTypesCriterion - { - Assert::keyExists($properties, 'eventTypes'); - Assert::isArray($properties['eventTypes']); - return new EventTypesCriterion(EventTypes::fromStrings(...$properties['eventTypes'])); - } - - /** - * @param array $properties - */ - private static function unserializeTagsCriterion(array $properties): TagsCriterion - { - Assert::keyExists($properties, 'tags'); - Assert::isArray($properties['tags']); - return new TagsCriterion(Tags::fromArray($properties['tags'])); + Assert::nullOrIsArray($properties['eventTypes'] ?? null); + Assert::nullOrIsArray($properties['tags'] ?? null); + return EventTypesAndTagsCriterion::create( + eventTypes: $properties['eventTypes'] ?? null, + tags: $properties['tags'] ?? null, + onlyLastEvent: $properties['onlyLastEvent'] ?? null, + ); } } diff --git a/tests/Integration/EventStoreConcurrencyTestBase.php b/tests/Integration/EventStoreConcurrencyTestBase.php index 3f55799..fb36cfd 100644 --- a/tests/Integration/EventStoreConcurrencyTestBase.php +++ b/tests/Integration/EventStoreConcurrencyTestBase.php @@ -11,25 +11,22 @@ use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; use Wwwision\DCBEventStore\Types\AppendCondition; -use Wwwision\DCBEventStore\Types\EventMetadata; -use Wwwision\DCBEventStore\Types\ReadOptions; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criterion; -use Wwwision\DCBEventStore\Types\Tag; -use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBEventStore\Types\Event; use Wwwision\DCBEventStore\Types\EventData; use Wwwision\DCBEventStore\Types\EventEnvelope; use Wwwision\DCBEventStore\Types\EventId; +use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\Events; use Wwwision\DCBEventStore\Types\EventType; -use Wwwision\DCBEventStore\Types\EventTypes; use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; +use Wwwision\DCBEventStore\Types\ReadOptions; +use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; +use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; +use Wwwision\DCBEventStore\Types\StreamQuery\Criterion; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuerySerializer; +use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\DCBEventStore\Types\Tags; use function array_map; use function array_rand; use function array_slice; @@ -79,10 +76,10 @@ public function test_consistency(int $process): void $tags[] = Tag::create($key, self::either(...$tagValues)); } $queryCreators = [ - static fn () => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::create(...self::some($numberOfTags, ...$tags))))), - static fn () => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::create(...self::some($numberOfEventTypes, ...$eventTypes))))), - static fn () => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::create(...self::some($numberOfEventTypes, ...$eventTypes)), Tags::create(...self::some($numberOfTags, ...$tags))))), - static fn () => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::create(...self::some(1, ...$eventTypes)), Tags::create(...self::some(1, ...$tags))), new EventTypesAndTagsCriterion(EventTypes::create(...self::some(1, ...$eventTypes)), Tags::create(...self::some(1, ...$tags))))), + static fn () => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(tags: self::some($numberOfTags, ...$tags)))), + static fn () => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: self::some($numberOfEventTypes, ...$eventTypes)))), + static fn () => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: self::some($numberOfEventTypes, ...$eventTypes), tags: self::some($numberOfTags, ...$tags)))), + static fn () => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: self::some(1, ...$eventTypes), tags: self::some(1, ...$tags)), EventTypesAndTagsCriterion::create(eventTypes: self::some(1, ...$eventTypes), tags: self::some(1, ...$tags)))), ]; for ($eventBatch = 0; $eventBatch < $numberOfEventBatches; $eventBatch ++) { @@ -193,9 +190,7 @@ private static function queryMatchesEvent(StreamQuery $query, Event $event): boo private static function criterionMatchesEvent(Criterion $criterion, Event $event): bool { return match ($criterion::class) { - EventTypesAndTagsCriterion::class => $event->tags->containEvery($criterion->tags) && $criterion->eventTypes->contain($event->type), - EventTypesCriterion::class => $criterion->eventTypes->contain($event->type), - TagsCriterion::class => $event->tags->containEvery($criterion->tags), + EventTypesAndTagsCriterion::class => ($criterion->tags === null || $event->tags->containEvery($criterion->tags)) && ($criterion->eventTypes === null || $criterion->eventTypes->contain($event->type)), default => throw new RuntimeException(sprintf('The criterion type "%s" is not supported by the %s', $criterion::class, self::class), 1700302540), }; } diff --git a/tests/Integration/EventStoreTestBase.php b/tests/Integration/EventStoreTestBase.php index 2402b2a..b92c79c 100644 --- a/tests/Integration/EventStoreTestBase.php +++ b/tests/Integration/EventStoreTestBase.php @@ -3,17 +3,17 @@ namespace Wwwision\DCBEventStore\Tests\Integration; +use Hoa\File\Read; use InvalidArgumentException; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\EventStream; use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\Types\EventEnvelopes; use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\ReadOptions; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\Criterion; use Wwwision\DCBEventStore\Types\Tag; use Wwwision\DCBEventStore\Types\Tags; @@ -53,8 +53,6 @@ #[CoversClass(AppendCondition::class)] #[CoversClass(EventMetadata::class)] #[CoversClass(Criteria::class)] -#[CoversClass(TagsCriterion::class)] -#[CoversClass(EventTypesCriterion::class)] #[CoversClass(EventTypesAndTagsCriterion::class)] abstract class EventStoreTestBase extends TestCase { @@ -100,7 +98,7 @@ public function test_read_returns_an_empty_stream_if_minimum_sequenceNumber_exce public function test_read_allows_filtering_of_events_by_tag(): void { $this->appendDummyEvents(); - $tagsCriterion = new TagsCriterion(Tags::fromArray(['baz:foos'])); + $tagsCriterion = EventTypesAndTagsCriterion::create(tags: ['baz:foos']); $query = StreamQuery::create(Criteria::create($tagsCriterion)); self::assertEventStream($this->stream($query), [ ['data' => 'a', 'criteria' => [$tagsCriterion]], @@ -122,8 +120,8 @@ public function test_read_allows_filtering_of_events_by_tags_disjunction(): void ['id' => 'g', 'tags' => ['baz:foos', 'foo:bar', 'foos:baz']], ['id' => 'h', 'tags' => ['baz:foosn', 'foo:notbar', 'foos:bar']], ]); - $tagsCriterion1 = new TagsCriterion(Tags::fromArray(['foo:bar'])); - $tagsCriterion2 = new TagsCriterion(Tags::fromArray(['baz:foos'])); + $tagsCriterion1 = EventTypesAndTagsCriterion::create(tags: ['foo:bar']); + $tagsCriterion2 = EventTypesAndTagsCriterion::create(tags: ['baz:foos']); $query = StreamQuery::create(Criteria::create($tagsCriterion1, $tagsCriterion2)); self::assertEventStream($this->stream($query), [ ['id' => 'a', 'criteria' => [$tagsCriterion1]], @@ -147,7 +145,7 @@ public function test_read_allows_filtering_of_events_by_tags_conjunction(): void ['id' => 'g', 'tags' => ['baz:foos', 'foo:bar', 'foos:baz']], ['id' => 'h', 'tags' => ['baz:foosn', 'foo:notbar', 'foos:bar']], ]); - $tagsCriterion = new TagsCriterion(Tags::fromArray(['foo:bar', 'baz:foos'])); + $tagsCriterion = EventTypesAndTagsCriterion::create(tags: ['foo:bar', 'baz:foos']); $query = StreamQuery::create(Criteria::create($tagsCriterion)); self::assertEventStream($this->stream($query), [ ['id' => 'b', 'criteria' => [$tagsCriterion]], @@ -156,10 +154,30 @@ public function test_read_allows_filtering_of_events_by_tags_conjunction(): void ]); } + public function test_read_allows_filtering_of_last_event_by_tag(): void + { + $this->appendEvents([ + ['id' => 'a', 'tags' => ['foo:bar']], + ['id' => 'b', 'tags' => ['foo:bar', 'baz:foos']], + ['id' => 'c', 'tags' => ['baz:foos', 'foo:bar']], + ['id' => 'd', 'tags' => ['baz:foos']], + ['id' => 'e', 'tags' => ['baz:foosnot']], + ['id' => 'f', 'tags' => ['foo:bar', 'baz:notfoos']], + ['id' => 'g', 'tags' => ['baz:foos', 'foo:bar', 'foos:baz']], + ['id' => 'h', 'tags' => ['baz:foosn', 'foo:notbar', 'foos:bar']], + ]); + $tagsCriterion = EventTypesAndTagsCriterion::create(tags: ['foo:bar', 'baz:foos'], onlyLastEvent: true); + $query = StreamQuery::create(Criteria::create($tagsCriterion)); + self::assertEventStream($this->stream($query), [ + ['id' => 'g', 'criteria' => [$tagsCriterion]], + ]); + } + + public function test_read_allows_filtering_of_events_by_event_types(): void { $this->appendDummyEvents(); - $eventTypesCriterion = new EventTypesCriterion(EventTypes::fromStrings('SomeEventType')); + $eventTypesCriterion = EventTypesAndTagsCriterion::create(eventTypes: ['SomeEventType']); $query = StreamQuery::create(Criteria::create($eventTypesCriterion)); self::assertEventStream($this->stream($query), [ ['data' => 'a', 'criteria' => [$eventTypesCriterion]], @@ -168,10 +186,20 @@ public function test_read_allows_filtering_of_events_by_event_types(): void ]); } + public function test_read_allows_filtering_of_last_event_by_event_types(): void + { + $this->appendDummyEvents(); + $eventTypesCriterion = EventTypesAndTagsCriterion::create(eventTypes: ['SomeEventType'], onlyLastEvent: true); + $query = StreamQuery::create(Criteria::create($eventTypesCriterion)); + self::assertEventStream($this->stream($query), [ + ['data' => 'e', 'criteria' => [$eventTypesCriterion]], + ]); + } + public function test_read_allows_filtering_of_events_by_tags_and_event_types(): void { $this->appendDummyEvents(); - $eventTypesAndTagsCriterion = new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))); + $eventTypesAndTagsCriterion = EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventType', tags: 'baz:foos'); $query = StreamQuery::create(Criteria::create($eventTypesAndTagsCriterion)); self::assertEventStream($this->stream($query), [ ['data' => 'a', 'criteria' => [$eventTypesAndTagsCriterion]], @@ -179,10 +207,20 @@ public function test_read_allows_filtering_of_events_by_tags_and_event_types(): ]); } + public function test_read_allows_filtering_of_last_event_by_tags_and_event_types(): void + { + $this->appendDummyEvents(); + $eventTypesAndTagsCriterion = EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventType', tags: 'baz:foos', onlyLastEvent: true); + $query = StreamQuery::create(Criteria::create($eventTypesAndTagsCriterion)); + self::assertEventStream($this->stream($query), [ + ['data' => 'e', 'criteria' => [$eventTypesAndTagsCriterion]], + ]); + } + public function test_read_allows_fetching_no_events(): void { $this->appendDummyEvents(); - $query = StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::fromStrings('NonExistingEventType')))); + $query = StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: ['NonExistingEventType']))); self::assertEventStream($this->stream($query), []); } @@ -232,7 +270,7 @@ public function test_read_backwards_returns_all_events_in_descending_order(): vo public function test_read_backwards_allows_to_specify_maximum_sequenceNumber(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), ReadOptions::create(backwards: true, from: SequenceNumber::fromInteger(4))), [ + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), ReadOptions::create(from: SequenceNumber::fromInteger(4), backwards: true)), [ ['id' => 'id-d', 'data' => 'd', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 4], ['id' => 'id-c', 'data' => 'c', 'type' => 'SomeEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 3], ['id' => 'id-b', 'data' => 'b', 'type' => 'SomeOtherEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 2], @@ -243,19 +281,51 @@ public function test_read_backwards_allows_to_specify_maximum_sequenceNumber(): public function test_read_backwards_returns_single_event_if_maximum_sequenceNumber_is_one(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), ReadOptions::create(backwards: true, from: SequenceNumber::fromInteger(1))), [ + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), ReadOptions::create(from: SequenceNumber::fromInteger(1), backwards: true)), [ ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], ]); } + public function test_read_options_dont_affect_matching_events(): void + { + $this->appendEvents([ + ['id' => 'a', 'type' => 'Type1', 'tags' => ['foo:bar']], + ['id' => 'b', 'type' => 'Type2', 'tags' => ['foo:bar', 'baz:foos']], + ['id' => 'c', 'type' => 'Type3', 'tags' => ['baz:foos', 'foo:bar']], + ['id' => 'd', 'type' => 'Type1', 'tags' => ['baz:foos']], + ['id' => 'e', 'type' => 'Type2', 'tags' => ['baz:foosnot']], + ['id' => 'f', 'type' => 'Type2', 'tags' => ['foo:bar', 'baz:notfoos']], + ['id' => 'g', 'type' => 'Type1', 'tags' => ['baz:foos', 'foo:bar', 'foos:baz']], + ['id' => 'h', 'type' => 'Type3', 'tags' => ['baz:foosn', 'foo:notbar', 'foos:bar']], + ]); + $criterion1 = EventTypesAndTagsCriterion::create(tags: ['foo:bar'], onlyLastEvent: true); + $criterion2 = EventTypesAndTagsCriterion::create(eventTypes: ['Type2', 'Type1'], tags: ['foo:bar']); + $query = StreamQuery::create(Criteria::create($criterion1, $criterion2)); + + /** @var EventEnvelopeShape[] $expectedEvents */ + $expectedEvents = [ + ['id' => 'a', 'criteria' => [$criterion2]], + ['id' => 'b', 'criteria' => [$criterion2]], + ['id' => 'f', 'criteria' => [$criterion2]], + ['id' => 'g', 'criteria' => [$criterion2, $criterion1]], + ]; + self::assertEventStream($this->stream($query), $expectedEvents); + + self::assertEventStream($this->stream($query, ReadOptions::create(backwards: true)), array_reverse($expectedEvents)); + self::assertEventStream($this->stream($query, ReadOptions::create(from: SequenceNumber::fromInteger(3))), array_slice($expectedEvents, 2)); + self::assertEventStream($this->stream($query, ReadOptions::create(from: SequenceNumber::fromInteger(3), backwards: true)), array_slice(array_reverse($expectedEvents), 2)); + } + public function test_append_appends_event_if_expectedHighestSequenceNumber_matches(): void { $this->appendDummyEvents(); - $eventTypesAndTagsCriterion = new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))); + $eventTypesAndTagsCriterion = EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventType', tags: 'baz:foos'); $query = StreamQuery::create(Criteria::create($eventTypesAndTagsCriterion)); $stream = $this->getEventStore()->read($query, ReadOptions::create(backwards: true)); - $lastSequenceNumber = $stream->first()->sequenceNumber; + $lastEvent = $stream->first(); + self::assertInstanceOf(EventEnvelope::class, $lastEvent); + $lastSequenceNumber = $lastEvent->sequenceNumber; $this->conditionalAppendEvent(['type' => 'SomeEventType', 'data' => 'new event', 'tags' => ['baz:foos']], $query, ExpectedHighestSequenceNumber::fromSequenceNumber($lastSequenceNumber)); self::assertEventStream($this->getEventStore()->read($query), [ @@ -269,9 +339,11 @@ public function test_append_fails_if_new_events_match_the_specified_query(): voi { $this->appendDummyEvents(); - $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); + $query = StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventType', tags: 'baz:foos'))); $stream = $this->getEventStore()->read($query, ReadOptions::create(backwards: true)); - $lastSequenceNumber = $stream->first()->sequenceNumber; + $lastEvent = $stream->first(); + self::assertInstanceOf(EventEnvelope::class, $lastEvent); + $lastSequenceNumber = $lastEvent->sequenceNumber; $this->appendEvent(['type' => 'SomeEventType', 'tags' => ['baz:foos']]); @@ -283,7 +355,7 @@ public function test_append_fails_if_no_last_event_id_was_expected_but_query_mat { $this->appendDummyEvents(); - $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); + $query = StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventType', tags: 'baz:foos'))); $this->expectException(ConditionalAppendFailed::class); $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedHighestSequenceNumber::none()); @@ -291,7 +363,7 @@ public function test_append_fails_if_no_last_event_id_was_expected_but_query_mat public function test_append_fails_if_last_event_id_was_expected_but_query_matches_no_events(): void { - $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventTypeThatDidNotOccur'), Tags::create(Tag::fromString('baz:foos'))))); + $query = StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: 'SomeEventTypeThatDidNotOccur', tags: 'baz:foos'))); $this->expectException(ConditionalAppendFailed::class); $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedHighestSequenceNumber::fromInteger(123)); @@ -304,15 +376,9 @@ final protected function streamAll(): EventStream return $this->getEventStore()->read(StreamQuery::wildcard()); } - final protected function parseQuery(string $query): StreamQuery + final protected function stream(StreamQuery $query, ReadOptions $options = null): EventStream { - $criteria = StreamQueryParser::parse($query); - return StreamQuery::create($criteria); - } - - final protected function stream(StreamQuery $query): EventStream - { - return $this->getEventStore()->read($query); + return $this->getEventStore()->read($query, $options); } final protected function appendDummyEvents(): void @@ -342,7 +408,7 @@ final protected function appendEvents(array $events): void } /** - * @phpstan-param EventShape $event + * @phpstan-param EventShape[] $events */ final protected function conditionalAppendEvents(array $events, StreamQuery $query, ExpectedHighestSequenceNumber $expectedHighestSequenceNumber): void { @@ -360,7 +426,7 @@ final protected function conditionalAppendEvent(array $event, StreamQuery $query /** * @phpstan-param array $expectedEvents */ - final protected static function assertEventStream(EventStream $eventStream, array $expectedEvents): void + final protected static function assertEventStream(EventStream|EventEnvelopes $eventStream, array $expectedEvents): void { $actualEvents = []; $index = 0; diff --git a/tests/Unit/Types/StreamQuery/StreamQuerySerializerTest.php b/tests/Unit/Types/StreamQuery/StreamQuerySerializerTest.php index d281085..db3ddea 100644 --- a/tests/Unit/Types/StreamQuery/StreamQuerySerializerTest.php +++ b/tests/Unit/Types/StreamQuery/StreamQuerySerializerTest.php @@ -5,25 +5,21 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Wwwision\DCBEventStore\Types\EventType; -use Wwwision\DCBEventStore\Types\EventTypes; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; -use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuerySerializer; -use Wwwision\DCBEventStore\Types\Tags; #[CoversClass(StreamQuerySerializer::class)] final class StreamQuerySerializerTest extends TestCase { public static function dataprovider_serialize(): iterable { - yield ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::single('foo', 'bar')))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"Tags","hash":"addb4f7ae3afe9ea5c8975ba330bf419","properties":{"tags":[{"key":"foo","value":"bar"}]}}]}']; - yield ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::single('SomeEventType')))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypes","hash":"ed73d849d917379fade8a8d4affeb1bd","properties":{"eventTypes":["SomeEventType"]}}]}']; - yield ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::create(EventType::fromString('SomeOtherEventType'), EventType::fromString('SomeEventType'))))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypes","hash":"a926cae309aa0cfc14ebbae0435c136f","properties":{"eventTypes":["SomeEventType","SomeOtherEventType"]}}]}']; - yield ['query' => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::single('SomeEventType'), Tags::single('foo', 'bar')))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"1d8d7779dae8565a4378ed69ff9677a4","properties":{"eventTypes":["SomeEventType"],"tags":[{"key":"foo","value":"bar"}]}}]}']; + yield ['query' => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(tags: ['foo:bar']))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"a2d4473e4dd478bf4bc84b6dc39eeed4","properties":{"tags":[{"key":"foo","value":"bar"}],"onlyLastEvent":false}}]}']; + yield ['query' => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: ['SomeEventType']))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"4e7de6454d2d1802ab1a89addb4e8faf","properties":{"eventTypes":["SomeEventType"],"onlyLastEvent":false}}]}']; + yield ['query' => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: ['SomeOtherEventType', 'SomeEventType']))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"e4f666fc292f9ead35ac959d3a43fcf2","properties":{"eventTypes":["SomeEventType","SomeOtherEventType"],"onlyLastEvent":false}}]}']; + yield ['query' => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: ['SomeEventType', 'SomeOtherEventType'], tags: ['foo:bar'], onlyLastEvent: false))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"c390815d2e7cffcd1a675817f89fb33c","properties":{"eventTypes":["SomeEventType","SomeOtherEventType"],"tags":[{"key":"foo","value":"bar"}],"onlyLastEvent":false}}]}']; + yield ['query' => StreamQuery::create(Criteria::create(EventTypesAndTagsCriterion::create(eventTypes: ['SomeEventType'], tags: ['foo:bar', 'baz:foos'], onlyLastEvent: true))), 'expectedResult' => '{"version":"1.0","criteria":[{"type":"EventTypesAndTags","hash":"af11a6a8247319ce67ca292b17d0bd06","properties":{"eventTypes":["SomeEventType"],"tags":[{"key":"baz","value":"foos"},{"key":"foo","value":"bar"}],"onlyLastEvent":true}}]}']; } /**