diff --git a/Specification.md b/Specification.md new file mode 100644 index 0000000..0ca8264 --- /dev/null +++ b/Specification.md @@ -0,0 +1,181 @@ +## EventStore + +### Reading + +Expects a [StreamQuery](#StreamQuery) and an optional starting [SequenceNumber](#SequenceNumber) and returns an [EventStream](#EventStream). + +**Note:** The EventStore should also allow for _backwards_ iteration on the EventStream in order to support cursor based pagination. + +### Writing + +Expects a set of [Events](#Events) and an [AppendCondition](#AppendCondition) and returns the last appended [SequenceNumber](#SequenceNumber). + +#### Potential API + +``` +EventStore { + + read(query: StreamQuery, from?: SequenceNumber): EventStream + + readBackwards(query: StreamQuery, from?: SequenceNumber): EventStream + + append(events: Events, condition: AppendCondition): SequenceNumber + +} +``` + +## StreamQuery + +The `StreamQuery` describes constraints that must be matched by [Event](#Event)s in the [EventStore](#EventStore). + +* It _MAY_ contain a set of [StreamQuery Criteria](#StreamQuery-Criterion) + +**Note:** All criteria of a StreamQuery are merged into a *logical disjunction*, so the example below matches all events, that match the first **OR** the second criterion. + +#### Potential serialization format + +```json +{ + "version": "1.0", + "criteria": [{ + "type": "EventTypes", + "properties": { + "event_types": ["EventType1", "EventType2"] + } + }, { + "type": "Tags", + "properties": { + "tags": ["foo:bar", "baz:foos"], + } + }, { + "type": "EventTypesAndTags", + "properties": { + "event_types": ["EventType2", "EventType3"], + "tags": ["foo:bar", "foo:baz"], + } + }] +} +``` + + +## StreamQuery Criterion + +In v1 the only supported criteria types are: + +* `Tags` – allows to target one or more [Tags](#Tags) +* `EventTypes` – allows to target one or more [EventType](#EventType)s +* `EventTypesAndTags` – allows to target one or more [Tags](#Tags) and one or more [EventType](#EventType)s + +## SequenceNumber + +When an [Event](#Eventd) is appended to the [EventStore](#EventStore) a `SequenceNumber` is assigned to it. + +It... +* _MUST_ be unique for one EventStore +* _MUST_ be monotonic increasing by `1` +* _MUST_ have an allowed minimum value of `1` +* _SHOULD_ have a reasonably high maximum value (depending on programming language and environment) + + +## EventStream + +When reading from the [EventStore](#EventStore) an `EventStream` is returned. + +It... +* It _MUST_ be iterable +* It _MUST_ return an [EventEnvelope](#EventEnvelope) for every iteration +* It _MUST_ include new events if they occur during iteration (i.e. it should be a stream, not a fixed set) +* Individual [EventEnvelope](#EventEnvelope) instances _MAY_ be converted during iteration for performance optimization +* Batches of events _MAY_ be loaded from the underlying storage at once for performance optimization + +## EventEnvelope + +Each item in the [EventStream](#EventStream) is an `EventEnvelope` that consists of the underlying event and metadata, like the [SequenceNumber](#SequenceNumber) that was added during the `append()` call. + +```json +{ + "event": { + "id": "15aaa216-4179-46d9-999a-75516e21a1c6", + "type": "SomeEventType", + "data": "{\"some\":\"data\"}" + "tags": ["type1:value1", "type2:value2"] + }, + "sequence_number": 1234 +} +``` + +## Events + +A set of [Event](#Event) instances that is passed to the `append()` method of the [EventStore](#EventStore) + +It... +* _MUST_ not be empty +* _MUST_ be iterable, each iteration returning an [Event](#Event) + +## Event + +* It _MUST_ contain a globally unique [EventId](#EventId) +* It _MUST_ contain an [EventType](#EventType) +* It _MUST_ contain [EventData](#EventData) +* It _MAY_ contain [Tags](#Tags) + +#### Potential serialization format + +```json +{ + "id": "15aaa216-4179-46d9-999a-75516e21a1c6", + "type": "SomeEventType", + "data": "{\"some\":\"data\"}" + "tags": ["key1:value1", "key1:value2"] +} +``` + +## EventId + +String based globally unique identifier of an [Event](#Event) + +* It _MUST_ satisfy the regular expression `^[\w\-]{1,100}$` +* It _MAY_ be implemented as a [UUID](https://www.ietf.org/rfc/rfc4122.txt) + +## EventType + +String based type of an event + +* It _MUST_ satisfy the regular expression `^[\w\.\:\-]{1,200}$` + +## EventData + +String based, opaque payload of an [Event](#Event) + +* It _SHOULD_ have a reasonable large enough maximum length (depending on language and environment) +* It _MAY_ contain [JSON](https://www.json.org/) +* It _MAY_ be serialized into an empty string + +## Tags + +A set of [Tag](#Tag) instances. + +* It _MUST_ contain at least one [Tag](#Tag) +* It _MAY_ contain multiple [Tag](#Tag)s with the same value +* It _SHOULD_ not contain muliple [Tag](#Tag)s with the same key/value pair + +## Tag + +A `Tag` can add domain specific metadata (usually the ids of an entity or concept of the core domain) to an event allowing for custom partitioning + +**NOTE:** If the `value` is not specified, all tags of the given `key` will match (wildcard) + +* It _MUST_ contain a `key` that satisfies the regular expression `^[[:alnum:]\-\_]{1,50}$` +* It _CAN_ contain a `value` that satisfies the regular expression `^[[:alnum:]\-\_]{1,50}$` + +## AppendCondition + +* It _MUST_ contain a [StreamQuery](#StreamQuery) +* It _MUST_ contain a [ExpectedHighestSequenceNumber](#ExpectedHighestSequenceNumber) + +## ExpectedHighestSequenceNumber + +Can _either_ be an instance of [SequenceNumber](#SequenceNumber) +Or one of: +* `NONE` – No event must match the specified [StreamQuery](#StreamQuery) +* `ANY` – Any event matches (= wildcard [AppendCondition](#AppendCondition)) \ No newline at end of file diff --git a/composer.json b/composer.json index b183f1a..dadb26a 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "require": { "php": ">=8.2", "ramsey/uuid": "^4.7", + "psr/clock": "^1", "webmozart/assert": "^1.11" }, "require-dev": { diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..cd33074 --- /dev/null +++ b/schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$ref": "#/$defs/AppendCondition", + "$defs": { + "AppendCondition": { + "type": "object", + "additionalProperties": false, + "properties": { + "query": { + "$ref": "#/$defs/StreamQuery" + }, + "expected_highest_sequence_number": { + "oneOf": [ + { + "const": "NONE" + }, + { + "const": "ANY" + }, + { + "$ref": "#/$defs/SequenceNumber" + } + ] + } + }, + "required": [ + "query", + "expected_highest_sequence_number" + ] + }, + "StreamQuery": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+$" + }, + "criterias": { + "type": "array", + "items": { + "$ref": "#/$defs/StreamQueryCriteria" + } + } + }, + "required": [ + "version", + "criterias" + ] + }, + "StreamQueryCriteria": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "EventTypesAndTags", + "EventTypes", + "Tags" + ] + }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/Tag" + } + }, + "event_types": { + "type": "array", + "items": { + "$ref": "#/$defs/EventType" + } + } + }, + "required": [ + "tags", + "event_types" + ] + } + }, + "required": [ + "type", + "criteria" + ] + }, + "EventEnvelope": { + "type": "object", + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/$defs/Event" + }, + "sequence_number": { + "$ref": "#/$defs/SequenceNumber" + } + }, + "required": [ + "event", + "sequence_number" + ] + }, + "Event": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/EventId" + }, + "type": { + "$ref": "#/$defs/EventType" + }, + "data": { + "$ref": "#/$defs/EventData" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/Tag" + } + } + }, + "required": [ + "id", + "type", + "data", + "tags" + ] + }, + "EventId": { + "type": "string", + "pattern": "^[\\w\\-]+$", + "minLength": 1, + "maxLength": 100 + }, + "EventType": { + "type": "string", + "pattern": "^[\\w\\.\\:\\-]+$", + "minLength": 1, + "maxLength": 200 + }, + "EventData": { + "type": "string" + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Za-z0-9\\-\\_]+$" + }, + "value": { + "minLength": 1, + "maxLength": 50, + "type": "string", + "pattern": "^[A-Za-z0-9\\-\\_]+$" + } + }, + "required": [ + "key", + "value" + ] + }, + "SequenceNumber": { + "type": "integer", + "minimum": 1 + } + } +} \ No newline at end of file diff --git a/src/EventNormalizer.php b/src/EventNormalizer.php deleted file mode 100644 index 6f63af6..0000000 --- a/src/EventNormalizer.php +++ /dev/null @@ -1,19 +0,0 @@ -isNone()}, _no_ event must match the specified $query + * NOTE: This is an atomic operation, so either _all_ events will be committed or _none_ * - * @param ExpectedLastEventId $expectedLastEventId If not NONE, the last event matching the given $query has to match. Otherwise, _no_ event must match the specified $query + * @param Events $events The events to append to the event stream + * @param AppendCondition $condition The condition that has to be met * @throws ConditionalAppendFailed If specified $query and $expectedLastEventId don't match */ - public function conditionalAppend(Events $events, StreamQuery $query, ExpectedLastEventId $expectedLastEventId): void; + public function append(Events $events, AppendCondition $condition): void; } diff --git a/src/EventStream.php b/src/EventStream.php index d3505f6..7e1501c 100644 --- a/src/EventStream.php +++ b/src/EventStream.php @@ -6,38 +6,19 @@ use IteratorAggregate; use Traversable; -use Wwwision\DCBEventStore\Helper\BatchEventStream; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\SequenceNumber; +use Wwwision\DCBEventStore\Types\EventEnvelope; /** - * Contract for an event stream returned by {@see EventStore::stream()} + * Contract for an event stream returned by {@see EventStore::read()} * * @extends IteratorAggregate */ interface EventStream extends IteratorAggregate { - /** - * Limits the stream to events with the specified $sequenceNumber or a higher one - * - * This method can be used to batch-process events {@see BatchEventStream} - */ - public function withMinimumSequenceNumber(SequenceNumber $sequenceNumber): self; - - /** - * Limits the stream to the specified amount of events - * - * This method can be used to batch-process events {@see BatchEventStream} - */ - public function limit(int $limit): self; - - /** - * Returns the last event envelope of that stream, or NULL if the stream is empty - */ - public function last(): ?EventEnvelope; - /** * @return Traversable */ public function getIterator(): Traversable; + + public function first(): ?EventEnvelope; } diff --git a/src/Exception/ConditionalAppendFailed.php b/src/Exception/ConditionalAppendFailed.php deleted file mode 100644 index d62731c..0000000 --- a/src/Exception/ConditionalAppendFailed.php +++ /dev/null @@ -1,35 +0,0 @@ -value\""); - } -} diff --git a/src/Exceptions/ConditionalAppendFailed.php b/src/Exceptions/ConditionalAppendFailed.php new file mode 100644 index 0000000..d9f9688 --- /dev/null +++ b/src/Exceptions/ConditionalAppendFailed.php @@ -0,0 +1,36 @@ +value\""); + } +} diff --git a/src/Helper/BatchEventStream.php b/src/Helper/BatchEventStream.php deleted file mode 100644 index 209c462..0000000 --- a/src/Helper/BatchEventStream.php +++ /dev/null @@ -1,79 +0,0 @@ -minimumSequenceNumber !== null && $sequenceNumber->equals($this->minimumSequenceNumber)) { - return $this; - } - return new self($this->wrappedEventStream, $this->batchSize, $sequenceNumber, $this->limit); - } - - public function limit(int $limit): self - { - if ($limit === $this->limit) { - return $this; - } - return new self($this->wrappedEventStream, $this->batchSize, $this->minimumSequenceNumber, $limit); - } - - public function last(): ?EventEnvelope - { - return $this->wrappedEventStream->last(); - } - - public function getIterator(): Traversable - { - $this->wrappedEventStream = $this->wrappedEventStream->limit($this->batchSize); - if ($this->minimumSequenceNumber !== null) { - $this->wrappedEventStream = $this->wrappedEventStream->withMinimumSequenceNumber($this->minimumSequenceNumber); - } - $iteration = 0; - do { - $eventEnvelope = null; - foreach ($this->wrappedEventStream as $eventEnvelope) { - yield $eventEnvelope; - $iteration++; - if ($this->limit !== null && $iteration >= $this->limit) { - return; - } - } - if ($eventEnvelope === null) { - return; - } - $this->wrappedEventStream = $this->wrappedEventStream->withMinimumSequenceNumber($eventEnvelope->sequenceNumber->next()); - } while (true); - } -} diff --git a/src/Helper/InMemoryEventStore.php b/src/Helper/InMemoryEventStore.php deleted file mode 100644 index b9faade..0000000 --- a/src/Helper/InMemoryEventStore.php +++ /dev/null @@ -1,86 +0,0 @@ -append($events); - * - * $inMemoryStream = $eventStore->stream($query); - */ -final class InMemoryEventStore implements EventStore -{ - /** - * @var EventEnvelope[] - */ - private array $eventEnvelopes = []; - - private function __construct() - { - } - - public static function create(): self - { - return new self(); - } - - public function setup(): void - { - // In-memory event store does not need any setup - } - - public function streamAll(): EventStream - { - return InMemoryEventStream::create(...$this->eventEnvelopes); - } - - public function stream(StreamQuery $query): InMemoryEventStream - { - return InMemoryEventStream::create(...array_filter($this->eventEnvelopes, static fn (EventEnvelope $eventEnvelope) => $query->matches($eventEnvelope->event))); - } - - public function conditionalAppend(Events $events, StreamQuery $query, ExpectedLastEventId $expectedLastEventId): void - { - $lastEvent = $this->stream($query)->last(); - if ($lastEvent === null) { - if (!$expectedLastEventId->isNone()) { - throw ConditionalAppendFailed::becauseNoEventMatchedTheQuery(); - } - } elseif ($expectedLastEventId->isNone()) { - throw ConditionalAppendFailed::becauseNoEventWhereExpected(); - } elseif (!$expectedLastEventId->matches($lastEvent->event->id)) { - throw ConditionalAppendFailed::becauseEventIdsDontMatch($expectedLastEventId, $lastEvent->event->id); - } - $this->append($events); - } - - public function append(Events $events): void - { - $sequenceNumber = SequenceNumber::fromInteger(count($this->eventEnvelopes)); - foreach ($events as $event) { - $sequenceNumber = $sequenceNumber->next(); - $this->eventEnvelopes[] = new EventEnvelope( - $sequenceNumber, - $event, - ); - } - } -} diff --git a/src/Helper/InMemoryEventStream.php b/src/Helper/InMemoryEventStream.php deleted file mode 100644 index dc6ccec..0000000 --- a/src/Helper/InMemoryEventStream.php +++ /dev/null @@ -1,80 +0,0 @@ -minimumSequenceNumber !== null && $sequenceNumber->equals($this->minimumSequenceNumber)) { - return $this; - } - return new self($this->events, $sequenceNumber, $this->limit); - } - - public function limit(int $limit): self - { - if ($limit === $this->limit) { - return $this; - } - return new self($this->events, $this->minimumSequenceNumber, $limit); - } - - public function last(): ?EventEnvelope - { - if ($this->events === []) { - return null; - } - return $this->events[array_key_last($this->events)]; - } - - public function getIterator(): Traversable - { - $iteration = 0; - foreach ($this->events as $eventEnvelope) { - if ($this->minimumSequenceNumber !== null && $eventEnvelope->sequenceNumber->value < $this->minimumSequenceNumber->value) { - continue; - } - yield $eventEnvelope; - $iteration++; - if ($this->limit !== null && $iteration >= $this->limit) { - return; - } - } - } -} diff --git a/src/Helpers/InMemoryEventStore.php b/src/Helpers/InMemoryEventStore.php new file mode 100644 index 0000000..cb4c442 --- /dev/null +++ b/src/Helpers/InMemoryEventStore.php @@ -0,0 +1,91 @@ +append($events); + * + * $inMemoryStream = $eventStore->stream($query); + */ +final class InMemoryEventStore implements EventStore +{ + /** + * @var EventEnvelope[] + */ + private array $eventEnvelopes = []; + + private function __construct() + { + } + + public static function create(): self + { + return new self(); + } + + public function read(StreamQuery $query, ?SequenceNumber $from = null): InMemoryEventStream + { + $matchingEventEnvelopes = $this->eventEnvelopes; + if ($from !== null) { + $matchingEventEnvelopes = array_filter($matchingEventEnvelopes, static fn (EventEnvelope $eventEnvelope) => $eventEnvelope->sequenceNumber->value >= $from->value); + } + if (!$query->isWildcard()) { + $matchingEventEnvelopes = array_filter($matchingEventEnvelopes, static fn (EventEnvelope $eventEnvelope) => $query->matches($eventEnvelope->event)); + } + return InMemoryEventStream::create(...$matchingEventEnvelopes); + } + + public function readBackwards(StreamQuery $query, ?SequenceNumber $from = null): InMemoryEventStream + { + $matchingEventEnvelopes = array_reverse($this->eventEnvelopes); + if ($from !== null) { + $matchingEventEnvelopes = array_filter($matchingEventEnvelopes, static fn (EventEnvelope $eventEnvelope) => $eventEnvelope->sequenceNumber->value <= $from->value); + } + if (!$query->isWildcard()) { + $matchingEventEnvelopes = array_filter($matchingEventEnvelopes, static fn (EventEnvelope $eventEnvelope) => $query->matches($eventEnvelope->event)); + } + return InMemoryEventStream::create(...$matchingEventEnvelopes); + } + + public function append(Events $events, AppendCondition $condition): void + { + if (!$condition->expectedHighestSequenceNumber->isAny()) { + $lastEventEnvelope = $this->readBackwards($condition->query)->first(); + if ($lastEventEnvelope === null) { + if (!$condition->expectedHighestSequenceNumber->isNone()) { + throw ConditionalAppendFailed::becauseNoEventMatchedTheQuery($condition->expectedHighestSequenceNumber); + } + } elseif ($condition->expectedHighestSequenceNumber->isNone()) { + throw ConditionalAppendFailed::becauseNoEventWhereExpected(); + } elseif (!$condition->expectedHighestSequenceNumber->matches($lastEventEnvelope->sequenceNumber)) { + throw ConditionalAppendFailed::becauseHighestExpectedSequenceNumberDoesNotMatch($condition->expectedHighestSequenceNumber, $lastEventEnvelope->sequenceNumber); + } + } + $sequenceNumber = count($this->eventEnvelopes); + foreach ($events as $event) { + $sequenceNumber++; + $this->eventEnvelopes[] = new EventEnvelope( + SequenceNumber::fromInteger($sequenceNumber), + $event, + ); + } + } +} diff --git a/src/Helpers/InMemoryEventStream.php b/src/Helpers/InMemoryEventStream.php new file mode 100644 index 0000000..7517068 --- /dev/null +++ b/src/Helpers/InMemoryEventStream.php @@ -0,0 +1,48 @@ +eventEnvelopes as $eventEnvelope) { + yield $eventEnvelope; + } + } + + public function first(): ?EventEnvelope + { + return $this->eventEnvelopes[array_key_first($this->eventEnvelopes)] ?? null; + } +} diff --git a/src/Helpers/SystemClock.php b/src/Helpers/SystemClock.php new file mode 100644 index 0000000..1afe2c4 --- /dev/null +++ b/src/Helpers/SystemClock.php @@ -0,0 +1,17 @@ +customerId); - * } - * } - */ -interface DomainEvent -{ - /** - * The Domain Ids that are affected by this Domain Ids - */ - public function domainIds(): DomainIds; -} diff --git a/src/Model/DomainEvents.php b/src/Model/DomainEvents.php deleted file mode 100644 index c46276e..0000000 --- a/src/Model/DomainEvents.php +++ /dev/null @@ -1,66 +0,0 @@ - - */ -final readonly class DomainEvents implements IteratorAggregate -{ - /** - * @var DomainEvent[] - */ - private array $domainEvents; - - private function __construct(DomainEvent ...$domainEvents) - { - $this->domainEvents = $domainEvents; - } - - public static function none(): self - { - return new self(); - } - - public static function single(DomainEvent $domainEvent): self - { - return new self($domainEvent); - } - - /** - * @param DomainEvent[] $domainEvents - */ - public static function fromArray(array $domainEvents): self - { - return new self(...$domainEvents); - } - - public function isEmpty(): bool - { - return $this->domainEvents === []; - } - - public function getIterator(): Traversable - { - return new ArrayIterator($this->domainEvents); - } - - public function append(DomainEvent|self $domainEvents): self - { - if ($domainEvents instanceof DomainEvent) { - $domainEvents = self::fromArray([$domainEvents]); - } - if ($domainEvents->isEmpty()) { - return $this; - } - return self::fromArray([...$this->domainEvents, ...$domainEvents]); - } -} diff --git a/src/Model/DomainId.php b/src/Model/DomainId.php deleted file mode 100644 index a55a69b..0000000 --- a/src/Model/DomainId.php +++ /dev/null @@ -1,15 +0,0 @@ -> $ids - */ - private function __construct(private array $ids) - { - Assert::notEmpty($this->ids, 'DomainIds must not be empty'); - } - - public static function single(string $key, string $value): self - { - return new self([[$key => $value]]); - } - - /** - * @param array|array> $ids - */ - public static function fromArray(array $ids): self - { - $convertedIds = []; - foreach ($ids as $keyAndValuePair) { - if ($keyAndValuePair instanceof DomainId) { - $keyAndValuePair = [$keyAndValuePair->key() => $keyAndValuePair->value()]; - } - Assert::isArray($keyAndValuePair); - if (in_array($keyAndValuePair, $convertedIds, true)) { - continue; - } - $key = key($keyAndValuePair); - Assert::string($key, 'domain ids only accepts keys of type string, given: %s'); - Assert::regex($key, '/^[a-z][a-zA-Z0-9_-]{0,19}$/', 'domain id keys must start with a lower case character, only contain alphanumeric characters, underscores and dashes and must not be longer than 20 characters, given: %s'); - $value = $keyAndValuePair[$key]; - Assert::string($value, 'domain ids only accepts values of type string, given: %s'); - Assert::regex($value, '/^[a-z][a-zA-Z0-9_-]{0,19}$/', 'domain id keys must start with a lower case character, only contain alphanumeric characters, underscores and dashes and must not be longer than 20 characters, given: %s'); - $convertedIds[] = [$key => $value]; - } - usort($convertedIds, static fn(array $id1, array $id2) => strcasecmp(key($id1), key($id2))); - return new self($convertedIds); - } - - public static function fromJson(string $json): self - { - $domainIds = json_decode($json, true); - Assert::isArray($domainIds, 'Failed to decode JSON to domain ids array'); - return self::fromArray($domainIds); - } - - public static function create(DomainId ...$ids): self - { - return self::fromArray($ids); - } - - public function merge(self|DomainId $other): self - { - if ($other instanceof DomainId) { - $other = self::create($other); - } - if ($other->equals($this)) { - return $this; - } - return self::fromArray(array_merge($this->ids, $other->ids)); - } - - public function contains(DomainId|string $key, string $value = null): bool - { - if ($key instanceof DomainId) { - $value = $key->value(); - $key = $key->key(); - } - Assert::notNull($value, 'contains() can be called with an instance of DomainId or $key and $value, but $value was not specified'); - return in_array([$key => $value], $this->ids, true); - } - - public function intersects(self|DomainId $other): bool - { - if ($other instanceof DomainId) { - $other = self::create($other); - } - foreach ($other->ids as $keyValuePair) { - $key = (string)key($keyValuePair); - $value = $keyValuePair[$key]; - if ($this->contains($key, $value)) { - return true; - } - } - return false; - } - - public function equals(self $other): bool - { - return $this->ids === $other->ids; - } - - /** - * @return array> - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * @return array> - */ - public function toArray(): array - { - return array_values($this->ids); - } -} diff --git a/src/Model/Event.php b/src/Model/Event.php deleted file mode 100644 index afa8241..0000000 --- a/src/Model/Event.php +++ /dev/null @@ -1,19 +0,0 @@ -value); - } - - public static function fromString(string $value): self - { - return new self($value); - } - - public function isNone(): bool - { - return $this->value === null; - } - - public function eventId(): EventId - { - if ($this->value === null) { - throw new RuntimeException('Failed to extract Event Id from ExpectedLastEventId[none]', 1686747562); - } - return EventId::fromString($this->value); - } - - public function matches(EventId $eventId): bool - { - return $this->value === $eventId->value; - } - - public function __toString(): string - { - return $this->value ?? '[NONE]'; - } -} diff --git a/src/Model/StreamQuery.php b/src/Model/StreamQuery.php deleted file mode 100644 index 9206eb0..0000000 --- a/src/Model/StreamQuery.php +++ /dev/null @@ -1,58 +0,0 @@ -domainIds !== null && !$this->domainIds->intersects($event->domainIds)) { - return false; - } - if ($this->types !== null && !$this->types->contains($event->type)) { - return false; - } - return true; - } -} diff --git a/src/Setupable.php b/src/Setupable.php new file mode 100644 index 0000000..a3914ff --- /dev/null +++ b/src/Setupable.php @@ -0,0 +1,16 @@ +value === $this->value; + } + public function jsonSerialize(): string { return $this->value; diff --git a/src/Model/EventTypes.php b/src/Types/EventTypes.php similarity index 82% rename from src/Model/EventTypes.php rename to src/Types/EventTypes.php index 6a12a4a..5b22b46 100644 --- a/src/Model/EventTypes.php +++ b/src/Types/EventTypes.php @@ -2,21 +2,23 @@ declare(strict_types=1); -namespace Wwwision\DCBEventStore\Model; +namespace Wwwision\DCBEventStore\Types; use ArrayIterator; use IteratorAggregate; +use JsonSerializable; use Traversable; use Webmozart\Assert\Assert; use function array_map; +use function array_values; /** * A type-safe set of {@see EventType} instances * * @implements IteratorAggregate */ -final class EventTypes implements IteratorAggregate +final class EventTypes implements IteratorAggregate, JsonSerializable { /** * @param EventType[] $types @@ -45,7 +47,7 @@ public static function single(string|EventType $type): self return self::create($type); } - public function contains(EventType $type): bool + public function contain(EventType $type): bool { foreach ($this->types as $typesInSet) { if ($typesInSet->value === $type->value) { @@ -75,4 +77,12 @@ public function merge(self $other): self } return new self(array_merge($this->types, $other->types)); } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_values($this->types); + } } diff --git a/src/Model/Events.php b/src/Types/Events.php similarity index 89% rename from src/Model/Events.php rename to src/Types/Events.php index 0e5b6ba..2ef6c5c 100644 --- a/src/Model/Events.php +++ b/src/Types/Events.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBEventStore\Model; +namespace Wwwision\DCBEventStore\Types; use ArrayIterator; use Closure; @@ -30,9 +30,9 @@ private function __construct(Event ...$events) $this->events = $events; } - public static function single(EventId $id, EventType $type, EventData $data, DomainIds $domainIds): self + public static function single(EventId $id, EventType $type, EventData $data, Tags $tags): self { - return new self(new Event($id, $type, $data, $domainIds)); + return new self(new Event($id, $type, $data, $tags)); } /** @@ -54,6 +54,7 @@ public function getIterator(): Traversable } /** + * @param Closure(Event $event): mixed $callback * @return array */ public function map(Closure $callback): array diff --git a/src/Types/ExpectedHighestSequenceNumber.php b/src/Types/ExpectedHighestSequenceNumber.php new file mode 100644 index 0000000..476bec9 --- /dev/null +++ b/src/Types/ExpectedHighestSequenceNumber.php @@ -0,0 +1,71 @@ +sequenceNumber === StreamState::NONE; + } + + public function isAny(): bool + { + return $this->sequenceNumber === StreamState::ANY; + } + + public function extractSequenceNumber(): SequenceNumber + { + if (!$this->sequenceNumber instanceof SequenceNumber) { + throw new RuntimeException(sprintf('Failed to extract Sequence number from %s', $this), 1686747562); + } + return $this->sequenceNumber; + } + + public function matches(SequenceNumber $sequenceNumber): bool + { + return $this->sequenceNumber instanceof SequenceNumber && $this->sequenceNumber->value >= $sequenceNumber->value; + } + + public function __toString(): string + { + return $this->sequenceNumber instanceof StreamState ? '[' . $this->sequenceNumber->name . ']' : (string)$this->sequenceNumber->value; + } +} diff --git a/src/Model/SequenceNumber.php b/src/Types/SequenceNumber.php similarity index 71% rename from src/Model/SequenceNumber.php rename to src/Types/SequenceNumber.php index 8bd3623..8410437 100644 --- a/src/Model/SequenceNumber.php +++ b/src/Types/SequenceNumber.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Wwwision\DCBEventStore\Model; +namespace Wwwision\DCBEventStore\Types; use Webmozart\Assert\Assert; /** - * The global sequence number of an event in the Event Store + * The global sequence number of an event in the Events Store * * Note: The sequence number is usually not referred to in user land code, but it can be used to batch process an event stream for example */ @@ -16,7 +16,7 @@ private function __construct( public int $value ) { - Assert::natural($this->value, 'sequence number has to be a non-negative integer, given: %d'); + Assert::greaterThanEq($this->value, 1, 'sequence number has to be represented with a positive integer of at least 1, given: %d'); } public static function fromInteger(int $value): self @@ -24,11 +24,6 @@ public static function fromInteger(int $value): self return new self($value); } - public static function none(): self - { - return new self(0); - } - public function previous(): self { return new self($this->value - 1); diff --git a/src/Types/StreamQuery/Criteria.php b/src/Types/StreamQuery/Criteria.php new file mode 100644 index 0000000..48370a7 --- /dev/null +++ b/src/Types/StreamQuery/Criteria.php @@ -0,0 +1,71 @@ + + */ +final readonly class Criteria implements IteratorAggregate, JsonSerializable +{ + /** + * @var Criterion[] + */ + private array $criteria; + + private function __construct(Criterion ...$criteria) + { + $this->criteria = $criteria; + } + + /** + * @param Criterion[] $criteria + */ + public static function fromArray(array $criteria): self + { + return new self(...$criteria); + } + + public static function create(Criterion ...$criteria): self + { + return new self(...$criteria); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->criteria); + } + + public function isEmpty(): bool + { + return $this->criteria === []; + } + + /** + * @param Closure(Criterion $criterion): mixed $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->criteria); + } + + /** + * @return Criterion[] + */ + public function jsonSerialize(): array + { + return $this->criteria; + } +} diff --git a/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php b/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php new file mode 100644 index 0000000..9ebbe73 --- /dev/null +++ b/src/Types/StreamQuery/Criteria/EventTypesAndTagsCriterion.php @@ -0,0 +1,24 @@ +eventTypes->contain($event->type) && $this->tags->intersect($event->tags); + } +} diff --git a/src/Types/StreamQuery/Criteria/EventTypesCriterion.php b/src/Types/StreamQuery/Criteria/EventTypesCriterion.php new file mode 100644 index 0000000..84b3a2c --- /dev/null +++ b/src/Types/StreamQuery/Criteria/EventTypesCriterion.php @@ -0,0 +1,22 @@ +eventTypes->contain($event->type); + } +} diff --git a/src/Types/StreamQuery/Criteria/TagsCriterion.php b/src/Types/StreamQuery/Criteria/TagsCriterion.php new file mode 100644 index 0000000..d7e3081 --- /dev/null +++ b/src/Types/StreamQuery/Criteria/TagsCriterion.php @@ -0,0 +1,22 @@ +tags->intersect($event->tags); + } +} diff --git a/src/Types/StreamQuery/Criterion.php b/src/Types/StreamQuery/Criterion.php new file mode 100644 index 0000000..877c89c --- /dev/null +++ b/src/Types/StreamQuery/Criterion.php @@ -0,0 +1,17 @@ +criteria as $criterion) { + if ($criterion->matches($event)) { + return true; + } + } + return false; + } + + public function isWildcard(): bool + { + return $this->criteria->isEmpty(); + } +} diff --git a/src/Types/StreamQuery/StreamQuerySerializer.php b/src/Types/StreamQuery/StreamQuerySerializer.php new file mode 100644 index 0000000..b75e290 --- /dev/null +++ b/src/Types/StreamQuery/StreamQuerySerializer.php @@ -0,0 +1,116 @@ + StreamQuery::VERSION, + 'criteria' => array_map(self::serializeCriterion(...), iterator_to_array($streamQuery->criteria)), + ]; + try { + return json_encode($array, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + } catch (JsonException $e) { + throw new InvalidArgumentException(sprintf('Failed to serialize StreamQuery: %s', $e->getMessage()), 1687970471, $e); + } + } + + public static function unserialize(string $string): StreamQuery + { + try { + $array = json_decode($string, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException(sprintf('Failed to unserialize StreamQuery: %s', $e->getMessage()), 1687970501, $e); + } + Assert::isArray($array); + Assert::keyExists($array, 'criteria'); + Assert::keyExists($array, 'version'); + Assert::eq($array['version'], StreamQuery::VERSION, 'Expected a version equal to %2$s. Got: %s'); + Assert::isArray($array['criteria']); + return StreamQuery::create(Criteria::fromArray(array_map(self::unserializeCriterion(...), $array['criteria']))); + } + + /** + * @param Criterion $criterion + * @return array{type: string, properties: array} + */ + private static function serializeCriterion(Criterion $criterion): array + { + return [ + 'type' => substr(substr($criterion::class, 0, -9), strrpos($criterion::class, '\\') + 1), + 'properties' => get_object_vars($criterion), + ]; + } + + /** + * @param array $criterion + */ + private static function unserializeCriterion(array $criterion): Criterion + { + Assert::keyExists($criterion, 'type'); + Assert::string($criterion['type']); + /** @var class-string $criterionClassName */ + $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), + }; + } + + /** + * @param array $properties + */ + 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'])); + } +} diff --git a/src/Types/StreamState.php b/src/Types/StreamState.php new file mode 100644 index 0000000..126fa5d --- /dev/null +++ b/src/Types/StreamState.php @@ -0,0 +1,11 @@ +|string $value + * @return self + */ + public static function parse(array|string $value): self + { + if (is_string($value)) { + return self::fromString($value); + } + return self::fromArray($value); + } + + public static function fromString(string $string): self + { + Assert::contains($string, ':'); + [$key, $value] = explode(':', $string); + return new self($key, $value); + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + Assert::keyExists($array, 'key'); + Assert::string($array['key'], 'Tag key has to be of type string, given: %s'); + Assert::keyExists($array, 'value'); + Assert::string($array['value'], 'Tag value has to be of type string, given: %s'); + return new self($array['key'], $array['value']); + } + + public function equals(self $other): bool + { + return $other->key === $this->key && $other->value === $this->value; + } + + public function toString(): string + { + return $this->key . ':' . $this->value; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/src/Types/Tags.php b/src/Types/Tags.php new file mode 100644 index 0000000..80ba3a2 --- /dev/null +++ b/src/Types/Tags.php @@ -0,0 +1,130 @@ + + */ +final readonly class Tags implements IteratorAggregate, JsonSerializable +{ + /** + * @param array $tags + */ + private function __construct(private array $tags) + { + Assert::notEmpty($this->tags, 'Tags must not be empty'); + } + + /** + * @param array $tags + */ + public static function fromArray(array $tags): self + { + $convertedTags = []; + foreach ($tags as $tag) { + if (!$tag instanceof Tag) { + if (!is_string($tag) && !is_array($tag)) { + throw new InvalidArgumentException(sprintf('Tags must be of type string or array, given: %s', get_debug_type($tag)), 1690808045); + } + $tag = Tag::parse($tag); + } + $convertedTags[$tag->toString()] = $tag; + } + ksort($convertedTags); + return new self($convertedTags); + } + + public static function fromJson(string $json): self + { + try { + $tags = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException(sprintf('Failed to decode JSON to tags: %s', $e->getMessage()), 1690807603, $e); + } + Assert::isArray($tags, 'Failed to decode JSON to tags'); + return self::fromArray($tags); + } + + public static function single(string $type, string $value): self + { + return self::fromArray([Tag::create($type, $value)]); + } + + public static function create(Tag ...$tags): self + { + return self::fromArray($tags); + } + + public function merge(self|Tag $other): self + { + if ($other instanceof Tag) { + $other = self::create($other); + } + if ($other->equals($this)) { + return $this; + } + return self::fromArray(array_merge($this->tags, $other->tags)); + } + + public function contain(Tag $tag): bool + { + return array_key_exists($tag->toString(), $this->tags); + } + + public function intersect(self|Tag $other): bool + { + if ($other instanceof Tag) { + $other = self::create($other); + } + foreach ($other->tags as $tag) { + if ($this->contain($tag)) { + return true; + } + } + return false; + } + + public function equals(self $other): bool + { + return array_keys($this->tags) === array_keys($other->tags); + } + + /** + * @return array in the format ['someKey:someValue', 'someKey:someOtherValue'] + */ + public function toSimpleArray(): array + { + return array_keys($this->tags); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_values($this->tags); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->tags); + } +} diff --git a/tests/Integration/EventStoreConcurrencyTestBase.php b/tests/Integration/EventStoreConcurrencyTestBase.php index eee1429..a72a368 100644 --- a/tests/Integration/EventStoreConcurrencyTestBase.php +++ b/tests/Integration/EventStoreConcurrencyTestBase.php @@ -9,18 +9,24 @@ use PHPUnit\Framework\TestCase; use Random\Randomizer; use Wwwision\DCBEventStore\EventStore; -use Wwwision\DCBEventStore\Exception\ConditionalAppendFailed; -use Wwwision\DCBEventStore\Model\DomainId; -use Wwwision\DCBEventStore\Model\DomainIds; -use Wwwision\DCBEventStore\Model\Event; -use Wwwision\DCBEventStore\Model\EventData; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\EventId; -use Wwwision\DCBEventStore\Model\Events; -use Wwwision\DCBEventStore\Model\EventType; -use Wwwision\DCBEventStore\Model\EventTypes; -use Wwwision\DCBEventStore\Model\ExpectedLastEventId; -use Wwwision\DCBEventStore\Model\StreamQuery; +use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; +use Wwwision\DCBEventStore\Types\AppendCondition; +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\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\Events; +use Wwwision\DCBEventStore\Types\EventType; +use Wwwision\DCBEventStore\Types\EventTypes; +use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuerySerializer; use function array_map; use function array_rand; use function array_slice; @@ -55,34 +61,38 @@ public static function consistency_dataProvider(): iterable public function test_consistency(int $process): void { $numberOfEventTypes = 5; - $numberOfDomainIdKeys = 3; - $numberOfDomainIdValues = 3; - $numberOfDomainIds = 7; + $numberOfTagKeys = 3; + $numberOfTagValues = 3; + $numberOfTags = 7; $maxNumberOfEventsPerCommit = 3; $numberOfEventBatches = 30; - $eventTypes = self::spawn($numberOfEventTypes, static fn (int $index) => EventType::fromString('Event' . $index)); - $domainIdKeys = self::spawn($numberOfDomainIdKeys, static fn (int $index) => 'key' . $index); - $domainIdValues = self::spawn($numberOfDomainIdValues, static fn (int $index) => 'value' . $index); - $domainIds = []; - foreach ($domainIdKeys as $key) { - $domainIds[] = self::mockDomainId($key, self::either(...$domainIdValues)); + $eventTypes = self::spawn($numberOfEventTypes, static fn (int $index) => EventType::fromString('Events' . $index)); + $tagKeys = self::spawn($numberOfTagKeys, static fn (int $index) => 'key' . $index); + $tagValues = self::spawn($numberOfTagValues, static fn (int $index) => 'value' . $index); + $tags = []; + foreach ($tagKeys as $key) { + $tags[] = Tag::create($key, self::either(...$tagValues)); } - $queryCreators = [static fn () => StreamQuery::matchingIds(DomainIds::create(...self::some($numberOfDomainIds, ...$domainIds))), static fn () => StreamQuery::matchingTypes(EventTypes::create(...self::some($numberOfEventTypes, ...$eventTypes))), static fn () => StreamQuery::matchingIdsAndTypes(DomainIds::create(...self::some($numberOfDomainIds, ...$domainIds)), EventTypes::create(...self::some($numberOfEventTypes, ...$eventTypes))),]; + $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))))), + ]; for ($eventBatch = 0; $eventBatch < $numberOfEventBatches; $eventBatch ++) { $query = self::either(...$queryCreators)(); - $expectedLastEventId = $this->getExpectedLastEventId($query); + $expectedHighestSequenceNumber = $this->getExpectedHighestSequenceNumber($query); $numberOfEvents = self::between(1, $maxNumberOfEventsPerCommit); $events = []; for ($i = 0; $i < $numberOfEvents; $i++) { $descriptor = $process . '(' . getmypid() . ') ' . $eventBatch . '.' . ($i + 1) . '/' . $numberOfEvents; - $eventData = $i > 0 ? ['descriptor' => $descriptor] : ['query' => self::streamQueryToArray($query), 'expectedLastEventId' => $expectedLastEventId->isNone() ? null : $expectedLastEventId->eventId()->value, 'descriptor' => $descriptor]; - $events[] = new Event(EventId::create(), self::either(...$eventTypes), EventData::fromString(json_encode($eventData, JSON_THROW_ON_ERROR)), DomainIds::create(...self::some($numberOfDomainIds, ...$domainIds))); + $eventData = $i > 0 ? ['descriptor' => $descriptor] : ['query' => StreamQuerySerializer::serialize($query), 'expectedHighestSequenceNumber' => $expectedHighestSequenceNumber->isNone() ? null : $expectedHighestSequenceNumber->extractSequenceNumber()->value, 'descriptor' => $descriptor]; + $events[] = new Event(EventId::create(), self::either(...$eventTypes), EventData::fromString(json_encode($eventData, JSON_THROW_ON_ERROR)), Tags::create(...self::some($numberOfTags, ...$tags))); } try { - static::createEventStore()->conditionalAppend(Events::fromArray($events), $query, $expectedLastEventId); + static::createEventStore()->append(Events::fromArray($events), new AppendCondition($query, $expectedHighestSequenceNumber)); } catch (ConditionalAppendFailed $e) { } } @@ -91,83 +101,48 @@ public function test_consistency(int $process): void public static function validateEvents(): void { - /** @var EventEnvelope[] $processedEvents */ - $processedEvents = []; + /** @var EventEnvelope[] $processedEventEnvelopes */ + $processedEventEnvelopes = []; $lastSequenceNumber = 0; - foreach (static::createEventStore()->streamAll() as $eventEnvelope) { + foreach (static::createEventStore()->read(StreamQuery::wildcard()) as $eventEnvelope) { $payload = json_decode($eventEnvelope->event->data->value, true, 512, JSON_THROW_ON_ERROR); - $query = isset($payload['query']) ? self::arrayToStreamQuery($payload['query']) : null; + $query = isset($payload['query']) ? StreamQuerySerializer::unserialize($payload['query']) : null; $sequenceNumber = $eventEnvelope->sequenceNumber->value; self::assertGreaterThan($lastSequenceNumber, $sequenceNumber, sprintf('Expected sequence number of event "%s" to be greater than the previous one (%d) but it is %d', $eventEnvelope->event->id->value, $lastSequenceNumber, $sequenceNumber)); $eventId = $eventEnvelope->event->id->value; - $lastMatchedEventId = null; - foreach ($processedEvents as $processedEvent) { - self::assertNotSame($eventId, $processedEvent->event->id->value, sprintf('Event id "%s" is used for events with sequence numbers %d and %d', $eventId, $processedEvent->sequenceNumber->value, $sequenceNumber)); + $lastMatchedSequenceNumber = null; + foreach ($processedEventEnvelopes as $processedEvent) { + self::assertNotSame($eventId, $processedEvent->event->id->value, sprintf('Events id "%s" is used for events with sequence numbers %d and %d', $eventId, $processedEvent->sequenceNumber->value, $sequenceNumber)); if ($query !== null && $query->matches($processedEvent->event)) { - $lastMatchedEventId = $processedEvent->event->id; + $lastMatchedSequenceNumber = $processedEvent->sequenceNumber; } } if ($query !== null) { - if ($payload['expectedLastEventId'] === null) { - self::assertNull($lastMatchedEventId, sprintf('Event "%s" (sequence number %d) was appended with no lastMatchedEventId but the event "%s" matches the corresponding query', $eventId, $sequenceNumber, $lastMatchedEventId?->value)); - } elseif ($lastMatchedEventId === null) { - self::fail(sprintf('Event "%s" (sequence number %d) was appended with lastMatchedEventId "%s" but no event matches the corresponding query', $eventId, $sequenceNumber, $payload['expectedLastEventId'])); + if ($payload['expectedHighestSequenceNumber'] === null) { + self::assertNull($lastMatchedSequenceNumber, sprintf('Events "%s" (sequence number %d) was appended with no expectedHighestSequenceNumber but the event "%s" matches the corresponding query', $eventId, $sequenceNumber, $lastMatchedSequenceNumber?->value)); + } elseif ($lastMatchedSequenceNumber === null) { + self::fail(sprintf('Events "%s" (sequence number %d) was appended with expectedHighestSequenceNumber %d but no event matches the corresponding query', $eventId, $sequenceNumber, $payload['expectedHighestSequenceNumber'])); } else { - self::assertSame($lastMatchedEventId->value, $payload['expectedLastEventId'], sprintf('Event "%s" (sequence number %d) was appended with lastMatchedEventId "%s" but the last event that matches the corresponding query is "%s"', $eventId, $sequenceNumber, $payload['expectedLastEventId'], $lastMatchedEventId->value)); + self::assertSame($lastMatchedSequenceNumber->value, $payload['expectedHighestSequenceNumber'], sprintf('Events "%s" (sequence number %d) was appended with expectedHighestSequenceNumber %d but the last event that matches the corresponding query is "%s"', $eventId, $sequenceNumber, $payload['expectedHighestSequenceNumber'], $lastMatchedSequenceNumber->value)); } } $lastSequenceNumber = $sequenceNumber; - $processedEvents[] = $eventEnvelope; + $processedEventEnvelopes[] = $eventEnvelope; } } // ---------------------------------------------- - private static function streamQueryToArray(StreamQuery $query): array - { - return ['domainIds' => $query->domainIds?->toArray(), 'types' => $query->types?->toStringArray(),]; - } - private static function arrayToStreamQuery(array $array): StreamQuery + public function getExpectedHighestSequenceNumber(StreamQuery $query): ExpectedHighestSequenceNumber { - if ($array['domainIds'] !== null && $array['types'] !== null) { - return StreamQuery::matchingIdsAndTypes(DomainIds::fromArray($array['domainIds']), EventTypes::fromStrings(...$array['types'])); - } - if ($array['domainIds'] !== null) { - return StreamQuery::matchingIds(DomainIds::fromArray($array['domainIds'])); - } - return StreamQuery::matchingTypes(EventTypes::fromStrings(...$array['types'])); - } - - public function getExpectedLastEventId(StreamQuery $query): ExpectedLastEventId - { - $lastEventEnvelope = static::createEventStore()->stream($query)->last(); + $lastEventEnvelope = static::createEventStore()->readBackwards($query)->first(); if ($lastEventEnvelope === null) { - return ExpectedLastEventId::none(); + return ExpectedHighestSequenceNumber::none(); } - return ExpectedLastEventId::fromEventId($lastEventEnvelope->event->id); + return ExpectedHighestSequenceNumber::fromSequenceNumber($lastEventEnvelope->sequenceNumber); } - private static function mockDomainId(string $key, string $value): DomainId - { - return new class ($key, $value) implements DomainId { - public function __construct(private readonly string $key, private readonly string $value) {} - - public function key(): string - { - return $this->key; - } - - public function value(): string - { - return $this->value; - } - }; - } - - - - private static function spawn(int $number, \Closure $closure): array { return array_map($closure, range(1, $number)); diff --git a/tests/Integration/EventStoreTestBase.php b/tests/Integration/EventStoreTestBase.php index 96958e3..497f3c1 100644 --- a/tests/Integration/EventStoreTestBase.php +++ b/tests/Integration/EventStoreTestBase.php @@ -6,31 +6,38 @@ use InvalidArgumentException; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\EventStream; -use Wwwision\DCBEventStore\Exception\ConditionalAppendFailed; -use Wwwision\DCBEventStore\Model\DomainIds; -use Wwwision\DCBEventStore\Model\Event; -use Wwwision\DCBEventStore\Model\EventData; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\EventId; -use Wwwision\DCBEventStore\Model\Events; -use Wwwision\DCBEventStore\Model\EventType; -use Wwwision\DCBEventStore\Model\EventTypes; -use Wwwision\DCBEventStore\Model\ExpectedLastEventId; -use Wwwision\DCBEventStore\Model\SequenceNumber; -use Wwwision\DCBEventStore\Model\StreamQuery; +use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; +use Wwwision\DCBEventStore\Types\AppendCondition; +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\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\Events; +use Wwwision\DCBEventStore\Types\EventType; +use Wwwision\DCBEventStore\Types\EventTypes; +use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; +use Wwwision\DCBEventStore\Types\SequenceNumber; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Wwwision\DCBEventStore\Tests\Unit\EventEnvelopeShape; -use Wwwision\DCBEventStore\Tests\Unit\EventShape; +use function array_keys; use function array_map; +use function get_debug_type; +use function getenv; use function in_array; use function range; /** - * @phpstan-type EventShape array{id?: string, type?: string, data?: string, domainIds?: array>} - * @phpstan-type EventEnvelopeShape array{id?: string, type?: string, data?: string, domainIds?: array>, sequenceNumber?: int} + * @phpstan-type EventShape array{id?: string, type?: string, data?: string, tags?: array} + * @phpstan-type EventEnvelopeShape array{id?: string, type?: string, data?: string, tags?: array, sequenceNumber?: int} */ -#[CoversClass(DomainIds::class)] +#[CoversClass(Tags::class)] #[CoversClass(EventData::class)] #[CoversClass(ConditionalAppendFailed::class)] #[CoversClass(EventEnvelope::class)] @@ -48,28 +55,45 @@ abstract class EventStoreTestBase extends TestCase abstract protected function createEventStore(): EventStore; - public function test_streamAll_returns_an_empty_stream_if_no_events_were_published(): void + public function test_read_returns_an_empty_stream_if_no_events_were_published(): void { - self::assertEventStream($this->getEventStore()->streamAll(), []); + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard()), []); } - public function test_streamAll_returns_all_events(): void + public function test_read_returns_all_events(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->streamAll(), [ - ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar']], 'sequenceNumber' => 1], - ['id' => 'id-b', 'data' => 'b', 'type' => 'SomeOtherEventType', 'domainIds' => [['foo' => 'bar']],'sequenceNumber' => 2], - ['id' => 'id-c', 'data' => 'c', 'type' => 'SomeEventType', 'domainIds' => [['foo' => 'bar']],'sequenceNumber' => 3], - ['id' => 'id-d', 'data' => 'd', 'type' => 'SomeOtherEventType', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar']],'sequenceNumber' => 4], - ['id' => 'id-e', 'data' => 'e', 'type' => 'SomeEventType', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar']],'sequenceNumber' => 5], - ['id' => 'id-f', 'data' => 'f', 'type' => 'SomeOtherEventType', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar']],'sequenceNumber' => 6], + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard()), [ + ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], + ['id' => 'id-b', 'data' => 'b', 'type' => 'SomeOtherEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 2], + ['id' => 'id-c', 'data' => 'c', 'type' => 'SomeEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 3], + ['id' => 'id-d', 'data' => 'd', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 4], + ['id' => 'id-e', 'data' => 'e', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 5], + ['id' => 'id-f', 'data' => 'f', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 6], ]); } - public function test_stream_allows_filtering_of_events_by_domain_id(): void + public function test_read_allows_to_specify_minimum_sequenceNumber(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingIds(DomainIds::single('baz', 'foos'),)), [ + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), SequenceNumber::fromInteger(4)), [ + ['id' => 'id-d', 'data' => 'd', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 4], + ['id' => 'id-e', 'data' => 'e', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 5], + ['id' => 'id-f', 'data' => 'f', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 6], + ]); + } + + public function test_read_returns_an_empty_stream_if_minimum_sequenceNumber_exceeds_highest(): void + { + $this->appendDummyEvents(); + self::assertEventStream($this->getEventStore()->read(StreamQuery::wildcard(), SequenceNumber::fromInteger(123)), []); + } + + public function test_read_allows_filtering_of_events_by_tag(): void + { + $this->appendDummyEvents(); + $query = StreamQuery::create(Criteria::create(new TagsCriterion(Tags::fromArray(['baz:foos'])))); + self::assertEventStream($this->stream($query), [ ['data' => 'a'], ['data' => 'd'], ['data' => 'e'], @@ -77,19 +101,20 @@ public function test_stream_allows_filtering_of_events_by_domain_id(): void ]); } - public function test_stream_allows_filtering_of_events_by_domain_ids(): void + public function test_read_allows_filtering_of_events_by_tags(): void { $this->appendEvents([ - ['id' => 'a', 'domainIds' => [['foo' => 'bar']]], - ['id' => 'b', 'domainIds' => [['foo' => 'bar'], ['baz' => 'foos']]], - ['id' => 'c', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar']]], - ['id' => 'd', 'domainIds' => [['baz' => 'foos']]], - ['id' => 'e', 'domainIds' => [['baz' => 'foosnot']]], - ['id' => 'f', 'domainIds' => [['foo' => 'bar'], ['baz' => 'notfoos']]], - ['id' => 'g', 'domainIds' => [['baz' => 'foos'], ['foo' => 'bar'], ['foos' => 'baz']]], - ['id' => 'h', 'domainIds' => [['baz' => 'foosn'], ['foo' => 'notbar'], ['foos' => 'bar']]], + ['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']], ]); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingIds(DomainIds::fromArray([['foo' => 'bar'], ['baz' => 'foos']]),)), [ + $query = StreamQuery::create(Criteria::create(new TagsCriterion(Tags::fromArray(['foo:bar'])), new TagsCriterion(Tags::fromArray(['baz:foos'])))); + self::assertEventStream($this->stream($query), [ ['id' => 'a'], ['id' => 'b'], ['id' => 'c'], @@ -99,88 +124,169 @@ public function test_stream_allows_filtering_of_events_by_domain_ids(): void ]); } - public function test_stream_allows_filtering_of_events_by_event_types(): void + public function test_read_allows_filtering_of_events_by_event_types(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingTypes(EventTypes::single('SomeEventType'))), [ + $query = StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::fromStrings('SomeEventType')))); + self::assertEventStream($this->stream($query), [ ['data' => 'a'], ['data' => 'c'], ['data' => 'e'], ]); } - public function test_stream_allows_filtering_of_events_by_domain_ids_and_event_types(): void + public function test_read_allows_filtering_of_events_by_tags_and_event_types(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventType'))), [ + $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); + self::assertEventStream($this->stream($query), [ ['data' => 'a'], ['data' => 'e'], ]); } - public function test_stream_allows_fetching_no_events(): void + public function test_read_allows_fetching_no_events(): void + { + $this->appendDummyEvents(); + $query = StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::fromStrings('NonExistingEventType')))); + self::assertEventStream($this->stream($query), []); + } + +// // NOTE: This test is commented out because that guarantee is currently NOT given (it works on SQLite but not on MariaDB and PostgreSQL) +// public function test_read_includes_events_that_where_appended_after_iteration_started(): void +// { +// $this->appendDummyEvents(); +// $actualEvents = []; +// $index = 0; +// $eventStream = $this->getEventStore()->read(StreamQuery::wildcard()); +// foreach ($eventStream as $eventEnvelope) { +// $actualEvents[] = self::eventEnvelopeToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['id', 'type', 'data', 'tags', 'sequenceNumber'], $eventEnvelope); +// if ($eventEnvelope->sequenceNumber->value === 3) { +// $this->appendEvents([ +// ['id' => 'id-g', 'data' => 'g', 'type' => 'SomeEventType', 'tags' => ['foo:bar', 'foo:baz']], +// ['id' => 'id-h', 'data' => 'h', 'type' => 'SomeOtherEventType', 'tags' => ['foo:foos', 'bar:baz']], +// ]); +// } +// $index ++; +// } +// $expectedEvents = [ +// ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], +// ['id' => 'id-b', 'data' => 'b', 'type' => 'SomeOtherEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 2], +// ['id' => 'id-c', 'data' => 'c', 'type' => 'SomeEventType', 'tags' => ['foo:bar'], 'sequenceNumber' => 3], +// ['id' => 'id-d', 'data' => 'd', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 4], +// ['id' => 'id-e', 'data' => 'e', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 5], +// ['id' => 'id-f', 'data' => 'f', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 6], +// ['id' => 'id-g', 'data' => 'g', 'type' => 'SomeEventType', 'tags' => ['foo:bar', 'foo:baz'], 'sequenceNumber' => 7], +// ['id' => 'id-h', 'data' => 'h', 'type' => 'SomeOtherEventType', 'tags' => ['bar:baz', 'foo:foos'], 'sequenceNumber' => 8], +// ]; +// self::assertEquals($expectedEvents, $actualEvents); +// } + + public function test_readBackwards_returns_all_events_in_descending_order(): void + { + $this->appendDummyEvents(); + self::assertEventStream($this->getEventStore()->readBackwards(StreamQuery::wildcard()), [ + ['id' => 'id-f', 'data' => 'f', 'type' => 'SomeOtherEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 6], + ['id' => 'id-e', 'data' => 'e', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 5], + ['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], + ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], + ]); + } + + public function test_readBackwards_allows_to_specify_maximum_sequenceNumber(): void { $this->appendDummyEvents(); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingIds(DomainIds::single('non-existing', 'id'))), []); + self::assertEventStream($this->getEventStore()->readBackwards(StreamQuery::wildcard(), SequenceNumber::fromInteger(4)), [ + ['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], + ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], + ]); } - public function test_conditionalAppend_appends_event_if_expectedLastEventId_matches(): void + public function test_readBackwards_returns_single_event_if_maximum_sequenceNumber_is_one(): void { $this->appendDummyEvents(); + self::assertEventStream($this->getEventStore()->readBackwards(StreamQuery::wildcard(), SequenceNumber::fromInteger(1)), [ + ['id' => 'id-a', 'data' => 'a', 'type' => 'SomeEventType', 'tags' => ['baz:foos', 'foo:bar'], 'sequenceNumber' => 1], + ]); + } - $query = StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventType')); - $stream = $this->getEventStore()->stream($query); - $lastEventId = $stream->last()->event->id; - $this->conditionalAppendEvent(['type' => 'SomeEventType', 'data' => 'new event', 'domainIds' => [['baz' => 'foos']]], $query, ExpectedLastEventId::fromEventId($lastEventId)); + public function test_append_appends_event_if_expectedHighestSequenceNumber_matches(): void + { + $this->appendDummyEvents(); + + $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); + $stream = $this->getEventStore()->readBackwards($query); + $lastSequenceNumber = $stream->first()->sequenceNumber; + $this->conditionalAppendEvent(['type' => 'SomeEventType', 'data' => 'new event', 'tags' => ['baz:foos']], $query, ExpectedHighestSequenceNumber::fromSequenceNumber($lastSequenceNumber)); - self::assertEventStream($this->getEventStore()->stream(StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventType'))), [ + self::assertEventStream($this->getEventStore()->read($query), [ ['data' => 'a'], ['data' => 'e'], ['data' => 'new event'], ]); } - public function test_conditionalAppend_fails_if_new_events_match_the_specified_query(): void + public function test_append_fails_if_new_events_match_the_specified_query(): void { $this->appendDummyEvents(); - $query = StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventType')); - $stream = $this->getEventStore()->stream($query); - $lastEventId = $stream->last()->event->id; + $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); + $stream = $this->getEventStore()->readBackwards($query); + $lastSequenceNumber = $stream->first()->sequenceNumber; - $this->appendEvent(['type' => 'SomeEventType', 'domainIds' => [['baz' => 'foos']]]); + $this->appendEvent(['type' => 'SomeEventType', 'tags' => ['baz:foos']]); $this->expectException(ConditionalAppendFailed::class); - $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedLastEventId::fromEventId($lastEventId)); + $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedHighestSequenceNumber::fromSequenceNumber($lastSequenceNumber)); } - public function test_conditionalAppend_fails_if_no_last_event_id_was_expected_but_query_matches_events(): void + public function test_append_fails_if_no_last_event_id_was_expected_but_query_matches_events(): void { $this->appendDummyEvents(); - $query = StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventType')); + $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventType'), Tags::create(Tag::fromString('baz:foos'))))); $this->expectException(ConditionalAppendFailed::class); - $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedLastEventId::none()); + $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedHighestSequenceNumber::none()); } - public function test_conditionalAppend_fails_if_last_event_id_was_expected_but_query_matches_no_events(): void + public function test_append_fails_if_last_event_id_was_expected_but_query_matches_no_events(): void { - $query = StreamQuery::matchingIdsAndTypes(DomainIds::single('baz', 'foos'), EventTypes::single('SomeEventTypeThatDidNotOccur')); + $query = StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::fromStrings('SomeEventTypeThatDidNotOccur'), Tags::create(Tag::fromString('baz:foos'))))); $this->expectException(ConditionalAppendFailed::class); - $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedLastEventId::fromString('some-expected-id')); + $this->conditionalAppendEvent(['type' => 'DoesNotMatter'], $query, ExpectedHighestSequenceNumber::fromInteger(123)); } // --- Helpers --- + final protected function streamAll(): EventStream + { + return $this->getEventStore()->read(StreamQuery::wildcard()); + } + + final protected function parseQuery(string $query): StreamQuery + { + $criteria = StreamQueryParser::parse($query); + return StreamQuery::create($criteria); + } + + final protected function stream(StreamQuery $query): EventStream + { + return $this->getEventStore()->read($query); + } + final protected function appendDummyEvents(): void { $this->appendEvents(array_map(static fn ($char) => [ 'id' => 'id-' . $char, 'data' => $char, 'type' => in_array($char, ['a', 'c', 'e'], true) ? 'SomeEventType' : 'SomeOtherEventType', - 'domainIds' => in_array($char, ['b', 'c'], true) ? [['foo' => 'bar']] : [['foo' => 'bar'], ['baz' => 'foos']], + 'tags' => in_array($char, ['b', 'c'], true) ? ['foo:bar'] : ['foo:bar', 'baz:foos'], ], range('a', 'f'))); } @@ -197,23 +303,23 @@ final protected function appendEvent(array $event): void */ final protected function appendEvents(array $events): void { - $this->getEventStore()->append(Events::fromArray(array_map(self::arrayToEvent(...), $events))); + $this->getEventStore()->append(Events::fromArray(array_map(self::arrayToEvent(...), $events)), AppendCondition::noConstraints()); } /** * @phpstan-param EventShape $event */ - final protected function conditionalAppendEvents(array $events, StreamQuery $query, ExpectedLastEventId $expectedLastEventId): void + final protected function conditionalAppendEvents(array $events, StreamQuery $query, ExpectedHighestSequenceNumber $expectedHighestSequenceNumber): void { - $this->getEventStore()->conditionalAppend(Events::fromArray(array_map(self::arrayToEvent(...), $events)), $query, $expectedLastEventId); + $this->getEventStore()->append(Events::fromArray(array_map(self::arrayToEvent(...), $events)), new AppendCondition($query, $expectedHighestSequenceNumber)); } /** * @phpstan-param EventShape $event */ - final protected function conditionalAppendEvent(array $event, StreamQuery $query, ExpectedLastEventId $expectedLastEventId): void + final protected function conditionalAppendEvent(array $event, StreamQuery $query, ExpectedHighestSequenceNumber $expectedHighestSequenceNumber): void { - $this->conditionalAppendEvents([$event], $query, $expectedLastEventId); + $this->conditionalAppendEvents([$event], $query, $expectedHighestSequenceNumber); } /** @@ -224,20 +330,18 @@ final protected static function assertEventStream(EventStream $eventStream, arra $actualEvents = []; $index = 0; foreach ($eventStream as $eventEnvelope) { - $actualEvents[] = self::eventEnvelopeToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['id', 'type', 'data', 'domainIds', 'sequenceNumber'], $eventEnvelope); + $actualEvents[] = self::eventEnvelopeToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['id', 'type', 'data', 'tags', 'sequenceNumber'], $eventEnvelope); $index ++; } self::assertEquals($expectedEvents, $actualEvents); } - // --- Internal --- private function getEventStore(): EventStore { if ($this->eventStore === null) { $this->eventStore = $this->createEventStore(); - //$this->eventStore->setup(); } return $this->eventStore; } @@ -248,7 +352,7 @@ private function getEventStore(): EventStore */ private static function eventEnvelopeToArray(array $keys, EventEnvelope $eventEnvelope): array { - $supportedKeys = ['id', 'type', 'data', 'domainIds', 'sequenceNumber']; + $supportedKeys = ['id', 'type', 'data', 'tags', 'sequenceNumber']; $unsupportedKeys = array_diff($keys, $supportedKeys); if ($unsupportedKeys !== []) { throw new InvalidArgumentException(sprintf('Invalid key(s) "%s" for expected event. Allowed keys are: "%s"', implode('", "', $unsupportedKeys), implode('", "', $supportedKeys)), 1684668588); @@ -257,7 +361,7 @@ private static function eventEnvelopeToArray(array $keys, EventEnvelope $eventEn 'id' => $eventEnvelope->event->id->value, 'type' => $eventEnvelope->event->type->value, 'data' => $eventEnvelope->event->data->value, - 'domainIds' => $eventEnvelope->event->domainIds->toArray(), + 'tags' => $eventEnvelope->event->tags->toSimpleArray(), 'sequenceNumber' => $eventEnvelope->sequenceNumber->value, ]; foreach (array_diff($supportedKeys, $keys) as $unusedKey) { @@ -275,7 +379,7 @@ private static function arrayToEvent(array $event): Event isset($event['id']) ? EventId::fromString($event['id']) : EventId::create(), EventType::fromString($event['type'] ?? 'SomeEventType'), EventData::fromString($event['data'] ?? ''), - DomainIds::fromArray($event['domainIds'] ?? [['foo' => 'bar']]), + Tags::fromArray($event['tags'] ?? ['foo:bar']), ); } } \ No newline at end of file diff --git a/tests/Integration/Helper/InMemoryEventStoreTest.php b/tests/Integration/Helper/InMemoryEventStoreTest.php index 4d7f64f..4930283 100644 --- a/tests/Integration/Helper/InMemoryEventStoreTest.php +++ b/tests/Integration/Helper/InMemoryEventStoreTest.php @@ -3,30 +3,30 @@ namespace Wwwision\DCBEventStore\Tests\Integration\Helper; -use Wwwision\DCBEventStore\Exception\ConditionalAppendFailed; -use Wwwision\DCBEventStore\Model\DomainIds; -use Wwwision\DCBEventStore\Model\Event; -use Wwwision\DCBEventStore\Model\EventData; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\EventId; -use Wwwision\DCBEventStore\Model\Events; -use Wwwision\DCBEventStore\Model\EventType; -use Wwwision\DCBEventStore\Model\EventTypes; -use Wwwision\DCBEventStore\Model\ExpectedLastEventId; -use Wwwision\DCBEventStore\Model\SequenceNumber; -use Wwwision\DCBEventStore\Model\StreamQuery; +use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; +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\Events; +use Wwwision\DCBEventStore\Types\EventType; +use Wwwision\DCBEventStore\Types\EventTypes; +use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; +use Wwwision\DCBEventStore\Types\SequenceNumber; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use Wwwision\DCBEventStore\Tests\Integration\EventStoreTestBase; -use Wwwision\DCBEventStore\Helper\InMemoryEventStore; -use Wwwision\DCBEventStore\Helper\InMemoryEventStream; +use Wwwision\DCBEventStore\Helpers\InMemoryEventStore; +use Wwwision\DCBEventStore\Helpers\InMemoryEventStream; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(InMemoryEventStore::class)] #[CoversClass(InMemoryEventStream::class)] -#[CoversClass(DomainIds::class)] +#[CoversClass(Tags::class)] #[CoversClass(EventData::class)] #[CoversClass(ConditionalAppendFailed::class)] #[CoversClass(EventEnvelope::class)] -#[CoversClass(ExpectedLastEventId::class)] +#[CoversClass(ExpectedHighestSequenceNumber::class)] #[CoversClass(EventId::class)] #[CoversClass(EventType::class)] #[CoversClass(EventTypes::class)] diff --git a/tests/Unit/Model/DomainIdsTest.php b/tests/Unit/Model/DomainIdsTest.php deleted file mode 100644 index 66a238f..0000000 --- a/tests/Unit/Model/DomainIdsTest.php +++ /dev/null @@ -1,237 +0,0 @@ - 'someId']], $ids); - } - - public function test_create_merges_repeating_key_and_value_pairs(): void - { - $domainId = self::mockDomainId('someKey', 'someValue'); - self::assertDomainIdsMatch([['someKey' => 'someValue']], DomainIds::create($domainId, $domainId, $domainId)); - } - - public static function dataProvider_invalidKeys(): iterable - { - yield [123]; - yield [true]; - yield ['UpperCase']; - yield ['lowerCaseButTooooLong']; - yield ['späcialCharacters']; - } - - /** - * @dataProvider dataProvider_invalidKeys - */ - public function test_fromArray_fails_if_specified_key_is_not_valid($key): void - { - $this->expectException(InvalidArgumentException::class); - DomainIds::fromArray([[$key => 'bar']]); - } - - public static function dataProvider_invalidValues(): iterable - { - yield [123]; - yield [true]; - yield ['UpperCase']; - yield ['lowerCaseButTooooLong']; - yield ['späcialCharacters']; - } - - /** - * @dataProvider dataProvider_invalidValues - */ - public function test_fromArray_fails_if_specified_value_is_not_valid($value): void - { - $this->expectException(InvalidArgumentException::class); - DomainIds::fromArray([['key' => $value]]); - } - - public function test_fromArray_merges_repeating_key_and_value_pairs(): void - { - $domainId = self::mockDomainId('someKey', 'someValue'); - self::assertDomainIdsMatch([['someKey' => 'someValue']], DomainIds::fromArray([$domainId, $domainId])); - } - - public function test_fromJson_fails_if_value_is_not_valid_JSON(): void - { - $this->expectException(InvalidArgumentException::class); - DomainIds::fromJson('not-json'); - } - - public function test_fromJson_fails_if_value_is_no_JSON_array(): void - { - $this->expectException(InvalidArgumentException::class); - DomainIds::fromJson('false'); - } - - public function test_fromJson_sorts_values_and_removes_duplicates(): void - { - $domainIds = DomainIds::fromJson('[{"foo": "bar"}, {"bar": "foos"}, {"foo": "bar"}, {"foo": "baz"}]'); - self::assertDomainIdsMatch([['bar' => 'foos'], ['foo' => 'bar'], ['foo' => 'baz']], $domainIds); - } - - public function test_intersects_allows_checking_single_domainIds(): void - { - $ids = DomainIds::fromArray([['foo' => 'bar'], ['baz' => 'foos']]); - - self::assertTrue($ids->intersects(self::mockDomainId('baz', 'foos'))); - self::assertFalse($ids->intersects(self::mockDomainId('foo', 'foos'))); - } - - - public static function dataProvider_intersects(): iterable - { - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['foo' => 'bar']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'bar']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['baz' => 'foos']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'bar'], ['baz' => 'foos']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'bar'], ['baz' => 'other']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'other'], ['baz' => 'foos']], 'expectedResult' => true]; - - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['foo' => 'bar2']], 'expectedResult' => false]; - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['bar' => 'bar']], 'expectedResult' => false]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'other'], ['baz' => 'other']], 'expectedResult' => false]; - } - - /** - * @dataProvider dataProvider_intersects - */ - public function test_intersects(array $ids1, array $ids2, bool $expectedResult): void - { - if ($expectedResult) { - self::assertTrue(DomainIds::fromArray($ids1)->intersects(DomainIds::fromArray($ids2))); - } else { - self::assertFalse(DomainIds::fromArray($ids1)->intersects(DomainIds::fromArray($ids2))); - } - } - - public static function dataProvider_equals(): iterable - { - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['foo' => 'bar']], 'expectedResult' => true]; - yield ['ids1' => [['foo' => 'bar'], ['bar' => 'baz']], 'ids2' => [['bar' => 'baz'], ['foo' => 'bar']], 'expectedResult' => true]; - - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['foo' => 'bar2']], 'expectedResult' => false]; - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['bar' => 'bar']], 'expectedResult' => false]; - yield ['ids1' => [['foo' => 'bar'], ['baz' => 'foos']], 'ids2' => [['foo' => 'other'], ['baz' => 'other']], 'expectedResult' => false]; - } - - /** - * @dataProvider dataProvider_equals - */ - public function test_equals(array $ids1, array $ids2, bool $expectedResult): void - { - if ($expectedResult) { - self::assertTrue(DomainIds::fromArray($ids1)->equals(DomainIds::fromArray($ids2))); - } else { - self::assertFalse(DomainIds::fromArray($ids1)->equals(DomainIds::fromArray($ids2))); - } - } - - public function test_merge_allows_two_domain_ids_that_contain_different_values_for_the_same_key(): void - { - $ids1 = DomainIds::fromArray([['foo' => 'bar'], ['bar' => 'baz']]); - $ids2 = DomainIds::fromArray([['foo' => 'bar'], ['bar' => 'not_baz']]); - - self::assertDomainIdsMatch([['bar' => 'baz'], ['bar' => 'not_baz'], ['foo' => 'bar']], $ids1->merge($ids2)); - } - - public function test_merge_returns_same_instance_if_values_are_equal(): void - { - $ids1 = DomainIds::single('foo', 'bar'); - $ids2 = DomainIds::single('foo', 'bar'); - - self::assertSame($ids1, $ids1->merge($ids2)); - } - - public function test_merge_allows_merging_single_domainIds(): void - { - $ids1 = DomainIds::single('foo', 'bar'); - $ids2 = self::mockDomainId('foo', 'baz'); - - self::assertDomainIdsMatch([['foo' => 'bar'], ['foo' => 'baz']], $ids1->merge($ids2)); - } - - public static function dataProvider_merge(): iterable - { - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['foo' => 'bar']], 'expectedResult' => [['foo' => 'bar']]]; - yield ['ids1' => [['foo' => 'bar'], ['bar' => 'baz']], 'ids2' => [['bar' => 'baz']], 'expectedResult' => [['bar' => 'baz'], ['foo' => 'bar']]]; - yield ['ids1' => [['foo' => 'bar']], 'ids2' => [['bar' => 'baz']], 'expectedResult' => [['bar' => 'baz'], ['foo' => 'bar']]]; - } - - /** - * @dataProvider dataProvider_merge - */ - public function test_merge(array $ids1, array $ids2, array $expectedResult): void - { - self::assertDomainIdsMatch($expectedResult, DomainIds::fromArray($ids1)->merge(DomainIds::fromArray($ids2))); - } - - public function test_contains_allows_checking_single_domainIds(): void - { - $ids = DomainIds::fromArray([['foo' => 'bar'], ['baz' => 'foos']]); - - self::assertTrue($ids->contains(self::mockDomainId('baz', 'foos'))); - self::assertFalse($ids->contains(self::mockDomainId('foo', 'foos'))); - } - - public static function dataProvider_contains(): iterable - { - yield ['ids' => [['foo' => 'bar']], 'key' => 'foo', 'value' => 'bar', 'expectedResult' => true]; - yield ['ids' => [['foo' => 'bar'], ['bar' => 'baz']], 'key' => 'bar', 'value' => 'baz', 'expectedResult' => true]; - - yield ['ids' => [['foo' => 'bar']], 'key' => 'foo', 'value' => 'bar2', 'expectedResult' => false]; - yield ['ids' => [['foo' => 'bar']], 'key' => 'bar', 'value' => 'bar', 'expectedResult' => false]; - yield ['ids' => [['foo' => 'bar'], ['baz' => 'foos']], 'key' => 'notFoo', 'value' => 'notBar', 'expectedResult' => false]; - } - - /** - * @dataProvider dataProvider_contains - */ - public function test_contains(array $ids, string $key, string $value, bool $expectedResult): void - { - if ($expectedResult) { - self::assertTrue(DomainIds::fromArray($ids)->contains($key, $value)); - } else { - self::assertFalse(DomainIds::fromArray($ids)->contains($key, $value)); - } - } - - public function test_serialized_format(): void - { - $domainIds = DomainIds::fromJson('[{"foo": "bar"}, {"bar": "foos"}, {"foo": "bar"}, {"foo": "baz"}]'); - self::assertJsonStringEqualsJsonString('[{"bar": "foos"}, {"foo": "bar"}, {"foo": "baz"}]', json_encode($domainIds)); - } - - // -------------------------------------- - - private static function mockDomainId(string $key, string $value): DomainId - { - return new class ($key, $value) implements DomainId { - public function __construct(private readonly string $key, private readonly string $value) {} - public function key(): string { return $this->key; } - public function value(): string { return $this->value; } - }; - } - private static function assertDomainIdsMatch(array $expected, DomainIds $actual): void - { - self::assertSame($expected, $actual->toArray()); - } -} \ No newline at end of file diff --git a/tests/Unit/Model/StreamQueryTest.php b/tests/Unit/Model/StreamQueryTest.php deleted file mode 100644 index e9ed3b7..0000000 --- a/tests/Unit/Model/StreamQueryTest.php +++ /dev/null @@ -1,53 +0,0 @@ - 'bar'], ['bar' => 'baz']]); - $event = new Event(EventId::create(), $eventType1, EventData::fromString(''), $domainIds1); - - yield ['query' => StreamQuery::matchingIds(DomainIds::single('foo', 'not_bar')), 'event' => $event, 'expectedResult' => false]; - yield ['query' => StreamQuery::matchingTypes(EventTypes::single('SomeOtherEventType')), 'event' => $event, 'expectedResult' => false]; - yield ['query' => StreamQuery::matchingIdsAndTypes(DomainIds::single('foo', 'not_bar'), EventTypes::single('SomeEventType')), 'event' => $event, 'expectedResult' => false]; - - yield ['query' => StreamQuery::matchingIds(DomainIds::single('foo', 'bar')), 'event' => $event, 'expectedResult' => true]; - yield ['query' => StreamQuery::matchingIds(DomainIds::fromArray([['foo' => 'bar'], ['bar' => 'not_baz']])), 'event' => $event, 'expectedResult' => true]; - yield ['query' => StreamQuery::matchingIds(DomainIds::fromArray([['foo' => 'bar'], ['bar' => 'baz'], ['foos' => 'bars']])), 'event' => $event, 'expectedResult' => true]; - yield ['query' => StreamQuery::matchingTypes(EventTypes::single('SomeEventType')), 'event' => $event, 'expectedResult' => true]; - yield ['query' => StreamQuery::matchingTypes(EventTypes::create(EventType::fromString('SomeOtherEventType'), EventType::fromString('SomeEventType'))), 'event' => $event, 'expectedResult' => true]; - yield ['query' => StreamQuery::matchingIdsAndTypes(DomainIds::single('foo', 'bar'), EventTypes::single('SomeEventType')), 'event' => $event, 'expectedResult' => true]; - - yield ['query' => StreamQuery::matchingIdsAndTypes(DomainIds::fromArray([['key2' => 'value1'], ['key1' => 'value3']]), EventTypes::single('Event4')), 'event' => new Event(EventId::create(), EventType::fromString('Event3'), EventData::fromString(''), DomainIds::single('key2', 'value1')), 'expectedResult' => false]; - } - - /** - * @dataProvider dataprovider_matches - */ - public function test_matches(StreamQuery $query, Event $event, bool $expectedResult): void - { - if ($expectedResult === true) { - self::assertTrue($query->matches($event)); - } else { - self::assertFalse($query->matches($event)); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Types/StreamQuery/StreamQueryTest.php b/tests/Unit/Types/StreamQuery/StreamQueryTest.php new file mode 100644 index 0000000..690fa81 --- /dev/null +++ b/tests/Unit/Types/StreamQuery/StreamQueryTest.php @@ -0,0 +1,69 @@ + ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::single('foo', 'not_bar')))), 'event' => $event]; + yield 'different event type' => ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::single('SomeOtherEventType')))), 'event' => $event]; + yield 'matching event type, different tag value' => ['query' => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::single('SomeEventType'), Tags::single('foo', 'not_bar')))), 'event' => $event]; + + yield 'matching tag, different event type' => ['query' => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::single('Event4'), Tags::fromArray(['key2:value1', 'key1:value3'])))), 'event' => new Event(EventId::create(), EventType::fromString('Event3'), EventData::fromString(''), Tags::single('key2', 'value1'))]; + } + + /** + * @dataProvider dataprovider_no_match + */ + public function test_matches_false(StreamQuery $query, Event $event): void + { + self::assertFalse($query->matches($event)); + } + + public static function dataprovider_matches(): iterable + { + $eventType = EventType::fromString('SomeEventType'); + $tags = Tags::fromArray(['foo:bar', 'bar:baz']); + $event = new Event(EventId::create(), $eventType, EventData::fromString(''), $tags); + + yield 'matching tag type and value' => ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::single('foo', 'bar')))), 'event' => $event]; + yield 'partially matching tags' => ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::fromArray(['foo:bar', 'bar:not_baz'])))), 'event' => $event]; + yield 'matching all tags plus additional tags' => ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::fromArray(['foo:bar', 'bar:baz', 'foos:bars'])))), 'event' => $event]; + yield 'matching event type' => ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::single('SomeEventType')))), 'event' => $event]; + yield 'matching one of two event types' => ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::create(EventType::fromString('SomeOtherEventType'), EventType::fromString('SomeEventType'))))), 'event' => $event]; + yield 'matching event type and tag' => ['query' => StreamQuery::create(Criteria::create(new EventTypesAndTagsCriterion(EventTypes::single('SomeEventType'), Tags::single('foo', 'bar')))), 'event' => $event]; + + } + + /** + * @dataProvider dataprovider_matches + */ + public function test_matches_true(StreamQuery $query, Event $event): void + { + self::assertTrue($query->matches($event)); + } +} \ No newline at end of file diff --git a/tests/Unit/Types/TagsTest.php b/tests/Unit/Types/TagsTest.php new file mode 100644 index 0000000..e598c37 --- /dev/null +++ b/tests/Unit/Types/TagsTest.php @@ -0,0 +1,227 @@ +expectException(InvalidArgumentException::class); + Tags::fromArray([['key' => $key]]); + } + + public static function dataProvider_invalidValues(): iterable + { + yield [123]; + yield [true]; + yield ['validCharactersButExactlyOneCharacterToooooooooLong']; + yield ['späcialCharacters']; + } + + /** + * @dataProvider dataProvider_invalidValues + */ + public function test_fromArray_fails_if_specified_value_is_not_valid($value): void + { + $this->expectException(InvalidArgumentException::class); + Tags::fromArray([['key' => 'tag', 'value' => $value]]); + } + + public function test_fromArray_merges_repeating_key_and_value_pairs(): void + { + $tag = Tag::create('someKey', 'someValue'); + self::assertTagsMatch(['someKey:someValue'], Tags::fromArray([$tag, $tag])); + } + + public function test_fromJson_fails_if_value_is_not_valid_JSON(): void + { + $this->expectException(InvalidArgumentException::class); + Tags::fromJson('not-json'); + } + + public function test_fromJson_fails_if_value_is_no_JSON_array(): void + { + $this->expectException(InvalidArgumentException::class); + Tags::fromJson('false'); + } + + public function test_fromJson_sorts_values_and_removes_duplicates(): void + { + $tags = Tags::fromJson('[{"key": "foo", "value": "bar"}, {"key": "bar", "value": "foos"}, {"key": "foo", "value": "bar"}, {"key": "foo", "value": "baz"}]'); + self::assertTagsMatch(['bar:foos', 'foo:bar', 'foo:baz'], $tags); + } + + public function test_intersects_allows_checking_single_tags(): void + { + $ids = Tags::fromArray(['foo:bar', 'baz:foos']); + + self::assertTrue($ids->intersect(Tag::create('baz', 'foos'))); + self::assertFalse($ids->intersect(Tag::create('foo', 'foos'))); + } + + + public static function dataProvider_intersects(): iterable + { + yield ['ids1' => ['foo:bar'], 'ids2' => ['foo:bar'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:bar'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['baz:foos'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:bar', 'baz:foos'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:bar', 'baz:other'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:other', 'baz:foos'], 'expectedResult' => true]; + + yield ['ids1' => ['foo:bar'], 'ids2' => ['foo:bar2'], 'expectedResult' => false]; + yield ['ids1' => ['foo:bar'], 'ids2' => ['bar:bar'], 'expectedResult' => false]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:other', 'baz:other'], 'expectedResult' => false]; + } + + /** + * @dataProvider dataProvider_intersects + */ + public function test_intersects(array $ids1, array $ids2, bool $expectedResult): void + { + if ($expectedResult) { + self::assertTrue(Tags::fromArray($ids1)->intersect(Tags::fromArray($ids2))); + } else { + self::assertFalse(Tags::fromArray($ids1)->intersect(Tags::fromArray($ids2))); + } + } + + public static function dataProvider_equals(): iterable + { + yield ['ids1' => ['foo:bar'], 'ids2' => ['foo:bar'], 'expectedResult' => true]; + yield ['ids1' => ['foo:bar', 'bar:baz'], 'ids2' => ['bar:baz', 'foo:bar'], 'expectedResult' => true]; + + yield ['ids1' => ['foo:bar'], 'ids2' => ['foo:bar2'], 'expectedResult' => false]; + yield ['ids1' => ['foo:bar'], 'ids2' => ['bar:bar'], 'expectedResult' => false]; + yield ['ids1' => ['foo:bar', 'baz:foos'], 'ids2' => ['foo:other', 'baz:other'], 'expectedResult' => false]; + } + + /** + * @dataProvider dataProvider_equals + */ + public function test_equals(array $ids1, array $ids2, bool $expectedResult): void + { + if ($expectedResult) { + self::assertTrue(Tags::fromArray($ids1)->equals(Tags::fromArray($ids2))); + } else { + self::assertFalse(Tags::fromArray($ids1)->equals(Tags::fromArray($ids2))); + } + } + + public function test_merge_allows_two_tags_that_contain_different_values_for_the_same_key(): void + { + $ids1 = Tags::fromArray(['foo:bar', 'bar:baz']); + $ids2 = Tags::fromArray(['foo:bar', 'bar:not_baz']); + + self::assertTagsMatch(['bar:baz', 'bar:not_baz', 'foo:bar'], $ids1->merge($ids2)); + } + + public function test_merge_returns_same_instance_if_values_are_equal(): void + { + $ids1 = Tags::create(Tag::create('foo', 'bar')); + $ids2 = Tags::create(Tag::create('foo', 'bar')); + + self::assertSame($ids1, $ids1->merge($ids2)); + } + + public function test_merge_allows_merging_single_tags(): void + { + $ids1 = Tags::create(Tag::create('foo', 'bar')); + $ids2 = Tag::create('foo', 'baz'); + + self::assertTagsMatch(['foo:bar', 'foo:baz'], $ids1->merge($ids2)); + } + + public static function dataProvider_merge(): iterable + { + yield ['ids1' => ['foo:bar'], 'ids2' => ['foo:bar'], 'expectedResult' => ['foo:bar']]; + yield ['ids1' => ['foo:bar', 'bar:baz'], 'ids2' => ['bar:baz'], 'expectedResult' => ['bar:baz', 'foo:bar']]; + yield ['ids1' => ['foo:bar'], 'ids2' => ['bar:baz'], 'expectedResult' => ['bar:baz', 'foo:bar']]; + } + + /** + * @dataProvider dataProvider_merge + */ + public function test_merge(array $ids1, array $ids2, array $expectedResult): void + { + self::assertTagsMatch($expectedResult, Tags::fromArray($ids1)->merge(Tags::fromArray($ids2))); + } + + public function test_contains_allows_checking_single_tags(): void + { + $ids = Tags::fromArray(['foo:bar', 'baz:foos']); + + self::assertTrue($ids->contain(Tag::create('baz', 'foos'))); + self::assertFalse($ids->contain(Tag::create('foo', 'foos'))); + } + + public static function dataProvider_contains(): iterable + { + yield ['ids' => ['foo:bar'], 'key' => 'foo', 'value' => 'bar', 'expectedResult' => true]; + yield ['ids' => ['foo:bar', 'bar:baz'], 'key' => 'bar', 'value' => 'baz', 'expectedResult' => true]; + + yield ['ids' => ['foo:bar'], 'key' => 'key', 'value' => 'bar2', 'expectedResult' => false]; + yield ['ids' => ['foo:bar'], 'key' => 'bar', 'value' => 'bar', 'expectedResult' => false]; + yield ['ids' => ['foo:bar', 'baz:foos'], 'key' => 'notFoo', 'value' => 'notBar', 'expectedResult' => false]; + } + + /** + * @dataProvider dataProvider_contains + */ + public function test_contains(array $ids, string $key, string $value, bool $expectedResult): void + { + if ($expectedResult) { + self::assertTrue(Tags::fromArray($ids)->contain(Tag::create($key, $value))); + } else { + self::assertFalse(Tags::fromArray($ids)->contain(Tag::create($key, $value))); + } + } + + public function test_serialized_format(): void + { + $tags = Tags::fromJson('["foo:bar", "bar:foos", "foo:bar", "foo:baz"]'); + self::assertJsonStringEqualsJsonString('[{"key": "bar", "value": "foos"}, {"key": "foo", "value": "bar"}, {"key": "foo", "value": "baz"}]', json_encode($tags)); + } + + // -------------------------------------- + + private static function assertTagsMatch(array $expected, Tags $actual): void + { + self::assertSame($expected, $actual->toSimpleArray()); + } +} \ No newline at end of file