Skip to content

Commit

Permalink
Merge pull request #8 from bwaidelich/feature/event-metadata
Browse files Browse the repository at this point in the history
FEATURE: Event metadata and recorded at timestamp
  • Loading branch information
bwaidelich authored Aug 16, 2023
2 parents 94fdc5a + 28b81e5 commit 236d2db
Show file tree
Hide file tree
Showing 11 changed files with 144 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/Helpers/InMemoryEventStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Wwwision\DCBEventStore\Helpers;

use DateTimeImmutable;
use Wwwision\DCBEventStore\EventStore;
use Wwwision\DCBEventStore\Exceptions\ConditionalAppendFailed;
use Wwwision\DCBEventStore\Types\AppendCondition;
Expand Down Expand Up @@ -84,6 +85,7 @@ public function append(Events $events, AppendCondition $condition): void
$sequenceNumber++;
$this->eventEnvelopes[] = new EventEnvelope(
SequenceNumber::fromInteger($sequenceNumber),
new DateTimeImmutable(),
$event,
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Types/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
4 changes: 1 addition & 3 deletions src/Types/EventEnvelope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand Down
62 changes: 62 additions & 0 deletions src/Types/EventMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBEventStore\Types;

use InvalidArgumentException;
use JsonException;
use JsonSerializable;
use Webmozart\Assert\Assert;

/**
* Array-based metadata of an event
*/
final class EventMetadata implements JsonSerializable
{
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->value;
}
}
4 changes: 2 additions & 2 deletions src/Types/Events.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
3 changes: 2 additions & 1 deletion tests/Integration/EventStoreConcurrencyTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 2 additions & 0 deletions tests/Integration/EventStoreTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']),
);
}
}
60 changes: 60 additions & 0 deletions tests/Unit/Types/EventMetadataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);

namespace Unit\Types;

use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Wwwision\DCBEventStore\Types\EventMetadata;

#[CoversClass(EventMetadata::class)]
final class EventMetadataTest extends TestCase
{

public function test_none_creates_empty_instance(): void
{
self::assertSame([], EventMetadata::none()->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);
}

}
1 change: 1 addition & 0 deletions tests/Unit/Types/EventTypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Wwwision\DCBEventStore\Types\EventTypes;

#[CoversClass(EventTypes::class)]
#[CoversClass(EventType::class)]
final class EventTypesTest extends TestCase
{

Expand Down
13 changes: 10 additions & 3 deletions tests/Unit/Types/StreamQuery/StreamQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,41 @@
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;
use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion;
use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesCriterion;
use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\TagsCriterion;
use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery;
use Wwwision\DCBEventStore\Types\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
{

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];
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 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())];
}

/**
Expand All @@ -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];
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/Types/TagsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use const JSON_THROW_ON_ERROR;

#[CoversClass(Tags::class)]
#[CoversClass(Tag::class)]
final class TagsTest extends TestCase
{

Expand Down

0 comments on commit 236d2db

Please sign in to comment.