diff --git a/README.md b/README.md index 036b9d6..9ab48cf 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ + [Event sourcing](#event-sourcing) + [Snapshots](#snapshots) + [Upcasting](#upcasting) + - [Defining an upcaster](#defining-an-upcaster) + - [Upcasting an event](#upcasting-an-event) + - [Chaining upcasters](#chaining-upcasters) + - [Reconstituting from an iterable](#reconstituting-from-an-iterable) + - [Default values for new fields](#default-values-for-new-fields) * [FAQ](#faq) * [License](#license) * [Contributing](#contributing) @@ -19,10 +24,7 @@ The `Building Blocks` library provides the tactical design building blocks of Domain-Driven Design: `Entity`, `Identity`, `AggregateRoot`, and the infrastructure required to carry domain events through a transactional outbox -or an event-sourced store. - -It is persistence-agnostic and framework-agnostic. It depends only on the other `tiny-blocks` primitives -(`immutable-object`, `value-object`, `collection`, `time`) and `ramsey/uuid` for event identifiers. +or an event-sourced store. It is persistence-agnostic and framework-agnostic. Domain events defined here are plain PHP objects fully compatible with any PSR-14 dispatcher. The library does not replace PSR-14; it defines what flows through it. @@ -44,7 +46,8 @@ The library exposes three styles of aggregate modeling through sibling interface ### Entity -Every entity declares an `IDENTITY` constant pointing to the property that holds its `Identity`. +Every entity implements a protected `identityName()` method returning the name of the property that holds its +`Identity`. #### Single-field identity @@ -93,7 +96,7 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds #### Identity access * `getIdentity`, `getIdentityValue`, `sameIdentityOf`, `identityEquals`: provided by `EntityBehavior` for any entity - that declares the `IDENTITY` constant. + that implements `identityName()`. ```php use TinyBlocks\BuildingBlocks\Aggregate\AggregateRoot; @@ -103,11 +106,14 @@ Every entity declares an `IDENTITY` constant pointing to the property that holds { use AggregateRootBehavior; - private const string IDENTITY = 'userId'; - private function __construct(private UserId $userId, private string $email) { } + + protected function identityName(): string + { + return 'userId'; + } } $user->sameIdentityOf(other: $otherUser); @@ -129,21 +135,31 @@ control and a `ModelVersion` for schema evolution of the aggregate type itself. { use AggregateRootBehavior; - private const string IDENTITY = 'userId'; + protected function identityName(): string + { + return 'userId'; + } } $user->getSequenceNumber(); ``` -* `getModelVersion`: resolved from the optional `MODEL_VERSION` class constant, defaults to zero when absent. +* `getModelVersion`: resolved from the protected `modelVersion()` method, defaults to zero when not overridden. ```php final class Cart implements AggregateRoot { use AggregateRootBehavior; - private const string IDENTITY = 'cartId'; - private const int MODEL_VERSION = 1; + protected function identityName(): string + { + return 'cartId'; + } + + protected function modelVersion(): int + { + return 1; + } } $cart->getModelVersion(); @@ -177,7 +193,7 @@ emitted as side effects and must be delivered at-least-once. #### Emitting events from the aggregate -* `pushEvent`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a +* `push`: protected method on `EventualAggregateRootBehavior`. Increments the sequence number and appends a fully-built `EventRecord` to the recorded buffer. The `Revision` is provided on the call site, so the event class stays pure. @@ -190,8 +206,6 @@ emitted as side effects and must be delivered at-least-once. { use EventualAggregateRootBehavior; - private const string IDENTITY = 'orderId'; - private function __construct(private OrderId $orderId) { } @@ -199,10 +213,15 @@ emitted as side effects and must be delivered at-least-once. public static function place(OrderId $orderId, string $item): Order { $order = new Order(orderId: $orderId); - $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1)); + $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); return $order; } + + protected function identityName(): string + { + return 'orderId'; + } } ``` @@ -240,14 +259,12 @@ emitted as side effects and must be delivered at-least-once. { use EventSourcingRootBehavior; - private const string IDENTITY = 'cartId'; - private CartId $cartId; private array $productIds = []; public function addProduct(string $productId): void { - $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1)); + $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); } public function applySnapshot(Snapshot $snapshot): void @@ -255,6 +272,11 @@ emitted as side effects and must be delivered at-least-once. $this->productIds = $snapshot->getAggregateState()['productIds'] ?? []; } + protected function identityName(): string + { + return 'cartId'; + } + protected function whenProductAdded(ProductAdded $event): void { $this->productIds[] = $event->productId; @@ -383,13 +405,47 @@ Upcasters migrate serialized events across schema changes without touching the e $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); $upcasted = new ProductV1Upcaster()->upcast(event: $event); ``` +#### Chaining upcasters + +* `Upcasters`: ordered collection of `Upcaster` instances. `chain` folds them left-to-right over an + `IntermediateEvent`, applying each upcaster in sequence. Upcasters that do not match the current `(type, revision)` + pair pass the event through unchanged. + + ```php + use TinyBlocks\BuildingBlocks\Upcast\Upcasters; + + $upcasters = Upcasters::createFrom(elements: [ + new ProductV1Upcaster(), + new ProductV2Upcaster(), + ]); + + $upcasted = $upcasters->chain(event: $event); + ``` + +#### Reconstituting from an iterable + +* `IntermediateEvent` implements `ObjectMapper`, so it can be reconstituted from an iterable of typed field values. + Pass already-constructed `EventType` and `Revision` instances — the mapper maps each field by name. + + ```php + use TinyBlocks\BuildingBlocks\Event\EventType; + use TinyBlocks\BuildingBlocks\Event\Revision; + use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; + + $event = IntermediateEvent::fromIterable(iterable: [ + 'type' => EventType::fromString(value: 'ProductAdded'), + 'revision' => Revision::of(value: 2), + 'serializedEvent' => ['productId' => 'prod-1', 'quantity' => 1], + ]); + ``` + #### Default values for new fields * `DefaultValues`: type-to-default-value map for common primitive types, used when an upcast introduces a new field. @@ -412,7 +468,7 @@ the event itself. Keeping the event pure prevents infrastructure concerns from l Only the aggregate has the context needed to build the complete envelope: identity, sequence number, aggregate type name. Storing raw events and wrapping them later would either duplicate that context or require a second pass. -`pushEvent` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation. +`push` builds the full `EventRecord` immediately, and the outbox adapter reads them as-is with no translation. ### 03. Why are `EventualAggregateRoot` and `EventSourcingRoot` siblings instead of a hierarchy? @@ -422,7 +478,7 @@ would imply the two patterns can coexist on the same aggregate, which they canno ### 04. Why does `Revision` live on the call site instead of on the event class? -Keeping `Revision` on the `pushEvent` or `when` call site makes the aggregate the author of schema evolution. The +Keeping `Revision` on the `push` or `when` call site makes the aggregate the author of schema evolution. The event class stays pure. Bumping the revision of an existing event does not require creating a new class. ### 05. Why does `blank` skip the constructor? @@ -440,11 +496,28 @@ consumers to decide which copy is authoritative. ### 07. Why are custom exceptions declared under `Internal\Exceptions` instead of the root namespace? -Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, `MissingIdentityConstant` -and `MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or +Custom exceptions such as `InvalidEventType`, `InvalidRevision`, `InvalidSequenceNumber`, and +`MissingIdentityProperty` are implementation details. They extend `InvalidArgumentException` or `RuntimeException` from the PHP standard library, so consumers that catch the broad standard types continue to work; consumers that need precise handling can catch the specific classes. +### 08. Why did `IDENTITY` and `MODEL_VERSION` move from constants to methods? + +Class constants read by reflection inside traits are invisible to static analyzers such as PHPStan and Psalm. Every +concrete aggregate had to annotate `@phpstan-ignore-next-line` or equivalent suppressions just to satisfy level-9 +analysis. Replacing them with a protected `identityName(): string` method and a protected `modelVersion(): int` +method makes the contract explicit in PHP's type system: the compiler enforces implementation, IDEs can navigate to +it, and static analyzers raise no warnings — in the library or at consumer sites. + +### 09. Why do `Revision`, `SequenceNumber`, and `EventType` now have private constructors? + +These value objects have named static factories that carry semantic meaning: `Revision::initial()` communicates +"first schema revision", `SequenceNumber::first()` communicates "first recorded event", and +`EventType::fromEvent($event)` communicates "derive the type name from this event". Leaving the constructor public +allowed `new Revision(value: 1)` at call sites, which bypasses the semantic intent and mixes raw construction with +factory conventions. A private constructor forces all creation through the factories, making the intent visible at +every call site. The `of()` factory on `Revision` and `SequenceNumber` covers the loading-from-persistence path. + ## License Building Blocks is licensed under [MIT](https://github.com/tiny-blocks/building-blocks/blob/main/LICENSE). diff --git a/composer.json b/composer.json index a343f8e..076c563 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "ramsey/uuid": "^4.9", "tiny-blocks/collection": "^2.3", "tiny-blocks/immutable-object": "^1.1", + "tiny-blocks/mapper": "^2.0", "tiny-blocks/time": "^1.5", "tiny-blocks/value-object": "^3.2" }, diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 6115fe6..6e9089c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,4 +11,6 @@ parameters: - '#destructuring on mixed#' - identifier: trait.unused - '#expects int<1, max>#' + - message: '#Upcasters::chain\(\) should return#' + path: src/Upcast/Upcasters.php reportUnmatchedIgnoredErrors: false diff --git a/src/Aggregate/AggregateRoot.php b/src/Aggregate/AggregateRoot.php index ddbc59c..0118b16 100644 --- a/src/Aggregate/AggregateRoot.php +++ b/src/Aggregate/AggregateRoot.php @@ -44,11 +44,11 @@ public function getSequenceNumber(): SequenceNumber; /** * Returns the schema version of this aggregate type. * - *

Resolved from the optional MODEL_VERSION class constant, defaults to 0 - * when the constant is not declared. Used by consumers to migrate aggregate schemas when loading older + *

Resolved from the protected modelVersion() method, defaults to 0 + * when the method is not overridden. Used by consumers to migrate aggregate schemas when loading older * persisted state.

* - * @return SequenceNumber The declared model version, or 0 when undefined. + * @return SequenceNumber The declared model version, or 0 when not overridden. */ public function getModelVersion(): SequenceNumber; diff --git a/src/Aggregate/AggregateRootBehavior.php b/src/Aggregate/AggregateRootBehavior.php index 6cb166b..1c36c77 100644 --- a/src/Aggregate/AggregateRootBehavior.php +++ b/src/Aggregate/AggregateRootBehavior.php @@ -28,11 +28,7 @@ public function getSequenceNumber(): SequenceNumber public function getModelVersion(): SequenceNumber { - if (!defined('static::MODEL_VERSION')) { - return new SequenceNumber(value: 0); - } - - return new SequenceNumber(value: static::MODEL_VERSION); + return SequenceNumber::of(value: $this->modelVersion()); } public function buildAggregateName(): string @@ -40,11 +36,24 @@ public function buildAggregateName(): string return new ReflectionClass(objectOrClass: static::class)->getShortName(); } + protected function modelVersion(): int + { + return 0; + } + protected function nextSequenceNumber(): void { $this->sequenceNumber = $this->getSequenceNumber()->next(); } + protected function generateSnapshotData(): SnapshotData + { + $state = get_object_vars($this); + unset($state['recordedEvents']); + + return new SnapshotData(data: $state); + } + protected function buildEventRecord(DomainEvent $event, Revision $revision): EventRecord { return new EventRecord( @@ -59,12 +68,4 @@ protected function buildEventRecord(DomainEvent $event, Revision $revision): Eve sequenceNumber: $this->getSequenceNumber() ); } - - protected function generateSnapshotData(): SnapshotData - { - $state = get_object_vars($this); - unset($state['recordedEvents']); - - return new SnapshotData(data: $state); - } } diff --git a/src/Aggregate/EventSourcingRoot.php b/src/Aggregate/EventSourcingRoot.php index 7e19e9c..801b703 100644 --- a/src/Aggregate/EventSourcingRoot.php +++ b/src/Aggregate/EventSourcingRoot.php @@ -7,7 +7,7 @@ use TinyBlocks\BuildingBlocks\Entity\Identity; use TinyBlocks\BuildingBlocks\Event\EventRecord; use TinyBlocks\BuildingBlocks\Event\EventRecords; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; +use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; /** @@ -41,7 +41,7 @@ public function recordedEvents(): EventRecords; * * @param Identity $identity The identity to assign to the new aggregate. * @return static A new aggregate in its initial state. - * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + * @throws MissingIdentityProperty When the property referenced by identityName() does not exist. */ public static function blank(Identity $identity): static; @@ -56,7 +56,7 @@ public static function blank(Identity $identity): static; * @param iterable $records The event stream to replay, ordered by sequence number. * @param Snapshot|null $snapshot Optional snapshot to restore from before replay. * @return static The reconstituted aggregate. - * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. + * @throws MissingIdentityProperty When the property referenced by identityName() does not exist. */ public static function reconstitute(Identity $identity, iterable $records, ?Snapshot $snapshot = null): static; diff --git a/src/Aggregate/EventSourcingRootBehavior.php b/src/Aggregate/EventSourcingRootBehavior.php index fa02b38..2503bbd 100644 --- a/src/Aggregate/EventSourcingRootBehavior.php +++ b/src/Aggregate/EventSourcingRootBehavior.php @@ -12,7 +12,6 @@ use TinyBlocks\BuildingBlocks\Event\EventRecords; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; trait EventSourcingRootBehavior @@ -30,12 +29,9 @@ public function recordedEvents(): EventRecords public static function blank(Identity $identity): static { - if (!defined('static::IDENTITY')) { - throw new MissingIdentityConstant(className: static::class); - } - $aggregate = new ReflectionClass(objectOrClass: static::class)->newInstanceWithoutConstructor(); - new ReflectionProperty($aggregate, static::IDENTITY)->setValue(objectOrValue: $aggregate, value: $identity); + new ReflectionProperty($aggregate, $aggregate->identityName()) + ->setValue(objectOrValue: $aggregate, value: $identity); $aggregate->sequenceNumber = SequenceNumber::initial(); $aggregate->recordedEvents = EventRecords::createFromEmpty(); diff --git a/src/Aggregate/EventualAggregateRootBehavior.php b/src/Aggregate/EventualAggregateRootBehavior.php index 8a30a79..4d57625 100644 --- a/src/Aggregate/EventualAggregateRootBehavior.php +++ b/src/Aggregate/EventualAggregateRootBehavior.php @@ -26,7 +26,7 @@ public function clearRecordedEvents(): void $this->recordedEvents = EventRecords::createFromEmpty(); } - protected function pushEvent(DomainEvent $event, Revision $revision): void + protected function push(DomainEvent $event, Revision $revision): void { $this->nextSequenceNumber(); $this->recordedEvents = ($this->recordedEvents ?? EventRecords::createFromEmpty()) diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php index ea4e9f5..5917022 100644 --- a/src/Entity/Entity.php +++ b/src/Entity/Entity.php @@ -4,7 +4,6 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; /** @@ -14,8 +13,8 @@ * across distinct representations and lifecycle transitions. Two entities are equal when their * identities are equal, regardless of attribute differences.

* - *

Concrete entities declare the IDENTITY class constant pointing to the property that - * holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.

+ *

Concrete entities implement the protected identityName() method returning the property + * that holds their {@see Identity}. The default behavior uses reflection to resolve and compare it.

* * @see Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software * (Addison-Wesley, 2003), Chapter 5 "Entities (a.k.a. Reference Objects)". @@ -26,17 +25,15 @@ interface Entity * Returns the Identity that uniquely identifies this entity. * * @return Identity The identity instance held by this entity. - * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. - * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist. + * @throws MissingIdentityProperty When the property referenced by identityName() does not exist. */ public function getIdentity(): Identity; /** * Returns the name of the property that holds this entity's Identity. * - * @return string The property name, resolved from the IDENTITY class constant. - * @throws MissingIdentityConstant When the IDENTITY class constant is not defined. - * @throws MissingIdentityProperty When the property referenced by IDENTITY does not exist. + * @return string The property name, resolved from identityName(). + * @throws MissingIdentityProperty When the property referenced by identityName() does not exist. */ public function getIdentityName(): string; diff --git a/src/Entity/EntityBehavior.php b/src/Entity/EntityBehavior.php index 5cccb42..7a84bfb 100644 --- a/src/Entity/EntityBehavior.php +++ b/src/Entity/EntityBehavior.php @@ -4,18 +4,15 @@ namespace TinyBlocks\BuildingBlocks\Entity; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; trait EntityBehavior { + abstract protected function identityName(): string; + public function getIdentityName(): string { - if (!defined('static::IDENTITY')) { - throw new MissingIdentityConstant(className: static::class); - } - - $name = static::IDENTITY; + $name = $this->identityName(); if (!property_exists($this, $name)) { throw new MissingIdentityProperty(propertyName: $name, className: static::class); diff --git a/src/Event/EventType.php b/src/Event/EventType.php index a05c9a1..4937e19 100644 --- a/src/Event/EventType.php +++ b/src/Event/EventType.php @@ -15,7 +15,7 @@ private const string PATTERN = '/^[A-Z][A-Za-z0-9]+$/'; - public function __construct(public string $value) + private function __construct(public string $value) { if (!preg_match(pattern: self::PATTERN, subject: $value)) { throw new InvalidEventType(value: $value, pattern: self::PATTERN); diff --git a/src/Event/Revision.php b/src/Event/Revision.php index 51f0fce..9a8738c 100644 --- a/src/Event/Revision.php +++ b/src/Event/Revision.php @@ -12,10 +12,20 @@ { use ValueObjectBehavior; - public function __construct(public int $value) + private function __construct(public int $value) { if ($value < 1) { throw new InvalidRevision(value: $value); } } + + public static function initial(): Revision + { + return new Revision(value: 1); + } + + public static function of(int $value): Revision + { + return new Revision(value: $value); + } } diff --git a/src/Event/SequenceNumber.php b/src/Event/SequenceNumber.php index c52f21a..217906b 100644 --- a/src/Event/SequenceNumber.php +++ b/src/Event/SequenceNumber.php @@ -12,7 +12,7 @@ { use ValueObjectBehavior; - public function __construct(public int $value) + private function __construct(public int $value) { if ($value < 0) { throw new InvalidSequenceNumber(value: $value); @@ -29,6 +29,11 @@ public static function first(): SequenceNumber return new SequenceNumber(value: 1); } + public static function of(int $value): SequenceNumber + { + return new SequenceNumber(value: $value); + } + public function next(): SequenceNumber { return new SequenceNumber(value: $this->value + 1); diff --git a/src/Internal/Exceptions/MissingIdentityConstant.php b/src/Internal/Exceptions/MissingIdentityConstant.php deleted file mode 100644 index 3327ea7..0000000 --- a/src/Internal/Exceptions/MissingIdentityConstant.php +++ /dev/null @@ -1,15 +0,0 @@ -.', $className)); - } -} diff --git a/src/Upcast/IntermediateEvent.php b/src/Upcast/IntermediateEvent.php index 12da42f..b6e882f 100644 --- a/src/Upcast/IntermediateEvent.php +++ b/src/Upcast/IntermediateEvent.php @@ -6,12 +6,15 @@ use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; +use TinyBlocks\Mapper\ObjectMapper; +use TinyBlocks\Mapper\ObjectMappability; use TinyBlocks\Vo\ValueObject; use TinyBlocks\Vo\ValueObjectBehavior; -final readonly class IntermediateEvent implements ValueObject +final readonly class IntermediateEvent implements ValueObject, ObjectMapper { use ValueObjectBehavior; + use ObjectMappability; public function __construct( public EventType $type, diff --git a/src/Upcast/SingleUpcasterBehavior.php b/src/Upcast/SingleUpcasterBehavior.php index f19511f..5d0712d 100644 --- a/src/Upcast/SingleUpcasterBehavior.php +++ b/src/Upcast/SingleUpcasterBehavior.php @@ -20,7 +20,7 @@ public function upcast(IntermediateEvent $event): IntermediateEvent return $event ->withSerializedEvent(serializedEvent: $this->doUpcast(data: $event->serializedEvent)) - ->withRevision(revision: new Revision(value: static::TO_REVISION)); + ->withRevision(revision: Revision::of(value: static::TO_REVISION)); } abstract protected function doUpcast(array $data): array; diff --git a/src/Upcast/Upcaster.php b/src/Upcast/Upcaster.php index 70c3e5a..278871d 100644 --- a/src/Upcast/Upcaster.php +++ b/src/Upcast/Upcaster.php @@ -16,6 +16,8 @@ * (EXPECTED_EVENT_TYPE, FROM_REVISION, TO_REVISION) triple through class constants and * delegates the payload transformation to an abstract doUpcast() hook.

* + *

To apply a sequence of upcasters in order, use {@see Upcasters::chain}.

+ * * @see Greg Young, Versioning in an Event Sourced System (Leanpub, 2017), * "Basic Type Based Conversion" and "Upcasting". */ diff --git a/src/Upcast/Upcasters.php b/src/Upcast/Upcasters.php new file mode 100644 index 0000000..2dd8d93 --- /dev/null +++ b/src/Upcast/Upcasters.php @@ -0,0 +1,19 @@ +upcast(event: $carried); + }; + + return $this->reduce(accumulator: $upcast, initial: $event); + } +} diff --git a/tests/Aggregate/EventSourcingRootBehaviorTest.php b/tests/Aggregate/EventSourcingRootBehaviorTest.php index b6c4251..4561e60 100644 --- a/tests/Aggregate/EventSourcingRootBehaviorTest.php +++ b/tests/Aggregate/EventSourcingRootBehaviorTest.php @@ -7,9 +7,7 @@ use PHPUnit\Framework\TestCase; use Test\TinyBlocks\BuildingBlocks\Models\Cart; use Test\TinyBlocks\BuildingBlocks\Models\CartId; -use Test\TinyBlocks\BuildingBlocks\Models\CartWithoutIdentityConstant; use Test\TinyBlocks\BuildingBlocks\Models\ProductAdded; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Snapshot\Snapshot; final class EventSourcingRootBehaviorTest extends TestCase @@ -262,17 +260,6 @@ public function testReconstituteCombinedWithSnapshotAndLaterEventsAdvancesSequen self::assertSame(2, $reconstituted->getSequenceNumber()->value); } - public function testBlankThrowsWhenIdentityConstantIsMissing(): void - { - /** @Given an event-sourced aggregate class omitting the IDENTITY constant */ - /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ - $this->expectException(MissingIdentityConstant::class); - $this->expectExceptionMessage(CartWithoutIdentityConstant::class); - - /** @When creating a blank aggregate */ - CartWithoutIdentityConstant::blank(identity: new CartId(value: 'cart-missing')); - } - public function testReconstitutedAggregateHasNoRecordedEvents(): void { /** @Given a cart with one recorded event */ diff --git a/tests/Entity/EntityBehaviorTest.php b/tests/Entity/EntityBehaviorTest.php index 38da3fd..056e4d9 100644 --- a/tests/Entity/EntityBehaviorTest.php +++ b/tests/Entity/EntityBehaviorTest.php @@ -9,8 +9,6 @@ use Test\TinyBlocks\BuildingBlocks\Models\Order; use Test\TinyBlocks\BuildingBlocks\Models\OrderId; use Test\TinyBlocks\BuildingBlocks\Models\OrderWithMissingIdentityProperty; -use Test\TinyBlocks\BuildingBlocks\Models\OrderWithoutIdentityConstant; -use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityConstant; use TinyBlocks\BuildingBlocks\Internal\Exceptions\MissingIdentityProperty; final class EntityBehaviorTest extends TestCase @@ -38,7 +36,7 @@ public function testGetIdentityNameReturnsPropertyName(): void /** @When retrieving the identity property name */ $name = $order->getIdentityName(); - /** @Then it matches the IDENTITY constant value */ + /** @Then it matches the value returned by identityName() */ self::assertSame('orderId', $name); } @@ -126,22 +124,9 @@ public function testIdentityEqualsReturnsFalseForDifferentIdentity(): void self::assertFalse($result); } - public function testShipThrowsWhenIdentityConstantIsMissing(): void - { - /** @Given an aggregate whose class omits the IDENTITY constant */ - $order = new OrderWithoutIdentityConstant(); - - /** @Then a MissingIdentityConstant exception carrying the class name is thrown */ - $this->expectException(MissingIdentityConstant::class); - $this->expectExceptionMessage(OrderWithoutIdentityConstant::class); - - /** @When shipping the order and indirectly reaching identity resolution */ - $order->ship(); - } - public function testShipThrowsWhenIdentityPropertyIsMissing(): void { - /** @Given an aggregate whose IDENTITY points to a non-existent property */ + /** @Given an aggregate whose identityName() points to a non-existent property */ $order = new OrderWithMissingIdentityProperty(); /** @Then a MissingIdentityProperty exception carrying the property name is thrown */ diff --git a/tests/Event/EventRecordTest.php b/tests/Event/EventRecordTest.php index d61f8c5..8663cd2 100644 --- a/tests/Event/EventRecordTest.php +++ b/tests/Event/EventRecordTest.php @@ -24,10 +24,10 @@ public function testEventRecordExposesEveryConstructorField(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: ['status' => 'placed']); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @When constructing the EventRecord */ $record = new EventRecord( @@ -61,10 +61,10 @@ public function testEqualsReturnsTrueForRecordsBuiltFromEqualValues(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: []); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @And two records constructed from those identical values */ $first = new EventRecord( @@ -103,10 +103,10 @@ public function testEqualsReturnsFalseForRecordsWithDifferentIdentifiers(): void $orderId = new OrderId(value: 'ord-1'); $placedEvent = new OrderPlaced(item: 'book'); $eventType = EventType::fromString(value: 'OrderPlaced'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $occurredOn = Instant::now(); $snapshotData = new SnapshotData(data: []); - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); /** @And two records with different UUIDs */ $first = new EventRecord( diff --git a/tests/Event/EventRecordsTest.php b/tests/Event/EventRecordsTest.php index 2f0c6d4..7133d65 100644 --- a/tests/Event/EventRecordsTest.php +++ b/tests/Event/EventRecordsTest.php @@ -41,11 +41,11 @@ public function testAddingARecordYieldsACollectionOfOneElement(): void type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), identity: new OrderId(value: 'ord-1'), - revision: new Revision(value: 1), + revision: Revision::initial(), occurredOn: Instant::now(), snapshotData: new SnapshotData(data: []), aggregateType: 'Order', - sequenceNumber: new SequenceNumber(value: 1) + sequenceNumber: SequenceNumber::first() ); /** @When adding the record */ @@ -63,11 +63,11 @@ public function testFirstElementRoundTripsTheAddedRecord(): void type: EventType::fromString(value: 'OrderPlaced'), event: new OrderPlaced(item: 'book'), identity: new OrderId(value: 'ord-1'), - revision: new Revision(value: 1), + revision: Revision::initial(), occurredOn: Instant::now(), snapshotData: new SnapshotData(data: []), aggregateType: 'Order', - sequenceNumber: new SequenceNumber(value: 1) + sequenceNumber: SequenceNumber::first() ); $records = EventRecords::createFromEmpty()->add($record); diff --git a/tests/Event/EventTypeTest.php b/tests/Event/EventTypeTest.php index 7f7b63c..f84daaf 100644 --- a/tests/Event/EventTypeTest.php +++ b/tests/Event/EventTypeTest.php @@ -7,12 +7,23 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use Test\TinyBlocks\BuildingBlocks\Models\OrderPlaced; use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidEventType; final class EventTypeTest extends TestCase { + public function testConstructorIsPrivate(): void + { + /** @Given the EventType class constructor */ + $constructor = new ReflectionMethod(EventType::class, '__construct'); + + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + public function testFromEventUsesTheShortClassNameOfTheDomainEvent(): void { /** @Given a domain event instance */ @@ -66,7 +77,7 @@ public function testEqualsReturnsFalseForDifferentValues(): void } #[DataProvider('invalidPatterns')] - public function testConstructorRejectsValuesNotMatchingPattern(string $invalidValue): void + public function testFromStringRejectsValuesNotMatchingPattern(string $invalidValue): void { /** @Given a value that violates the event-type pattern */ /** @Then an InvalidEventType exception mentioning the pattern is thrown */ @@ -74,7 +85,7 @@ public function testConstructorRejectsValuesNotMatchingPattern(string $invalidVa $this->expectExceptionMessage('does not match the required pattern'); /** @When constructing with the invalid value */ - new EventType(value: $invalidValue); + EventType::fromString(value: $invalidValue); } public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): void @@ -84,7 +95,7 @@ public function testInvalidEventTypeIsCatchableAsInvalidArgumentException(): voi $this->expectException(InvalidArgumentException::class); /** @When constructing with an invalid value */ - new EventType(value: 'invalid'); + EventType::fromString(value: 'invalid'); } public function testInvalidEventTypeCarriesTheOffendingValue(): void @@ -95,7 +106,7 @@ public function testInvalidEventTypeCarriesTheOffendingValue(): void $this->expectExceptionMessage('lowercaseStart'); /** @When constructing with an invalid value */ - new EventType(value: 'lowercaseStart'); + EventType::fromString(value: 'lowercaseStart'); } /** diff --git a/tests/Event/RevisionTest.php b/tests/Event/RevisionTest.php index b20f308..7ccde97 100644 --- a/tests/Event/RevisionTest.php +++ b/tests/Event/RevisionTest.php @@ -7,38 +7,59 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidRevision; final class RevisionTest extends TestCase { - public function testRevisionStoresTheMinimumValidValue(): void + public function testConstructorIsPrivate(): void { - /** @Given the minimum valid revision value */ - /** @When constructing the revision */ - $revision = new Revision(value: 1); + /** @Given the Revision class constructor */ + $constructor = new ReflectionMethod(Revision::class, '__construct'); - /** @Then the value is stored verbatim */ + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + + public function testInitialReturnsRevisionOfOne(): void + { + /** @Given the initial revision factory */ + /** @When requesting the initial revision */ + $revision = Revision::initial(); + + /** @Then the value is one */ self::assertSame(1, $revision->value); } - public function testRevisionStoresAHigherValidValue(): void + public function testOfReturnsRevisionWithGivenValue(): void { - /** @Given a larger valid revision value */ - /** @When constructing the revision */ - $revision = new Revision(value: 42); + /** @Given a valid revision value */ + /** @When requesting a revision of that value */ + $revision = Revision::of(value: 42); - /** @Then the value is stored verbatim */ + /** @Then the value matches */ self::assertSame(42, $revision->value); } + public function testOfStoresTheMinimumValidValue(): void + { + /** @Given the minimum valid revision value */ + /** @When constructing the revision via factory */ + $revision = Revision::of(value: 1); + + /** @Then the value is stored verbatim */ + self::assertSame(1, $revision->value); + } + public function testEqualsReturnsTrueForSameRevision(): void { /** @Given two revisions with the same value */ - $first = new Revision(value: 2); + $first = Revision::of(value: 2); /** @And a matching counterpart */ - $second = new Revision(value: 2); + $second = Revision::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -50,10 +71,10 @@ public function testEqualsReturnsTrueForSameRevision(): void public function testEqualsReturnsFalseForDifferentRevisions(): void { /** @Given two revisions with different values */ - $first = new Revision(value: 1); + $first = Revision::of(value: 1); /** @And a distinct counterpart */ - $second = new Revision(value: 2); + $second = Revision::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -63,7 +84,7 @@ public function testEqualsReturnsFalseForDifferentRevisions(): void } #[DataProvider('invalidValues')] - public function testConstructorRejectsNonPositiveValue(int $invalidValue): void + public function testOfRejectsNonPositiveValue(int $invalidValue): void { /** @Given a value that violates the revision invariant */ /** @Then an InvalidRevision exception carrying the invalid value is thrown */ @@ -71,7 +92,7 @@ public function testConstructorRejectsNonPositiveValue(int $invalidValue): void $this->expectExceptionMessage((string) $invalidValue); /** @When constructing with a non-positive value */ - new Revision(value: $invalidValue); + Revision::of(value: $invalidValue); } public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void @@ -81,7 +102,7 @@ public function testInvalidRevisionIsCatchableAsInvalidArgumentException(): void $this->expectException(InvalidArgumentException::class); /** @When constructing with an invalid value */ - new Revision(value: 0); + Revision::of(value: 0); } public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void @@ -92,7 +113,7 @@ public function testInvalidRevisionMessageMentionsTheMinimumAllowed(): void $this->expectExceptionMessage('greater than or equal to 1'); /** @When constructing with an invalid value */ - new Revision(value: 0); + Revision::of(value: 0); } /** diff --git a/tests/Event/SequenceNumberTest.php b/tests/Event/SequenceNumberTest.php index 9d9782f..1c326cf 100644 --- a/tests/Event/SequenceNumberTest.php +++ b/tests/Event/SequenceNumberTest.php @@ -7,11 +7,22 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use TinyBlocks\BuildingBlocks\Event\SequenceNumber; use TinyBlocks\BuildingBlocks\Internal\Exceptions\InvalidSequenceNumber; final class SequenceNumberTest extends TestCase { + public function testConstructorIsPrivate(): void + { + /** @Given the SequenceNumber class constructor */ + $constructor = new ReflectionMethod(SequenceNumber::class, '__construct'); + + /** @When inspecting its visibility */ + /** @Then the constructor is private */ + self::assertTrue($constructor->isPrivate()); + } + public function testInitialYieldsZero(): void { /** @Given the initial-sequence factory */ @@ -32,10 +43,20 @@ public function testFirstYieldsOne(): void self::assertSame(1, $sequenceNumber->value); } + public function testOfReturnsSequenceNumberWithGivenValue(): void + { + /** @Given a valid sequence number value */ + /** @When requesting a sequence number of that value */ + $sequenceNumber = SequenceNumber::of(value: 5); + + /** @Then the value matches */ + self::assertSame(5, $sequenceNumber->value); + } + public function testNextYieldsTheFollowingValue(): void { /** @Given a sequence number of 5 */ - $sequenceNumber = new SequenceNumber(value: 5); + $sequenceNumber = SequenceNumber::of(value: 5); /** @When advancing to the next */ $next = $sequenceNumber->next(); @@ -47,7 +68,7 @@ public function testNextYieldsTheFollowingValue(): void public function testNextDoesNotMutateTheSource(): void { /** @Given a sequence number of 5 */ - $sequenceNumber = new SequenceNumber(value: 5); + $sequenceNumber = SequenceNumber::of(value: 5); /** @When advancing */ $sequenceNumber->next(); @@ -59,10 +80,10 @@ public function testNextDoesNotMutateTheSource(): void public function testIsAfterReturnsTrueWhenStrictlyGreater(): void { /** @Given a larger sequence number */ - $larger = new SequenceNumber(value: 10); + $larger = SequenceNumber::of(value: 10); /** @And a smaller counterpart */ - $smaller = new SequenceNumber(value: 5); + $smaller = SequenceNumber::of(value: 5); /** @When checking if the larger is after the smaller */ $result = $larger->isAfter(other: $smaller); @@ -74,10 +95,10 @@ public function testIsAfterReturnsTrueWhenStrictlyGreater(): void public function testIsAfterReturnsFalseWhenEqual(): void { /** @Given two equal sequence numbers */ - $first = new SequenceNumber(value: 3); + $first = SequenceNumber::of(value: 3); /** @And a counterpart with the same value */ - $second = new SequenceNumber(value: 3); + $second = SequenceNumber::of(value: 3); /** @When checking if one is strictly after the other */ $result = $first->isAfter(other: $second); @@ -89,10 +110,10 @@ public function testIsAfterReturnsFalseWhenEqual(): void public function testIsAfterReturnsFalseWhenStrictlySmaller(): void { /** @Given a smaller sequence number */ - $smaller = new SequenceNumber(value: 2); + $smaller = SequenceNumber::of(value: 2); /** @And a larger counterpart */ - $larger = new SequenceNumber(value: 8); + $larger = SequenceNumber::of(value: 8); /** @When checking if the smaller is after the larger */ $result = $smaller->isAfter(other: $larger); @@ -104,10 +125,10 @@ public function testIsAfterReturnsFalseWhenStrictlySmaller(): void public function testEqualsReturnsTrueForSameValue(): void { /** @Given two sequence numbers with the same value */ - $first = new SequenceNumber(value: 7); + $first = SequenceNumber::of(value: 7); /** @And a matching counterpart */ - $second = new SequenceNumber(value: 7); + $second = SequenceNumber::of(value: 7); /** @When comparing them */ $result = $first->equals(other: $second); @@ -119,10 +140,10 @@ public function testEqualsReturnsTrueForSameValue(): void public function testEqualsReturnsFalseForDifferentValues(): void { /** @Given two sequence numbers with different values */ - $first = new SequenceNumber(value: 1); + $first = SequenceNumber::of(value: 1); /** @And a distinct counterpart */ - $second = new SequenceNumber(value: 2); + $second = SequenceNumber::of(value: 2); /** @When comparing them */ $result = $first->equals(other: $second); @@ -132,7 +153,7 @@ public function testEqualsReturnsFalseForDifferentValues(): void } #[DataProvider('negativeValues')] - public function testConstructorRejectsNegativeValue(int $negativeValue): void + public function testOfRejectsNegativeValue(int $negativeValue): void { /** @Given a value that violates the sequence-number invariant */ /** @Then an InvalidSequenceNumber exception carrying the invalid value is thrown */ @@ -140,7 +161,7 @@ public function testConstructorRejectsNegativeValue(int $negativeValue): void $this->expectExceptionMessage((string) $negativeValue); /** @When constructing with a negative value */ - new SequenceNumber(value: $negativeValue); + SequenceNumber::of(value: $negativeValue); } public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException(): void @@ -150,7 +171,7 @@ public function testInvalidSequenceNumberIsCatchableAsInvalidArgumentException() $this->expectException(InvalidArgumentException::class); /** @When constructing with a negative value */ - new SequenceNumber(value: -1); + SequenceNumber::of(value: -1); } public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): void @@ -161,7 +182,7 @@ public function testInvalidSequenceNumberMessageMentionsTheMinimumAllowed(): voi $this->expectExceptionMessage('greater than or equal to 0'); /** @When constructing with a negative value */ - new SequenceNumber(value: -1); + SequenceNumber::of(value: -1); } /** diff --git a/tests/Models/Cart.php b/tests/Models/Cart.php index 821def9..1017d48 100644 --- a/tests/Models/Cart.php +++ b/tests/Models/Cart.php @@ -13,9 +13,6 @@ final class Cart implements EventSourcingRoot { use EventSourcingRootBehavior; - private const string IDENTITY = 'cartId'; - private const int MODEL_VERSION = 1; - private CartId $cartId; /** @var list */ @@ -23,7 +20,7 @@ final class Cart implements EventSourcingRoot public function addProduct(string $productId): void { - $this->when(event: new ProductAdded(productId: $productId), revision: new Revision(value: 1)); + $this->when(event: new ProductAdded(productId: $productId), revision: Revision::initial()); } public function applySnapshot(Snapshot $snapshot): void @@ -40,6 +37,16 @@ public function getProductIds(): array return $this->productIds; } + protected function identityName(): string + { + return 'cartId'; + } + + protected function modelVersion(): int + { + return 1; + } + protected function whenProductAdded(ProductAdded $event): void { $this->productIds[] = $event->productId; diff --git a/tests/Models/CartWithoutIdentityConstant.php b/tests/Models/CartWithoutIdentityConstant.php deleted file mode 100644 index 2d574cb..0000000 --- a/tests/Models/CartWithoutIdentityConstant.php +++ /dev/null @@ -1,18 +0,0 @@ -status = 'placed'; - $order->pushEvent(event: new OrderPlaced(item: $item), revision: new Revision(value: 1)); + $order->push(event: new OrderPlaced(item: $item), revision: Revision::initial()); return $order; } @@ -32,11 +30,16 @@ public static function place(OrderId $orderId, string $item): Order public function ship(string $carrier): void { $this->status = 'shipped'; - $this->pushEvent(event: new OrderShipped(carrier: $carrier), revision: new Revision(value: 1)); + $this->push(event: new OrderShipped(carrier: $carrier), revision: Revision::initial()); } public function getStatus(): string { return $this->status; } + + protected function identityName(): string + { + return 'orderId'; + } } diff --git a/tests/Models/OrderWithMissingIdentityProperty.php b/tests/Models/OrderWithMissingIdentityProperty.php index 8a03d0e..072aa86 100644 --- a/tests/Models/OrderWithMissingIdentityProperty.php +++ b/tests/Models/OrderWithMissingIdentityProperty.php @@ -12,10 +12,13 @@ final class OrderWithMissingIdentityProperty implements EventualAggregateRoot { use EventualAggregateRootBehavior; - private const string IDENTITY = 'nonExistentProperty'; - public function ship(): void { - $this->pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1)); + $this->push(event: new OrderShipped(carrier: 'DHL'), revision: Revision::initial()); + } + + protected function identityName(): string + { + return 'nonExistentProperty'; } } diff --git a/tests/Models/OrderWithoutIdentityConstant.php b/tests/Models/OrderWithoutIdentityConstant.php deleted file mode 100644 index cd73ce8..0000000 --- a/tests/Models/OrderWithoutIdentityConstant.php +++ /dev/null @@ -1,19 +0,0 @@ -pushEvent(event: new OrderShipped(carrier: 'DHL'), revision: new Revision(value: 1)); - } -} diff --git a/tests/Models/ProductV2Upcaster.php b/tests/Models/ProductV2Upcaster.php new file mode 100644 index 0000000..f34e7b1 --- /dev/null +++ b/tests/Models/ProductV2Upcaster.php @@ -0,0 +1,22 @@ + '']; + } +} diff --git a/tests/Snapshot/SnapshotTest.php b/tests/Snapshot/SnapshotTest.php index 8ad8fbf..4177b9f 100644 --- a/tests/Snapshot/SnapshotTest.php +++ b/tests/Snapshot/SnapshotTest.php @@ -121,7 +121,7 @@ public function testRoundTripThroughSnapshotRestoresDomainState(): void public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void { /** @Given shared fields for two snapshots */ - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); $createdAt = Instant::now(); /** @And two snapshots built from those identical fields */ @@ -150,7 +150,7 @@ public function testEqualsReturnsTrueForIdenticallyBuiltSnapshots(): void public function testEqualsReturnsFalseWhenAnyFieldDiffers(): void { /** @Given two snapshots that differ only by type */ - $sequenceNumber = new SequenceNumber(value: 1); + $sequenceNumber = SequenceNumber::first(); $createdAt = Instant::now(); /** @And the two snapshots constructed accordingly */ diff --git a/tests/Upcast/IntermediateEventTest.php b/tests/Upcast/IntermediateEventTest.php index 9790c75..9be2660 100644 --- a/tests/Upcast/IntermediateEventTest.php +++ b/tests/Upcast/IntermediateEventTest.php @@ -8,6 +8,7 @@ use TinyBlocks\BuildingBlocks\Event\EventType; use TinyBlocks\BuildingBlocks\Event\Revision; use TinyBlocks\BuildingBlocks\Upcast\IntermediateEvent; +use TinyBlocks\Mapper\KeyPreservation; final class IntermediateEventTest extends TestCase { @@ -15,7 +16,7 @@ public function testIntermediateEventExposesEveryConstructorField(): void { /** @Given every required field for an IntermediateEvent */ $eventType = EventType::fromString(value: 'ProductAdded'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $serializedEvent = ['productId' => 'prod-1']; /** @When constructing the intermediate event */ @@ -32,12 +33,12 @@ public function testWithRevisionOnlyReplacesTheRevision(): void /** @Given an intermediate event at revision 1 */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); /** @When bumping to revision 2 */ - $bumped = $event->withRevision(revision: new Revision(value: 2)); + $bumped = $event->withRevision(revision: Revision::of(value: 2)); /** @Then the revision changes */ self::assertSame(2, $bumped->revision->value); @@ -48,12 +49,12 @@ public function testWithRevisionPreservesTheTypeAndPayload(): void /** @Given an intermediate event at revision 1 */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); /** @When bumping to revision 2 */ - $bumped = $event->withRevision(revision: new Revision(value: 2)); + $bumped = $event->withRevision(revision: Revision::of(value: 2)); /** @Then neither the type nor the payload are affected */ self::assertSame('ProductAdded', $bumped->type->value); @@ -65,12 +66,12 @@ public function testWithRevisionReturnsANewInstance(): void /** @Given an intermediate event at revision 1 */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); /** @When bumping to revision 2 */ - $bumped = $event->withRevision(revision: new Revision(value: 2)); + $bumped = $event->withRevision(revision: Revision::of(value: 2)); /** @Then the source event remains untouched */ self::assertNotSame($event, $bumped); @@ -82,7 +83,7 @@ public function testWithSerializedEventOnlyReplacesThePayload(): void /** @Given an intermediate event with an original payload */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); @@ -98,7 +99,7 @@ public function testWithSerializedEventPreservesTheTypeAndRevision(): void /** @Given an intermediate event with an original payload */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); @@ -114,7 +115,7 @@ public function testEqualsReturnsTrueForIdenticalIntermediateEvents(): void { /** @Given shared fields for two intermediate events */ $eventType = EventType::fromString(value: 'ProductAdded'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); $payload = ['productId' => 'prod-1']; /** @And two intermediate events with identical values */ @@ -132,7 +133,7 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void { /** @Given two intermediate events with different payloads */ $eventType = EventType::fromString(value: 'ProductAdded'); - $revision = new Revision(value: 1); + $revision = Revision::initial(); /** @And the two events constructed accordingly */ $first = new IntermediateEvent(type: $eventType, revision: $revision, serializedEvent: ['productId' => 'a']); @@ -144,4 +145,61 @@ public function testEqualsReturnsFalseForDifferentPayloads(): void /** @Then they are not equal */ self::assertFalse($result); } + + public function testFromIterableWithTypedFieldsCreatesEqualEvent(): void + { + /** @Given an existing intermediate event */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When reconstituting from an iterable of typed values */ + $restored = IntermediateEvent::fromIterable(iterable: [ + 'type' => EventType::fromString(value: 'ProductAdded'), + 'revision' => Revision::initial(), + 'serializedEvent' => ['productId' => 'prod-1'] + ]); + + /** @Then the restored event equals the original */ + self::assertTrue($restored->equals(other: $event)); + } + + public function testToArraySerializesTypeAndRevisionToScalars(): void + { + /** @Given an intermediate event */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When converting to array */ + $array = $event->toArray(KeyPreservation::PRESERVE); + + /** @Then type and revision are unwrapped to their scalar values */ + self::assertSame('ProductAdded', $array['type']); + self::assertSame(1, $array['revision']); + self::assertSame(['productId' => 'prod-1'], $array['serializedEvent']); + } + + public function testToJsonSerializesToJsonString(): void + { + /** @Given an intermediate event */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @When converting to JSON */ + $json = $event->toJson(KeyPreservation::PRESERVE); + + /** @Then the result is a valid JSON string with the expected structure */ + self::assertSame( + '{"type":"ProductAdded","revision":1,"serializedEvent":{"productId":"prod-1"}}', + $json + ); + } } diff --git a/tests/Upcast/SingleUpcasterBehaviorTest.php b/tests/Upcast/SingleUpcasterBehaviorTest.php index df0a744..cb1ccc9 100644 --- a/tests/Upcast/SingleUpcasterBehaviorTest.php +++ b/tests/Upcast/SingleUpcasterBehaviorTest.php @@ -17,7 +17,7 @@ public function testUpcastBumpsTheRevisionOfAMatchingEvent(): void /** @Given a ProductAdded event at revision 1 */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); @@ -33,7 +33,7 @@ public function testUpcastEnrichesThePayloadOfAMatchingEvent(): void /** @Given a ProductAdded event at revision 1 */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['productId' => 'prod-1'] ); @@ -49,7 +49,7 @@ public function testUpcastReturnsUnchangedEventForMismatchedType(): void /** @Given an event whose type is not the one the upcaster handles */ $event = new IntermediateEvent( type: EventType::fromString(value: 'OrderPlaced'), - revision: new Revision(value: 1), + revision: Revision::initial(), serializedEvent: ['item' => 'book'] ); @@ -65,7 +65,7 @@ public function testUpcastReturnsUnchangedEventForMismatchedRevision(): void /** @Given a ProductAdded event at revision 2, past the upcaster's FROM_REVISION */ $event = new IntermediateEvent( type: EventType::fromString(value: 'ProductAdded'), - revision: new Revision(value: 2), + revision: Revision::of(value: 2), serializedEvent: ['productId' => 'prod-1', 'quantity' => 1] ); diff --git a/tests/Upcast/UpcastersTest.php b/tests/Upcast/UpcastersTest.php new file mode 100644 index 0000000..1ec84c4 --- /dev/null +++ b/tests/Upcast/UpcastersTest.php @@ -0,0 +1,111 @@ + 'prod-1'] + ); + + /** @When chaining through an empty Upcasters collection */ + $result = Upcasters::createFromEmpty()->chain(event: $event); + + /** @Then the event is returned unchanged */ + self::assertTrue($result->equals(other: $event)); + } + + public function testSingleMatchingUpcasterTransformsEvent(): void + { + /** @Given an event at revision 1 eligible for V1 migration */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @And a chain containing only the V1 upcaster */ + $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]); + + /** @When chaining the event */ + $result = $upcasters->chain(event: $event); + + /** @Then the revision advances to 2 and the payload gains the quantity field */ + self::assertSame(2, $result->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1], $result->serializedEvent); + } + + public function testSingleNonMatchingUpcasterReturnsEventUnchanged(): void + { + /** @Given an event at revision 2 — past the V1 migration window */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::of(value: 2), + serializedEvent: ['productId' => 'prod-1', 'quantity' => 1] + ); + + /** @And a chain containing only the V1 upcaster */ + $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster()]); + + /** @When chaining the event */ + $result = $upcasters->chain(event: $event); + + /** @Then the event is returned unchanged */ + self::assertTrue($result->equals(other: $event)); + } + + public function testChainedUpcastersApplySequentially(): void + { + /** @Given an event at revision 1 eligible for both V1 and V2 migrations */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::initial(), + serializedEvent: ['productId' => 'prod-1'] + ); + + /** @And a chain with both upcasters in order */ + $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]); + + /** @When chaining the event */ + $result = $upcasters->chain(event: $event); + + /** @Then the revision reaches 3 and both fields are added */ + self::assertSame(3, $result->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent); + } + + public function testOnlyMatchingUpcastersInChainApply(): void + { + /** @Given an event at revision 2 — only eligible for V2 migration */ + $event = new IntermediateEvent( + type: EventType::fromString(value: 'ProductAdded'), + revision: Revision::of(value: 2), + serializedEvent: ['productId' => 'prod-1', 'quantity' => 1] + ); + + /** @And a chain with both upcasters */ + $upcasters = Upcasters::createFrom(elements: [new ProductV1Upcaster(), new ProductV2Upcaster()]); + + /** @When chaining the event */ + $result = $upcasters->chain(event: $event); + + /** @Then only V2 applies: revision advances to 3 and notes is added */ + self::assertSame(3, $result->revision->value); + self::assertSame(['productId' => 'prod-1', 'quantity' => 1, 'notes' => ''], $result->serializedEvent); + } +}