diff --git a/src/Helpers/InMemoryEventStore.php b/src/Helpers/InMemoryEventStore.php index cb4c442..ddae208 100644 --- a/src/Helpers/InMemoryEventStore.php +++ b/src/Helpers/InMemoryEventStore.php @@ -4,6 +4,7 @@ namespace Wwwision\DCBEventStore\Helpers; +use DateTimeImmutable; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; use Wwwision\DCBEventStore\Types\AppendCondition; @@ -84,6 +85,7 @@ public function append(Events $events, AppendCondition $condition): void $sequenceNumber++; $this->eventEnvelopes[] = new EventEnvelope( SequenceNumber::fromInteger($sequenceNumber), + new DateTimeImmutable(), $event, ); } diff --git a/src/Types/Event.php b/src/Types/Event.php index a35c45f..411eb4b 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -14,7 +14,7 @@ public function __construct( public readonly EventType $type, public readonly EventData $data, // opaque, no size limit? public readonly Tags $tags, - // add metadata ? + public readonly EventMetadata $metadata, ) { } } diff --git a/src/Types/EventEnvelope.php b/src/Types/EventEnvelope.php index 685de06..6e8da1d 100644 --- a/src/Types/EventEnvelope.php +++ b/src/Types/EventEnvelope.php @@ -8,14 +8,12 @@ /** * An {@see Event} with its global {@see SequenceNumber} in the Events Store - * - * */ final class EventEnvelope { public function __construct( public readonly SequenceNumber $sequenceNumber, - //public DateTimeImmutable $recordedAt, // do we need it + public DateTimeImmutable $recordedAt, public readonly Event $event, ) { } diff --git a/src/Types/EventMetadata.php b/src/Types/EventMetadata.php new file mode 100644 index 0000000..573f85d --- /dev/null +++ b/src/Types/EventMetadata.php @@ -0,0 +1,62 @@ + $value + */ + private function __construct( + public readonly array $value, + ) { + Assert::isMap($value, 'EventMetadata must consist of an associative array with string keys'); + } + + public static function none(): self + { + return new self([]); + } + + /** + * @param array $value + */ + public static function fromArray(array $value): self + { + return new self($value); + } + + public static function fromJson(string $json): self + { + try { + $metadata = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new InvalidArgumentException(sprintf('Failed to decode JSON to event metadata: %s', $e->getMessage()), 1692197194, $e); + } + Assert::isArray($metadata, 'Failed to decode JSON to event metadata'); + return self::fromArray($metadata); + } + + public function with(string $key, mixed $value): self + { + return new self([...$this->value, $key => $value]); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->value; + } +} diff --git a/src/Types/Events.php b/src/Types/Events.php index 4ec0017..a6becb6 100644 --- a/src/Types/Events.php +++ b/src/Types/Events.php @@ -30,9 +30,9 @@ private function __construct(Event ...$events) $this->events = $events; } - public static function single(EventId $id, EventType $type, EventData $data, Tags $tags): self + public static function single(EventId $id, EventType $type, EventData $data, Tags $tags, EventMetadata $metadata): self { - return new self(new Event($id, $type, $data, $tags)); + return new self(new Event($id, $type, $data, $tags, $metadata)); } /** diff --git a/tests/Integration/EventStoreConcurrencyTestBase.php b/tests/Integration/EventStoreConcurrencyTestBase.php index 2923bc9..18a3502 100644 --- a/tests/Integration/EventStoreConcurrencyTestBase.php +++ b/tests/Integration/EventStoreConcurrencyTestBase.php @@ -10,6 +10,7 @@ use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; @@ -89,7 +90,7 @@ public function test_consistency(int $process): void for ($i = 0; $i < $numberOfEvents; $i++) { $descriptor = $process . '(' . getmypid() . ') ' . $eventBatch . '.' . ($i + 1) . '/' . $numberOfEvents; $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))); + $events[] = new Event(EventId::create(), self::either(...$eventTypes), EventData::fromString(json_encode($eventData, JSON_THROW_ON_ERROR)), Tags::create(...self::some($numberOfTags, ...$tags)), EventMetadata::none()); } try { static::createEventStore()->append(Events::fromArray($events), new AppendCondition($query, $expectedHighestSequenceNumber)); diff --git a/tests/Integration/EventStoreTestBase.php b/tests/Integration/EventStoreTestBase.php index 10d90eb..81155d3 100644 --- a/tests/Integration/EventStoreTestBase.php +++ b/tests/Integration/EventStoreTestBase.php @@ -8,6 +8,7 @@ use Wwwision\DCBEventStore\EventStream; use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed; use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; @@ -398,6 +399,7 @@ private static function arrayToEvent(array $event): Event EventType::fromString($event['type'] ?? 'SomeEventType'), EventData::fromString($event['data'] ?? ''), Tags::fromArray($event['tags'] ?? ['foo:bar']), + EventMetadata::fromArray($event['metadata'] ?? ['foo' => 'bar']), ); } } \ No newline at end of file diff --git a/tests/Unit/Types/EventMetadataTest.php b/tests/Unit/Types/EventMetadataTest.php new file mode 100644 index 0000000..cd0d609 --- /dev/null +++ b/tests/Unit/Types/EventMetadataTest.php @@ -0,0 +1,60 @@ +value); + } + + public function test_fromArray_fails_if_array_is_not_associative(): void + { + $this->expectException(InvalidArgumentException::class); + EventMetadata::fromArray(['foo', 'bar']); + } + + public function test_fromJson_fails_if_value_is_no_valid_json(): void + { + $this->expectException(InvalidArgumentException::class); + EventMetadata::fromJson('no json'); + } + + public function test_fromJson_fails_if_value_is_no_json_object(): void + { + $this->expectException(InvalidArgumentException::class); + EventMetadata::fromJson('"no array"'); + } + + public function test_fromJson_fails_if_value_is_no_associative_json_object(): void + { + $this->expectException(InvalidArgumentException::class); + EventMetadata::fromJson('["foo", "bar"]'); + } + + public function test_with_sets_metadata_value(): void + { + self::assertSame(['foo' => 'bar'], EventMetadata::none()->with('foo', 'bar')->value); + } + + public function test_with_overrides_previously_set_value(): void + { + self::assertSame(['foo' => 'replaced'], EventMetadata::none()->with('foo', 'bar')->with('foo', 'replaced')->value); + } + + public function test_jsonSerializable(): void + { + $actualResult = json_encode(EventMetadata::none()->with('foo', 'bar')->with('bar', 'baz')); + self::assertJsonStringEqualsJsonString('{"foo": "bar", "bar": "baz"}', $actualResult); + } + +} \ No newline at end of file diff --git a/tests/Unit/Types/EventTypesTest.php b/tests/Unit/Types/EventTypesTest.php index a1744c1..b0cf483 100644 --- a/tests/Unit/Types/EventTypesTest.php +++ b/tests/Unit/Types/EventTypesTest.php @@ -10,6 +10,7 @@ use Wwwision\DCBEventStore\Types\EventTypes; #[CoversClass(EventTypes::class)] +#[CoversClass(EventType::class)] final class EventTypesTest extends TestCase { diff --git a/tests/Unit/Types/StreamQuery/StreamQueryTest.php b/tests/Unit/Types/StreamQuery/StreamQueryTest.php index c9df8f3..5d59ed4 100644 --- a/tests/Unit/Types/StreamQuery/StreamQueryTest.php +++ b/tests/Unit/Types/StreamQuery/StreamQueryTest.php @@ -8,6 +8,7 @@ use Wwwision\DCBEventStore\Types\Event; use Wwwision\DCBEventStore\Types\EventData; use Wwwision\DCBEventStore\Types\EventId; +use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\EventType; use Wwwision\DCBEventStore\Types\EventTypes; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; @@ -15,11 +16,17 @@ use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; +use Wwwision\DCBEventStore\Types\Tag; use Wwwision\DCBEventStore\Types\Tags; #[CoversClass(StreamQuery::class)] #[CoversClass(Tags::class)] +#[CoversClass(Tag::class)] #[CoversClass(EventTypes::class)] +#[CoversClass(Criteria::class)] +#[CoversClass(TagsCriterion::class)] +#[CoversClass(EventTypesCriterion::class)] +#[CoversClass(EventTypesAndTagsCriterion::class)] final class StreamQueryTest extends TestCase { @@ -27,7 +34,7 @@ public static function dataprovider_no_match(): iterable { $eventType = EventType::fromString('SomeEventType'); $tags = Tags::fromArray(['foo:bar', 'bar:baz']); - $event = new Event(EventId::create(), $eventType, EventData::fromString(''), $tags); + $event = new Event(EventId::create(), $eventType, EventData::fromString(''), $tags, EventMetadata::none()); yield 'different tag' => ['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]; @@ -35,7 +42,7 @@ public static function dataprovider_no_match(): iterable 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 'partially matching tags' => ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::fromArray(['foo:bar', 'bar:not_baz'])))), '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'))]; + 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'), EventMetadata::none())]; } /** @@ -50,7 +57,7 @@ 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); + $event = new Event(EventId::create(), $eventType, EventData::fromString(''), $tags, EventMetadata::none()); yield 'matching tag type and value' => ['query' => StreamQuery::create(Criteria::create(new TagsCriterion(Tags::single('foo', 'bar')))), 'event' => $event]; yield 'matching event type' => ['query' => StreamQuery::create(Criteria::create(new EventTypesCriterion(EventTypes::single('SomeEventType')))), 'event' => $event]; diff --git a/tests/Unit/Types/TagsTest.php b/tests/Unit/Types/TagsTest.php index 4ccc9c5..656e45f 100644 --- a/tests/Unit/Types/TagsTest.php +++ b/tests/Unit/Types/TagsTest.php @@ -13,6 +13,7 @@ use const JSON_THROW_ON_ERROR; #[CoversClass(Tags::class)] +#[CoversClass(Tag::class)] final class TagsTest extends TestCase {