diff --git a/composer.json b/composer.json index 30ef0ccc7..8099663e1 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "doctrine/dbal": "^4.4.0", "doctrine/migrations": "^3.3.2", - "patchlevel/hydrator": "^1.8.0", + "patchlevel/hydrator": "^2.0.0", "patchlevel/worker": "^1.4.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/clock": "^1.0", diff --git a/composer.lock b/composer.lock index 21a4f4e7d..528d0217c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8cf3383e3c323c67f6456b7cb8e765fd", + "content-hash": "cfa5afcb62df6c72bda64c12b2da7096", "packages": [ { "name": "brick/math", @@ -416,16 +416,16 @@ }, { "name": "patchlevel/hydrator", - "version": "1.24.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "b33d9f92b25114156e9935c12c563195afdbeb13" + "reference": "1c37e647aa36e058ab27358a7ce798f0f87b0413" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/b33d9f92b25114156e9935c12c563195afdbeb13", - "reference": "b33d9f92b25114156e9935c12c563195afdbeb13", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/1c37e647aa36e058ab27358a7ce798f0f87b0413", + "reference": "1c37e647aa36e058ab27358a7ce798f0f87b0413", "shasum": "" }, "require": { @@ -433,7 +433,6 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/simple-cache": "^2.0.0 || ^3.0.0", - "symfony/event-dispatcher": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", "symfony/type-info": "^7.3.0 || ^8.0.0" }, "require-dev": { @@ -466,17 +465,21 @@ "email": "david.badura@patchlevel.de" } ], - "description": "Hydrator", - "homepage": "https://github.com/patchlevel/hydrator", + "description": "A library for seamless hydration of objects to arrays - and back again, optimized for developer experience and performance", + "homepage": "https://patchlevel.dev/docs/hydrator/latest", "keywords": [ + "denormalizer", "hydrator", + "normalizer", + "object mapping", + "patchlevel", "serializer" ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/1.24.0" + "source": "https://github.com/patchlevel/hydrator/tree/2.0.0" }, - "time": "2026-06-13T11:46:58+00:00" + "time": "2026-06-15T11:53:43+00:00" }, { "name": "patchlevel/worker", diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index cfdaddb68..62d7a26e6 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -327,3 +327,277 @@ and replaced with the following headers: The `Patchlevel\EventSourcing\Schema\DoctrineSchemaSubscriber` has been removed. use the `Patchlevel\EventSourcing\Schema\DoctrineSchemaListener` instead. + +## Serializer + +The library now uses `patchlevel/hydrator` 2.0. The `Patchlevel\Hydrator\MetadataHydrator` +has been removed. Build a hydrator with the `StackHydratorBuilder` and the `CoreExtension` instead. +Upcasting and crypto-shredding are no longer wired through the serializer factories, +you register them on the hydrator as middleware or extension. + +before: + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = new MetadataHydrator(); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); +``` + +### DefaultEventSerializer + +`createFromPaths()` no longer accepts an `$upcaster` or a `$cryptographer` argument. +The second argument is now an optional `Hydrator`, a default one is built when it is `null`. +Register upcasting via the `UpcastExtension` and crypto-shredding via the `CryptographyExtension` +on the hydrator you pass in. + +before: + +```php +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; +use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; + +/** + * @var Upcaster $upcaster + * @var PayloadCryptographer $cryptographer + */ +$serializer = DefaultEventSerializer::createFromPaths( + [__DIR__ . '/Events'], + $upcaster, + $cryptographer, +); +``` +after: + +```php +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Extension\Upcast\Upcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +/** + * @var Upcaster $upcaster + * @var CipherKeyStore $cipherKeyStore + */ +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->useExtension(new UpcastExtension(beforeEncoding: [$upcaster])) + ->build(); + +$serializer = DefaultEventSerializer::createFromPaths( + [__DIR__ . '/Events'], + $hydrator, +); +``` + +### Upcasting + +The event-sourcing upcasting classes have been removed in favor of the hydrator upcast extension: + +* `Patchlevel\EventSourcing\Serializer\Upcast\Upcaster` +* `Patchlevel\EventSourcing\Serializer\Upcast\Upcast` +* `Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain` + +Implement `Patchlevel\Hydrator\Extension\Upcast\Upcaster` (or use `CallbackUpcaster`) instead and register +your upcasters with the `UpcastExtension`. The upcaster no longer receives an `Upcast` object, it now +works on the payload array directly and selects the event by the class name from the metadata. +Renaming an event through an upcaster is no longer possible, use event aliases instead. + +before: + +```php +use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; +use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; + +final class ProfileCreatedEmailLowerCastUpcaster implements Upcaster +{ + public function __invoke(Upcast $upcast): Upcast + { + if ($upcast->eventName !== 'profile.created') { + return $upcast; + } + + return $upcast->replacePayloadByKey('email', strtolower($upcast->payload['email'])); + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Upcast\Upcaster; +use Patchlevel\Hydrator\Metadata\ClassMetadata; + +final class ProfileCreatedEmailLowerCastUpcaster implements Upcaster +{ + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return array + */ + public function upcast(ClassMetadata $metadata, array $data, array $context): array + { + if ($metadata->className !== ProfileCreated::class) { + return $data; + } + + $data['email'] = strtolower($data['email']); + + return $data; + } +} +``` + +### DefaultHeadersSerializer + +The `$hydrator` argument of the constructor, `createFromPaths()` and `createDefault()` +is now an optional `Hydrator` defaulting to `null`. The `MetadataHydrator` default has been removed. + +## Snapshots + +### DefaultSnapshotStore + +The constructor no longer accepts an array of adapters as its first argument, +it now requires an `AdapterRepository`. Pass adapters as an array through `createDefault()` instead. + +before: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; + +$snapshotStore = new DefaultSnapshotStore(['default' => $adapter]); +``` +after: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; + +$snapshotStore = DefaultSnapshotStore::createDefault(['default' => $adapter]); +``` + +The `$cryptographer` argument of `createDefault()` has been replaced by an optional `Hydrator`. +Build the hydrator with the `CryptographyExtension` like for the event serializer. + +before: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; + +/** @var PayloadCryptographer $cryptographer */ +$snapshotStore = DefaultSnapshotStore::createDefault( + ['default' => $adapter], + $cryptographer, +); +``` +after: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\Hydrator\Hydrator; + +/** @var Hydrator $hydrator */ +$snapshotStore = DefaultSnapshotStore::createDefault( + ['default' => $adapter], + $hydrator, +); +``` + +## Sensitive Data + +The crypto-shredding stack moved to the cryptography extension of `patchlevel/hydrator` 2.0. + +### Attributes + +The attributes moved namespace and `PersonalData` was renamed to `SensitiveData`: + +* `Patchlevel\Hydrator\Attribute\DataSubjectId` -> `Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId` +* `Patchlevel\Hydrator\Attribute\PersonalData` -> `Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData` + +before: + +```php +use Patchlevel\EventSourcing\Identifier\Uuid; +use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Attribute\PersonalData; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly Uuid $profileId, + #[PersonalData(fallback: 'unknown')] + public readonly string $email, + ) { + } +} +``` +after: + +```php +use Patchlevel\EventSourcing\Identifier\Uuid; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly Uuid $profileId, + #[SensitiveData(fallback: 'unknown')] + public readonly string $email, + ) { + } +} +``` + +### DoctrineCipherKeyStore + +The legacy `Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore` and +`Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore` have been merged into a +single `Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore` that implements the new +`Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore`. The default table name changed +from `crypto_keys` to `cryptography_keys`, and keys are now stored per id with a subject index. + +To erase the data of a subject, call `removeWithSubjectId()`, the `remove()` method now deletes by key id. + +before: + +```php +$cipherKeyStore->remove($subjectId); +``` +after: + +```php +$cipherKeyStore->removeWithSubjectId($subjectId); +``` + +:::danger +The key table layout changed (`crypto_keys` -> `cryptography_keys` with new columns). +Existing keys must be migrated, otherwise stored sensitive data can no longer be decrypted. +::: + +### Cryptographer + +`Patchlevel\Hydrator\Cryptography\PayloadCryptographer` and its implementations +(`PersonalDataPayloadCryptographer`, `SensitiveDataPayloadCryptographer`) have been removed. +Create a `Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer` and register it on the +hydrator through the `CryptographyExtension` (see the Serializer section above). diff --git a/docs/introduction.md b/docs/introduction.md index b75d15139..e366fdabb 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -11,7 +11,7 @@ powered by the reliable Doctrine ecosystem and focused on developer experience. * Automatic [snapshot](snapshots.md)-system to boost your performance * [Split](split-stream.md) big aggregates into multiple streams * Versioned and managed lifecycle of [subscriptions](subscription.md) like projections and processors -* Safe usage of [Personal Data](personal-data.md) with crypto-shredding +* Safe usage of [Personal Data](sensitive-data.md) with crypto-shredding * Smooth [upcasting](upcasting.md) of old events * Simple setup with [schema management](store.md) and [doctrine migration](store.md) * Built in [cli commands](cli.md) with [symfony](https://symfony.com/) diff --git a/docs/normalizer.md b/docs/normalizer.md index 260618d70..0d695e201 100644 --- a/docs/normalizer.md +++ b/docs/normalizer.md @@ -100,7 +100,7 @@ final class HotelCreated } ``` :::note -If you have personal data, you can use [crypto-shredding](personal-data.md). + If you have personal data, you can use [crypto-shredding](sensitive_data.md). ::: ### Aggregate @@ -459,4 +459,4 @@ final class DTO * [How to define aggregates](aggregate.md) * [How to define events](events.md) * [How to snapshot aggregates](snapshots.md) -* [How to work with personal data](personal-data.md) +* [How to work with personal data](sensitive-data.md) diff --git a/docs/personal-data.md b/docs/sensitive-data.md similarity index 74% rename from docs/personal-data.md rename to docs/sensitive-data.md index dd5878f70..70b862d25 100644 --- a/docs/personal-data.md +++ b/docs/sensitive-data.md @@ -1,4 +1,4 @@ -# Personal Data (GDPR) +# Sensitive Data According to GDPR, personal data must be able to be deleted upon request. But here we have the problem that our events are immutable and we cannot easily manipulate the event store. @@ -27,7 +27,7 @@ Without Subject Id, no personal data can be encrypted or decrypted. ```php use Patchlevel\EventSourcing\Identifier\Uuid; -use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; final class EmailChanged { @@ -42,23 +42,22 @@ final class EmailChanged :::tip You can use the `DataSubjectId` in aggregates for snapshots too. -::: - -### PersonalData +::: +### SensitiveData -Next, you have to mark the properties that should be encrypted with the `#[PersonalData]` attribute. +Next, you have to mark the properties that should be encrypted with the `#[SensitiveData]` attribute. ```php use Patchlevel\EventSourcing\Identifier\Uuid; -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class EmailChanged { public function __construct( #[DataSubjectId] public readonly Uuid $profileId, - #[PersonalData] + #[SensitiveData] public readonly string|null $email, ) { } @@ -66,7 +65,7 @@ final class EmailChanged ``` :::tip -You can use the `PersonalData` in aggregates for snapshots too. +You can use the `SensitiveData` in aggregates for snapshots too. ::: If the information could not be decrypted, then a fallback value will be used. @@ -74,16 +73,16 @@ The default fallback value is `null`. You can change this by setting the `fallback` parameter or using the `fallbackCallable` parameter. ```php -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; final class ProfileChanged { public function __construct( #[DataSubjectId] public readonly Uuid $profileId, - #[PersonalData(fallback: 'unknown')] + #[SensitiveData(fallback: 'unknown')] public readonly string $name, - #[PersonalData(fallbackCallable: [self::class, 'createAnonymousEmail'])] + #[SensitiveData(fallbackCallable: [self::class, 'createAnonymousEmail'])] public readonly string $email, ) { } @@ -141,34 +140,43 @@ $schemaDirector = new DoctrineSchemaDirector( ]), ); ``` -### Personal Data Payload Cryptographer +### Hydrator -Now we have to put the whole thing together in a Personal Data Payload Cryptographer. +Now we put the whole thing together. The cryptographer encrypts and decrypts the data, +and is registered on a hydrator via the cryptography extension. ```php -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\StackHydratorBuilder; /** @var CipherKeyStore $cipherKeyStore */ -$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); +$cryptographer = BaseCryptographer::createWithOpenssl($cipherKeyStore); + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension($cryptographer)) + ->build(); ``` :::tip -You can specify the cipher method with the second parameter. +You can specify the cipher method with the second parameter of `createWithOpenssl`. ::: ### Event Serializer Integration -The last step is to integrate the cryptographer into the event store. +The last step is to integrate the hydrator into the event store. ```php use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Hydrator; -/** @var PersonalDataPayloadCryptographer $cryptographer */ +/** @var Hydrator $hydrator */ DefaultEventSerializer::createFromPaths( [__DIR__ . '/Events'], - cryptographer: $cryptographer, + $hydrator, ); ``` @@ -182,14 +190,14 @@ And for the snapshot store. ```php use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Hydrator; -/** @var PersonalDataPayloadCryptographer $cryptographer */ +/** @var Hydrator $hydrator */ $snapshotStore = DefaultSnapshotStore::createDefault( [ /* adapters... */ ], - $cryptographer, + $hydrator, ); ``` @@ -209,10 +217,10 @@ To remove personal data, you can either remove the key manually or do it with a use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; #[Processor('delete_personal_data')] -final class DeletePersonalDataProcessor +final class DeleteSensitiveDataProcessor { public function __construct( private readonly CipherKeyStore $cipherKeyStore, @@ -224,7 +232,7 @@ final class DeletePersonalDataProcessor { $event = $message->event(); - $this->cipherKeyStore->remove($event->personId); + $this->cipherKeyStore->removeWithSubjectId($event->personId); } } ``` diff --git a/docs/snapshots.md b/docs/snapshots.md index 6e8bf93da..cfd8d2dc0 100644 --- a/docs/snapshots.md +++ b/docs/snapshots.md @@ -261,4 +261,4 @@ You still have to bring the aggregate up to date by loading the missing events f * [How to define aggregates](aggregate.md) * [How to store and load aggregates](repository.md) * [How to split streams](split-stream.md) -* [How to work with personal data](personal-data.md) +* [How to work with personal data](sensitive-data.md) diff --git a/docs/upcasting.md b/docs/upcasting.md index e7d122c83..c9683a34b 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -3,93 +3,123 @@ There are cases where we already have events in our stream but there is data missing or not in the right format for our new usecase. Normally you would need to create versioned events for this. This can lead to many versions of the same event which could lead to some chaos. -To prevent this we offer `Upcaster`, which can operate on the payload before denormalizing to an event object. -There you can change the event name and adjust the payload of the event. +To prevent this we offer upcasting, which can operate on the payload before it is denormalized to an event object. + +Upcasting is part of the hydrator. You write an `Upcaster`, or use the `CallbackUpcaster`, +and register it on the hydrator through the `UpcastExtension`. ## Adjust payload -Let's assume we have an `ProfileCreated` event which holds an email. -Now the business needs to have all emails to be in lower case. +Let's assume we have a `ProfileCreated` event which holds an email. +Now the business needs all emails to be in lower case. For that we could adjust the aggregate and the projections to take care of that. Or we can do this beforehand so we don't need to maintain two different places. ```php -use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; +use Patchlevel\Hydrator\Extension\Upcast\Upcaster; +use Patchlevel\Hydrator\Metadata\ClassMetadata; final class ProfileCreatedEmailLowerCastUpcaster implements Upcaster { - public function __invoke(Upcast $upcast): Upcast + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return array + */ + public function upcast(ClassMetadata $metadata, array $data, array $context): array { - // ignore if other event is processed - if ($upcast->eventName !== 'profile.created') { - return $upcast; + // ignore if another event is processed + if ($metadata->className !== ProfileCreated::class) { + return $data; } - if (!array_key_exists('email', $upcast->payload) || !is_string($upcast->payload['email'])) { - return $upcast; + if (!array_key_exists('email', $data) || !is_string($data['email'])) { + return $data; } - return $upcast->replacePayloadByKey('email', strtolower($upcast->payload['email'])); + $data['email'] = strtolower($data['email']); + + return $data; } } ``` -:::warning -Keep in mind that all events are passed to the upcaster, so an early return for unrelated events is recommended. -::: - -## Adjust event name - -Sometimes your event name was not the best choice and you want to change it. -For this we can use the `Upcaster` to change the event name. +:::tip +For simple cases you can use the `Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster` +instead of a dedicated class: ```php -use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; -final class EventNameRenameUpcaster implements Upcaster -{ - /** @param array $eventNameMap */ - public function __construct( - private readonly array $eventNameMap, - ) { - } - - public function __invoke(Upcast $upcast): Upcast - { - if (array_key_exists($upcast->eventName, $this->eventNameMap)) { - return $upcast->replaceEventName($this->eventNameMap[$upcast->eventName]); - } +$upcaster = CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data, array $context): array { + $data['email'] = strtolower($data['email']); - return $upcast; - } -} + return $data; + }, +); ``` -:::tip -Events can also have [aliases](events.md#alias). This is usually sufficient. ::: +## Adjust event name + +Renaming an event through an upcaster is no longer supported, because the event class is +already resolved from the stored event name before the payload is upcasted. +Use [event aliases](events.md#alias) instead. ## Configure -After we have defined the upcasting rules, we also have to pass the whole thing to the serializer. -Since we have multiple upcasters, we use a chain here. +After we have defined the upcasting rules, we register them on the hydrator through the +`UpcastExtension` and pass the hydrator to the serializer. ```php -use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; -use Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain; - -/** @var EventRegistry $eventRegistry */ -$upcaster = new UpcasterChain([ - new ProfileCreatedEmailLowerCastUpcaster(), - new EventNameRenameUpcaster(['old_event_name' => 'new_event_name']), -]); +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension( + beforeEncoding: [ + new ProfileCreatedEmailLowerCastUpcaster(), + ], + )) + ->build(); $serializer = DefaultEventSerializer::createFromPaths( ['src/Domain'], - $upcaster, + $hydrator, ); ``` +:::tip +`beforeEncoding` upcasters reshape the raw stored payload before its values are decoded. +If you need the already decoded (and decrypted) values, register them as `beforeTransform` instead. +::: +## Upcasting headers + +Message headers are hydrated the same way as events, so the same upcasters work for them. +An upcaster selects the header by its class name, build a hydrator with the `UpcastExtension` +and pass it to the headers serializer. + +```php +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension( + beforeEncoding: [ + new ApplicationHeaderUpcaster(), + ], + )) + ->build(); + +$headersSerializer = DefaultHeadersSerializer::createDefault($hydrator); +``` ## Learn more * [How to create messages](message.md) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6c4abf6bf..d24c07b62 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,29 +36,17 @@ parameters: count: 1 path: src/Console/DoctrineHelper.php - - - message: '#^Parameter \#1 \$key of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' - identifier: argument.type - count: 1 - path: src/Cryptography/DoctrineCipherKeyStore.php - - - - message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' - identifier: argument.type - count: 1 - path: src/Cryptography/DoctrineCipherKeyStore.php - - message: '#^Parameter \#3 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' identifier: argument.type count: 2 - path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + path: src/Cryptography/DoctrineCipherKeyStore.php - message: '#^Parameter \#5 \$createdAt of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects DateTimeImmutable, mixed given\.$#' identifier: argument.type count: 2 - path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + path: src/Cryptography/DoctrineCipherKeyStore.php - message: '#^Call to function method_exists\(\) with ReflectionFunction and ''isAnonymous'' will always evaluate to true\.$#' @@ -432,24 +420,6 @@ parameters: count: 1 path: tests/Integration/MicroAggregate/Profile.php - - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) with 0 and array\{array\\} will always evaluate to true\.$#' - identifier: staticMethod.alreadyNarrowedType - count: 4 - path: tests/Integration/PersonalData/PersonalDataTest.php - - - - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Integration\\\\PersonalData\\\\Profile'' and Patchlevel\\EventSourcing\\Tests\\Integration\\PersonalData\\Profile will always evaluate to true\.$#' - identifier: staticMethod.alreadyNarrowedType - count: 8 - path: tests/Integration/PersonalData/PersonalDataTest.php - - - - message: '#^Parameter \#2 \$haystack of static method PHPUnit\\Framework\\Assert\:\:assertStringNotContainsString\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 3 - path: tests/Integration/PersonalData/PersonalDataTest.php - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Integration\\Store\\Profile\:\:\$id is never read, only written\.$#' identifier: property.onlyWritten diff --git a/src/Cryptography/DoctrineCipherKeyStore.php b/src/Cryptography/DoctrineCipherKeyStore.php index f70680046..389ebeba0 100644 --- a/src/Cryptography/DoctrineCipherKeyStore.php +++ b/src/Cryptography/DoctrineCipherKeyStore.php @@ -6,77 +6,98 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; use Patchlevel\EventSourcing\Schema\DoctrineHelper; use Patchlevel\EventSourcing\Schema\DoctrineSchemaConfigurator; -use Patchlevel\Hydrator\Cryptography\Cipher\CipherKey; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; -use function array_key_exists; use function base64_decode; use function base64_encode; /** * @phpstan-type Row = array{ + * id: non-empty-string, * subject_id: non-empty-string, * crypto_key: non-empty-string, * crypto_method: non-empty-string, - * crypto_iv: non-empty-string + * created_at: non-empty-string * } */ final class DoctrineCipherKeyStore implements CipherKeyStore, DoctrineSchemaConfigurator { - /** @var array */ - private array $keyCache = []; + private Type $dateTimeType; public function __construct( private readonly Connection $connection, - private readonly string $tableName = 'crypto_keys', + private readonly string $tableName = 'cryptography_keys', ) { + $this->dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); } public function get(string $id): CipherKey { - if (array_key_exists($id, $this->keyCache)) { - return $this->keyCache[$id]; + /** @var Row|false $result */ + $result = $this->connection->fetchAssociative( + "SELECT * FROM {$this->tableName} WHERE id = :id", + ['id' => $id], + ); + + if ($result === false) { + throw CipherKeyNotExists::forKeyId($id); } + return new CipherKey( + $result['id'], + $result['subject_id'], + base64_decode($result['crypto_key']), + $result['crypto_method'], + $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), + ); + } + + public function currentKeyFor(string $subjectId): CipherKey + { /** @var Row|false $result */ $result = $this->connection->fetchAssociative( "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", - ['subject_id' => $id], + ['subject_id' => $subjectId], ); if ($result === false) { - throw new CipherKeyNotExists($id); + throw CipherKeyNotExists::forSubjectId($subjectId); } - $this->keyCache[$id] = new CipherKey( + return new CipherKey( + $result['id'], + $result['subject_id'], base64_decode($result['crypto_key']), $result['crypto_method'], - base64_decode($result['crypto_iv']), + $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), ); - - return $this->keyCache[$id]; } - public function store(string $id, CipherKey $key): void + public function store(CipherKey $key): void { $this->connection->insert($this->tableName, [ - 'subject_id' => $id, + 'id' => $key->id, + 'subject_id' => $key->subjectId, 'crypto_key' => base64_encode($key->key), 'crypto_method' => $key->method, - 'crypto_iv' => base64_encode($key->iv), + 'created_at' => $this->dateTimeType->convertToDatabaseValue($key->createdAt, $this->connection->getDatabasePlatform()), ]); - - $this->keyCache[$id] = $key; } public function remove(string $id): void { - $this->connection->delete($this->tableName, ['subject_id' => $id]); + $this->connection->delete($this->tableName, ['id' => $id]); + } - unset($this->keyCache[$id]); + public function removeWithSubjectId(string $subjectId): void + { + $this->connection->delete($this->tableName, ['subject_id' => $subjectId]); } public function configureSchema(Schema $schema, Connection $connection): void @@ -86,6 +107,9 @@ public function configureSchema(Schema $schema, Connection $connection): void } $table = $schema->createTable($this->tableName); + $table->addColumn('id', 'string') + ->setNotnull(true) + ->setLength(255); $table->addColumn('subject_id', 'string') ->setNotnull(true) ->setLength(255); @@ -95,14 +119,9 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addColumn('crypto_method', 'string') ->setNotnull(true) ->setLength(255); - $table->addColumn('crypto_iv', 'string') - ->setNotnull(true) - ->setLength(255); - $table->setPrimaryKey(['subject_id']); - } - - public function clear(): void - { - $this->keyCache = []; + $table->addColumn('created_at', 'datetimetz_immutable') + ->setNotnull(true); + $table->setPrimaryKey(['id']); + $table->addIndex(['subject_id']); } } diff --git a/src/Cryptography/ExtensionDoctrineCipherKeyStore.php b/src/Cryptography/ExtensionDoctrineCipherKeyStore.php deleted file mode 100644 index 2a8226fb8..000000000 --- a/src/Cryptography/ExtensionDoctrineCipherKeyStore.php +++ /dev/null @@ -1,127 +0,0 @@ -dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); - } - - public function get(string $id): CipherKey - { - /** @var Row|false $result */ - $result = $this->connection->fetchAssociative( - "SELECT * FROM {$this->tableName} WHERE id = :id", - ['id' => $id], - ); - - if ($result === false) { - throw CipherKeyNotExists::forKeyId($id); - } - - return new CipherKey( - $result['id'], - $result['subject_id'], - base64_decode($result['crypto_key']), - $result['crypto_method'], - $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), - ); - } - - public function currentKeyFor(string $subjectId): CipherKey - { - /** @var Row|false $result */ - $result = $this->connection->fetchAssociative( - "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", - ['subject_id' => $subjectId], - ); - - if ($result === false) { - throw CipherKeyNotExists::forSubjectId($subjectId); - } - - return new CipherKey( - $result['id'], - $result['subject_id'], - base64_decode($result['crypto_key']), - $result['crypto_method'], - $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), - ); - } - - public function store(CipherKey $key): void - { - $this->connection->insert($this->tableName, [ - 'id' => $key->id, - 'subject_id' => $key->subjectId, - 'crypto_key' => base64_encode($key->key), - 'crypto_method' => $key->method, - 'created_at' => $this->dateTimeType->convertToDatabaseValue($key->createdAt, $this->connection->getDatabasePlatform()), - ]); - } - - public function remove(string $id): void - { - $this->connection->delete($this->tableName, ['id' => $id]); - } - - public function removeWithSubjectId(string $subjectId): void - { - $this->connection->delete($this->tableName, ['subject_id' => $subjectId]); - } - - public function configureSchema(Schema $schema, Connection $connection): void - { - if (!DoctrineHelper::sameDatabase($this->connection, $connection)) { - return; - } - - $table = $schema->createTable($this->tableName); - $table->addColumn('id', 'string') - ->setNotnull(true) - ->setLength(255); - $table->addColumn('subject_id', 'string') - ->setNotnull(true) - ->setLength(255); - $table->addColumn('crypto_key', 'string') - ->setNotnull(true) - ->setLength(255); - $table->addColumn('crypto_method', 'string') - ->setNotnull(true) - ->setLength(255); - $table->addColumn('created_at', 'datetimetz_immutable') - ->setNotnull(true); - $table->setPrimaryKey(['id']); - $table->addIndex(['subject_id']); - } -} diff --git a/src/Message/Serializer/DefaultHeadersSerializer.php b/src/Message/Serializer/DefaultHeadersSerializer.php index 02addd9d5..a80dd89b3 100644 --- a/src/Message/Serializer/DefaultHeadersSerializer.php +++ b/src/Message/Serializer/DefaultHeadersSerializer.php @@ -8,18 +8,22 @@ use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; +use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; use function is_array; final class DefaultHeadersSerializer implements HeadersSerializer { + private readonly Hydrator $hydrator; + public function __construct( private readonly MessageHeaderRegistry $messageHeaderRegistry, - private readonly Hydrator $hydrator, - private readonly Encoder $encoder, + Hydrator|null $hydrator = null, + private readonly Encoder $encoder = new JsonEncoder(), ) { + $this->hydrator = $hydrator ?? self::defaultHydrator(); } /** @@ -61,21 +65,30 @@ public function deserialize(string $string, array $options = []): array } /** @param list $paths */ - public static function createFromPaths(array $paths): static - { + public static function createFromPaths( + array $paths, + Hydrator|null $hydrator = null, + ): static { return new self( (new AttributeMessageHeaderRegistryFactory())->create($paths), - new MetadataHydrator(), + $hydrator, new JsonEncoder(), ); } - public static function createDefault(): static + public static function createDefault(Hydrator|null $hydrator = null): static { return new self( MessageHeaderRegistry::createWithInternalHeaders(), - new MetadataHydrator(), + $hydrator, new JsonEncoder(), ); } + + private static function defaultHydrator(): Hydrator + { + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); + } } diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index 260098936..dc9725dcf 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -8,27 +8,35 @@ use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; +use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; final class DefaultEventSerializer implements EventSerializer { + public const CONTEXT_EVENT_NAME = 'event_name'; + public const CONTEXT_EVENT_CLASS = 'event_class'; + + private Hydrator $hydrator; + public function __construct( private EventRegistry $eventRegistry, - private Hydrator $hydrator = new MetadataHydrator(), + Hydrator|null $hydrator = null, private Encoder $encoder = new JsonEncoder(), - private Upcaster|null $upcaster = null, ) { + $this->hydrator = $hydrator ?? self::defaultHydrator(); } /** @param array $options */ public function serialize(object $event, array $options = []): SerializedEvent { $name = $this->eventRegistry->eventName($event::class); - $data = $this->hydrator->extract($event); + + /** @var array $data */ + $data = $this->hydrator->extract($event, [ + self::CONTEXT_EVENT_NAME => $name, + self::CONTEXT_EVENT_CLASS => $event::class, + ]); return new SerializedEvent( $name, @@ -40,30 +48,30 @@ public function serialize(object $event, array $options = []): SerializedEvent public function deserialize(SerializedEvent $data, array $options = []): object { $payload = $this->encoder->decode($data->payload, $options); + $class = $this->eventRegistry->eventClass($data->name); - $eventName = $data->name; - if ($this->upcaster) { - $upcast = ($this->upcaster)(new Upcast($data->name, $payload)); - $eventName = $upcast->eventName; - $payload = $upcast->payload; - } - - $class = $this->eventRegistry->eventClass($eventName); - - return $this->hydrator->hydrate($class, $payload); + return $this->hydrator->hydrate($class, $payload, [ + self::CONTEXT_EVENT_NAME => $data->name, + self::CONTEXT_EVENT_CLASS => $class, + ]); } /** @param list $paths */ public static function createFromPaths( array $paths, - Upcaster|null $upcaster = null, - PayloadCryptographer|null $cryptographer = null, + Hydrator|null $hydrator = null, ): static { return new self( (new AttributeEventRegistryFactory())->create($paths), - new MetadataHydrator(cryptographer: $cryptographer), + $hydrator, new JsonEncoder(), - $upcaster, ); } + + private static function defaultHydrator(): Hydrator + { + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); + } } diff --git a/src/Serializer/Normalizer/IdNormalizer.php b/src/Serializer/Normalizer/IdNormalizer.php index d65d903b2..202950a31 100644 --- a/src/Serializer/Normalizer/IdNormalizer.php +++ b/src/Serializer/Normalizer/IdNormalizer.php @@ -25,7 +25,8 @@ public function __construct( ) { } - public function normalize(mixed $value): string|null + /** @param array $context */ + public function normalize(mixed $value, array $context): string|null { if ($value === null) { return null; @@ -40,7 +41,8 @@ public function normalize(mixed $value): string|null return $value->toString(); } - public function denormalize(mixed $value): Identifier|null + /** @param array $context */ + public function denormalize(mixed $value, array $context): Identifier|null { if ($value === null) { return null; diff --git a/src/Serializer/Upcast/Upcast.php b/src/Serializer/Upcast/Upcast.php deleted file mode 100644 index 29a6b7629..000000000 --- a/src/Serializer/Upcast/Upcast.php +++ /dev/null @@ -1,35 +0,0 @@ - $payload */ - public function __construct( - public readonly string $eventName, - public readonly array $payload, - ) { - } - - public function replaceEventName(string $eventName): self - { - return new self($eventName, $this->payload); - } - - /** @param array $payload */ - public function replacePayload(array $payload): self - { - return new self($this->eventName, $payload); - } - - public function replacePayloadByKey(string $key, mixed $data): self - { - $payload = $this->payload; - $payload[$key] = $data; - - return new self($this->eventName, $payload); - } -} diff --git a/src/Serializer/Upcast/Upcaster.php b/src/Serializer/Upcast/Upcaster.php deleted file mode 100644 index 7c1d749a9..000000000 --- a/src/Serializer/Upcast/Upcaster.php +++ /dev/null @@ -1,10 +0,0 @@ - $upcaster */ - public function __construct( - private readonly iterable $upcaster, - ) { - } - - public function __invoke(Upcast $upcast): Upcast - { - foreach ($this->upcaster as $upcaster) { - $upcast = $upcaster($upcast); - } - - return $upcast; - } -} diff --git a/src/Snapshot/DefaultSnapshotStore.php b/src/Snapshot/DefaultSnapshotStore.php index 5bab975f6..71168d19e 100644 --- a/src/Snapshot/DefaultSnapshotStore.php +++ b/src/Snapshot/DefaultSnapshotStore.php @@ -9,37 +9,24 @@ use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataAwareMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use Patchlevel\EventSourcing\Snapshot\Adapter\SnapshotAdapter; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; +use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; use Throwable; use function array_key_exists; -use function is_array; use function sprintf; final class DefaultSnapshotStore implements SnapshotStore { - private AdapterRepository $adapterRepository; + private readonly Hydrator $hydrator; - private Hydrator $hydrator; - - private AggregateRootMetadataFactory $metadataFactory; - - /** @param array|AdapterRepository $adapterRepository */ public function __construct( - array|AdapterRepository $adapterRepository, + private readonly AdapterRepository $adapterRepository, Hydrator|null $hydrator = null, - AggregateRootMetadataFactory|null $metadataFactory = null, + private readonly AggregateRootMetadataFactory $metadataFactory = new AggregateRootMetadataAwareMetadataFactory(), ) { - if (is_array($adapterRepository)) { - $this->adapterRepository = new ArrayAdapterRepository($adapterRepository); - } else { - $this->adapterRepository = $adapterRepository; - } - - $this->hydrator = $hydrator ?? new MetadataHydrator(); - $this->metadataFactory = $metadataFactory ?? new AggregateRootMetadataAwareMetadataFactory(); + $this->hydrator = $hydrator ?? self::defaultHydrator(); } public function save(AggregateRoot $aggregateRoot): void @@ -118,11 +105,18 @@ private function version(string $aggregateClass): string|null } /** @param array $snapshotAdapters */ - public static function createDefault(array $snapshotAdapters, PayloadCryptographer|null $cryptographer = null): self + public static function createDefault(array $snapshotAdapters, Hydrator|null $hydrator = null): self { return new self( new ArrayAdapterRepository($snapshotAdapters), - new MetadataHydrator(cryptographer: $cryptographer), + $hydrator, ); } + + private static function defaultHydrator(): Hydrator + { + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); + } } diff --git a/tests/Benchmark/BasicImplementation/Events/EmailChanged.php b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php index a7c3743cc..de0d6fc9a 100644 --- a/tests/Benchmark/BasicImplementation/Events/EmailChanged.php +++ b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php @@ -6,8 +6,8 @@ use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; #[Event('profile.email_changed')] final class EmailChanged @@ -15,7 +15,7 @@ final class EmailChanged public function __construct( #[DataSubjectId] public ProfileId $profileId, - #[PersonalData] + #[SensitiveData] public string|null $email, ) { } diff --git a/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php b/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php index 07adcaeee..6be650f17 100644 --- a/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php +++ b/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php @@ -7,8 +7,8 @@ use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; #[Event('profile.created')] final class ProfileCreated @@ -18,7 +18,7 @@ public function __construct( #[EventTag(prefix: 'profile')] public ProfileId $profileId, public string $name, - #[PersonalData] + #[SensitiveData] public string|null $email, ) { } diff --git a/tests/Benchmark/CommandToQueryBench.php b/tests/Benchmark/CommandToQueryBench.php index 43f1f5893..45fc05d7b 100644 --- a/tests/Benchmark/CommandToQueryBench.php +++ b/tests/Benchmark/CommandToQueryBench.php @@ -59,7 +59,7 @@ public function setUp(): void $aggregateRootRegistry, $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), ); $projectionConnection = DbalManager::createConnection(); diff --git a/tests/Benchmark/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php index 0e7b93a92..cf1d01766 100644 --- a/tests/Benchmark/PersonalDataBench.php +++ b/tests/Benchmark/PersonalDataBench.php @@ -16,7 +16,10 @@ use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; use PhpBench\Attributes as Bench; #[Bench\BeforeMethods('setUp')] @@ -34,15 +37,20 @@ public function setUp(): void $cipherKeyStore = new DoctrineCipherKeyStore($connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl( + $cryptographer = BaseCryptographer::createWithOpenssl( $cipherKeyStore, ); + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension($cryptographer)) + ->build(); + $this->store = new StreamDoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths( [__DIR__ . '/BasicImplementation/Events'], - cryptographer: $cryptographer, + $hydrator, ), ); diff --git a/tests/Benchmark/SnapshotsBench.php b/tests/Benchmark/SnapshotsBench.php index 43d8ec223..ad1dc8ce9 100644 --- a/tests/Benchmark/SnapshotsBench.php +++ b/tests/Benchmark/SnapshotsBench.php @@ -41,7 +41,7 @@ public function setUp(): void $this->adapter = new InMemorySnapshotAdapter(); - $this->snapshotStore = new DefaultSnapshotStore(['default' => $this->adapter]); + $this->snapshotStore = DefaultSnapshotStore::createDefault(['default' => $this->adapter]); $this->repository = new DefaultRepository($this->store, Profile::metadata(), null, $this->snapshotStore); diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index 8d0bbcf75..9c94e8f07 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -153,7 +153,7 @@ public function testSnapshot(): void new AggregateRootRegistry(['profile' => Profile::class]), $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), new FooMessageDecorator(), ), $engine, @@ -207,7 +207,7 @@ public function testTempProjection(): void new AggregateRootRegistry(['profile' => Profile::class]), $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), new FooMessageDecorator(), ); @@ -266,7 +266,7 @@ public function testCommandBus(): void new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), new FooMessageDecorator(), ); @@ -344,7 +344,7 @@ public function testQueryBus(): void new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), new FooMessageDecorator(), ); @@ -409,7 +409,7 @@ public function testAggregateInitialization(): void $aggregateRootRegistry, $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), new FooMessageDecorator(), ); diff --git a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php index ae4bb3336..67409c11d 100644 --- a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php +++ b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php @@ -129,7 +129,7 @@ public function testSnapshot(): void ]), $store, null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + DefaultSnapshotStore::createDefault(['default' => new InMemorySnapshotAdapter()]), ), $engine, ); diff --git a/tests/Integration/PersonalData/Events/NameChanged.php b/tests/Integration/SensitiveData/Events/NameChanged.php similarity index 58% rename from tests/Integration/PersonalData/Events/NameChanged.php rename to tests/Integration/SensitiveData/Events/NameChanged.php index bc6c1edd0..98303e048 100644 --- a/tests/Integration/PersonalData/Events/NameChanged.php +++ b/tests/Integration/SensitiveData/Events/NameChanged.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events; use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\ProfileId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; @@ -16,10 +14,8 @@ final class NameChanged { public function __construct( #[DataSubjectId] - #[LegacyDataSubjectId] public readonly ProfileId $aggregateId, #[SensitiveData(fallback: 'unknown')] - #[PersonalData(fallback: 'unknown')] public readonly string $name, ) { } diff --git a/tests/Integration/PersonalData/Events/PersonalDataRemoved.php b/tests/Integration/SensitiveData/Events/PersonalDataRemoved.php similarity index 63% rename from tests/Integration/PersonalData/Events/PersonalDataRemoved.php rename to tests/Integration/SensitiveData/Events/PersonalDataRemoved.php index 646b4a3f4..bff7d54c0 100644 --- a/tests/Integration/PersonalData/Events/PersonalDataRemoved.php +++ b/tests/Integration/SensitiveData/Events/PersonalDataRemoved.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events; use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\ProfileId; #[Event('profile.personal_data_removed')] final class PersonalDataRemoved diff --git a/tests/Integration/PersonalData/Events/ProfileCreated.php b/tests/Integration/SensitiveData/Events/ProfileCreated.php similarity index 57% rename from tests/Integration/PersonalData/Events/ProfileCreated.php rename to tests/Integration/SensitiveData/Events/ProfileCreated.php index bb9e775f3..ffb3609ab 100644 --- a/tests/Integration/PersonalData/Events/ProfileCreated.php +++ b/tests/Integration/SensitiveData/Events/ProfileCreated.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events; use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\ProfileId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; @@ -16,10 +14,8 @@ final class ProfileCreated { public function __construct( #[DataSubjectId] - #[LegacyDataSubjectId] public ProfileId $profileId, #[SensitiveData(fallback: 'unknown')] - #[PersonalData(fallback: 'unknown')] public string $name, ) { } diff --git a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php b/tests/Integration/SensitiveData/Processor/DeletePersonalDataProcessor.php similarity index 58% rename from tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php rename to tests/Integration/SensitiveData/Processor/DeletePersonalDataProcessor.php index ab07bcc9d..5b4ec8d30 100644 --- a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php +++ b/tests/Integration/SensitiveData/Processor/DeletePersonalDataProcessor.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData\Processor; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Processor; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events\PersonalDataRemoved; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events\PersonalDataRemoved; +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; #[Processor('delete_personal_data')] final class DeletePersonalDataProcessor @@ -20,6 +20,6 @@ public function __construct( #[Subscribe(PersonalDataRemoved::class)] public function handleProfileCreated(PersonalDataRemoved $event): void { - $this->cipherKeyStore->remove($event->profileId->toString()); + $this->cipherKeyStore->removeWithSubjectId($event->profileId->toString()); } } diff --git a/tests/Integration/PersonalData/Profile.php b/tests/Integration/SensitiveData/Profile.php similarity index 74% rename from tests/Integration/PersonalData/Profile.php rename to tests/Integration/SensitiveData/Profile.php index 50d324df0..db765488b 100644 --- a/tests/Integration/PersonalData/Profile.php +++ b/tests/Integration/SensitiveData/Profile.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\Attribute\Snapshot; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events\NameChanged; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events\PersonalDataRemoved; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Events\ProfileCreated; -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events\NameChanged; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events\PersonalDataRemoved; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Events\ProfileCreated; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; #[Aggregate('profile')] #[Snapshot('default', 2)] @@ -23,7 +23,7 @@ final class Profile extends BasicAggregateRoot #[DataSubjectId] private ProfileId $id; - #[PersonalData(fallback: 'unknown')] + #[SensitiveData(fallback: 'unknown')] private string $name; public static function create(ProfileId $id, string $name): self diff --git a/tests/Integration/PersonalData/ProfileId.php b/tests/Integration/SensitiveData/ProfileId.php similarity index 77% rename from tests/Integration/PersonalData/ProfileId.php rename to tests/Integration/SensitiveData/ProfileId.php index 7b475b851..cf51c62e1 100644 --- a/tests/Integration/PersonalData/ProfileId.php +++ b/tests/Integration/SensitiveData/ProfileId.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData; use Patchlevel\EventSourcing\Identifier\Identifier; use Patchlevel\EventSourcing\Identifier\RamseyUuidV7Behaviour; diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/SensitiveData/SensitiveDataTest.php similarity index 52% rename from tests/Integration/PersonalData/PersonalDataTest.php rename to tests/Integration/SensitiveData/SensitiveDataTest.php index 3c71f04be..60b24fca0 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/SensitiveData/SensitiveDataTest.php @@ -2,13 +2,11 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData; +namespace Patchlevel\EventSourcing\Tests\Integration\SensitiveData; use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; -use Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; -use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; @@ -23,9 +21,8 @@ use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; -use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Processor\DeletePersonalDataProcessor; +use Patchlevel\EventSourcing\Tests\Integration\SensitiveData\Processor\DeletePersonalDataProcessor; use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; use Patchlevel\Hydrator\StackHydratorBuilder; @@ -33,7 +30,7 @@ use PHPUnit\Framework\TestCase; #[CoversNothing] -final class PersonalDataTest extends TestCase +final class SensitiveDataTest extends TestCase { private Connection $connection; @@ -50,11 +47,15 @@ public function tearDown(): void public function testSuccessfulWithEvent(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); $store = new StreamDoctrineDbalStore( $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], $hydrator), ); $manager = new DefaultRepositoryManager( @@ -81,7 +82,6 @@ public function testSuccessfulWithEvent(): void $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); self::assertSame(1, $profile->playhead()); self::assertSame('John', $profile->name()); @@ -89,17 +89,18 @@ public function testSuccessfulWithEvent(): void $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); self::assertCount(1, $result); - self::assertArrayHasKey(0, $result); - - $row = $result[0]; - - self::assertStringNotContainsString('John', $row['event_payload']); + self::assertIsString($result[0]['event_payload']); + self::assertStringNotContainsString('John', $result[0]['event_payload']); } public function testRemoveKeyWithEvent(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, @@ -107,7 +108,7 @@ public function testRemoveKeyWithEvent(): void $store = new StreamDoctrineDbalStore( $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], $hydrator), ); $manager = new DefaultRepositoryManager( @@ -144,7 +145,6 @@ public function testRemoveKeyWithEvent(): void $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); self::assertSame(1, $profile->playhead()); self::assertSame('John', $profile->name()); @@ -155,7 +155,6 @@ public function testRemoveKeyWithEvent(): void $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); self::assertSame(2, $profile->playhead()); self::assertSame('unknown', $profile->name()); @@ -165,7 +164,6 @@ public function testRemoveKeyWithEvent(): void $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); self::assertSame(3, $profile->playhead()); self::assertSame('hallo', $profile->name()); @@ -174,7 +172,11 @@ public function testRemoveKeyWithEvent(): void public function testRemoveKeyWithEventAndSnapshot(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, @@ -182,7 +184,7 @@ public function testRemoveKeyWithEventAndSnapshot(): void $store = new StreamDoctrineDbalStore( $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], $hydrator), ); $snapshotAdapter = new InMemorySnapshotAdapter(); @@ -193,7 +195,7 @@ public function testRemoveKeyWithEventAndSnapshot(): void null, DefaultSnapshotStore::createDefault( ['default' => $snapshotAdapter], - $cryptographer, + $hydrator, ), ); @@ -227,176 +229,22 @@ public function testRemoveKeyWithEventAndSnapshot(): void $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); self::assertSame(2, $profile->playhead()); self::assertSame('John 2', $profile->name()); - $cipherKeyStore->remove($profileId->toString()); - - $profile = $repository->load($profileId); - - self::assertInstanceOf(Profile::class, $profile); - self::assertEquals($profileId, $profile->aggregateRootId()); - self::assertSame(2, $profile->playhead()); - self::assertSame('unknown', $profile->name()); - } - - public function testWithStackHydrator(): void - { - $cipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection); - $extension = new CryptographyExtension( - BaseCryptographer::createWithOpenssl($cipherKeyStore), - ); - - $eventSerializer = new DefaultEventSerializer( - (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), - (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension($extension) - ->build(), - ); - - $store = new StreamDoctrineDbalStore( - $this->connection, - $eventSerializer, - ); - - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - ); - - $repository = $manager->get(Profile::class); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - new ChainDoctrineSchemaConfigurator([ - $store, - $cipherKeyStore, - ]), - ); - - $schemaDirector->create(); - - $profileId = ProfileId::generate(); - $profile = Profile::create($profileId, 'John'); - - $repository->save($profile); - - $profile = $repository->load($profileId); - - self::assertInstanceOf(Profile::class, $profile); - self::assertEquals($profileId, $profile->aggregateRootId()); - self::assertSame(1, $profile->playhead()); - self::assertSame('John', $profile->name()); - $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); - self::assertCount(1, $result); - self::assertArrayHasKey(0, $result); - - $row = $result[0]; + self::assertCount(2, $result); + self::assertIsString($result[1]['event_payload']); + self::assertStringNotContainsString('John 2', $result[1]['event_payload']); - self::assertStringNotContainsString('John', $row['event_payload']); - } - - public function testWithStackHydratorWithLegacyFallback(): void - { - $extensionCipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection); - $legacyCipherKeyStore = new DoctrineCipherKeyStore($this->connection); - - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore); - - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), - ); + $cipherKeyStore->removeWithSubjectId($profileId->toString()); - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - ); - - $repository = $manager->get(Profile::class); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - new ChainDoctrineSchemaConfigurator([ - $store, - $legacyCipherKeyStore, - $extensionCipherKeyStore, - ]), - ); - - $schemaDirector->create(); - - $profileId = ProfileId::generate(); - $profile = Profile::create($profileId, 'John'); - - $repository->save($profile); - - // switch to new hydrator - - $extension = new CryptographyExtension( - BaseCryptographer::createWithOpenssl($extensionCipherKeyStore), - PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore), - ); - - $eventSerializer = new DefaultEventSerializer( - (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), - (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension($extension) - ->build(), - ); - - $store = new StreamDoctrineDbalStore( - $this->connection, - $eventSerializer, - ); - - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - ); - - $repository = $manager->get(Profile::class); $profile = $repository->load($profileId); - self::assertInstanceOf(Profile::class, $profile); self::assertEquals($profileId, $profile->aggregateRootId()); - self::assertSame(1, $profile->playhead()); - self::assertSame('John', $profile->name()); - - $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); - - self::assertCount(1, $result); - self::assertArrayHasKey(0, $result); - - $row = $result[0]; - - self::assertStringNotContainsString('John', $row['event_payload']); - - $result = $this->connection->fetchAllAssociative('SELECT * FROM crypto_keys'); - - self::assertCount(1, $result); - self::assertArrayHasKey(0, $result); - - $row = $result[0]; - - self::assertEquals($profileId->toString(), $row['subject_id']); - - $result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys'); - - self::assertCount(0, $result); - - $profile->changeName('John 2'); - $repository->save($profile); - - $result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys'); - - self::assertCount(1, $result); - self::assertEquals($profileId->toString(), $row['subject_id']); + self::assertSame(2, $profile->playhead()); + self::assertSame('unknown', $profile->name()); } } diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index 2dd800487..632aef5b5 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -61,6 +61,8 @@ use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProcessor; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjectionWithCleanup; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -1377,7 +1379,10 @@ public function testCleanup(): void public function testLookup(): void { $eventRegistry = (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']); - $serializer = new DefaultEventSerializer($eventRegistry); + $serializer = new DefaultEventSerializer( + $eventRegistry, + (new StackHydratorBuilder())->useExtension(new CoreExtension())->build(), + ); $store = new StreamDoctrineDbalStore( $this->connection, diff --git a/tests/Unit/Fixture/EmailNormalizer.php b/tests/Unit/Fixture/EmailNormalizer.php index d13924557..a631c3a92 100644 --- a/tests/Unit/Fixture/EmailNormalizer.php +++ b/tests/Unit/Fixture/EmailNormalizer.php @@ -14,7 +14,8 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class EmailNormalizer implements Normalizer { - public function normalize(mixed $value): string + /** @param array $context */ + public function normalize(mixed $value, array $context): string { if (!$value instanceof Email) { throw new InvalidArgumentException(); @@ -23,7 +24,8 @@ public function normalize(mixed $value): string return $value->toString(); } - public function denormalize(mixed $value): Email|null + /** @param array $context */ + public function denormalize(mixed $value, array $context): Email|null { if ($value === null) { return null; diff --git a/tests/Unit/Fixture/MessageNormalizer.php b/tests/Unit/Fixture/MessageNormalizer.php index d18220bcb..a2f958fb4 100644 --- a/tests/Unit/Fixture/MessageNormalizer.php +++ b/tests/Unit/Fixture/MessageNormalizer.php @@ -13,8 +13,12 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class MessageNormalizer implements Normalizer { - /** @return array|null */ - public function normalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function normalize(mixed $value, array $context): array|null { if ($value === null) { return null; @@ -27,7 +31,8 @@ public function normalize(mixed $value): array|null return $value->toArray(); } - public function denormalize(mixed $value): Message|null + /** @param array $context */ + public function denormalize(mixed $value, array $context): Message|null { if ($value === null) { return null; diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 9e52c025c..5492f4cd0 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -12,10 +12,15 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function is_string; + #[CoversClass(DefaultHeadersSerializer::class)] final class DefaultHeadersSerializerTest extends TestCase { @@ -44,7 +49,7 @@ public function testDeserialize(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + null, new JsonEncoder(), ); @@ -60,4 +65,39 @@ public function testDeserialize(): void $deserializedMessage, ); } + + public function testDeserializeWithUpcaster(): void + { + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension( + beforeEncoding: [ + CallbackUpcaster::forClass( + StreamNameHeader::class, + static function (array $data): array { + $streamName = $data['streamName']; + + if (is_string($streamName)) { + $data['streamName'] = 'profile-' . $streamName; + } + + return $data; + }, + ), + ], + )) + ->build(); + + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + $hydrator, + new JsonEncoder(), + ); + + $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"1"}}'); + + self::assertEquals([new StreamNameHeader('profile-1')], $deserializedMessage); + } } diff --git a/tests/Unit/Serializer/DefaultEventSerializerTest.php b/tests/Unit/Serializer/DefaultEventSerializerTest.php index cba9905c6..bb0b83535 100644 --- a/tests/Unit/Serializer/DefaultEventSerializerTest.php +++ b/tests/Unit/Serializer/DefaultEventSerializerTest.php @@ -4,17 +4,11 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Serializer; -use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; -use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\EventSourcing\Serializer\SerializedEvent; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; -use Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; -use Patchlevel\Hydrator\MetadataHydrator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -57,85 +51,4 @@ public function testDeserialize(): void self::assertEquals($expected, $event); } - - public function testSerializeWithUpcasting(): void - { - $upcaster = new class implements Upcaster { - public function __invoke(Upcast $upcast): Upcast - { - if ($upcast->eventName !== 'profile_created_old') { - return $upcast; - } - - return new Upcast('profile_created', $upcast->payload + ['email' => 'info@patchlevel.de']); - } - }; - - $serializer = new DefaultEventSerializer( - (new AttributeEventRegistryFactory())->create([__DIR__ . '/../Fixture']), - new MetadataHydrator(), - new JsonEncoder(), - $upcaster, - ); - - $expected = new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ); - - $event = $serializer->deserialize( - new SerializedEvent( - 'profile_created_old', - '{"profileId":"1"}', - ), - ); - - self::assertEquals($expected, $event); - } - - public function testSerializeWithUpcastingChain(): void - { - $upcasterOne = new class implements Upcaster { - public function __invoke(Upcast $upcast): Upcast - { - if ($upcast->eventName !== 'profile_created_very_old') { - return $upcast; - } - - return new Upcast('profile_created_old', ['profileId' => $upcast->payload['id'] ?? 'None']); - } - }; - - $upcasterTwo = new class implements Upcaster { - public function __invoke(Upcast $upcast): Upcast - { - if ($upcast->eventName !== 'profile_created_old') { - return $upcast; - } - - return new Upcast('profile_created', $upcast->payload + ['email' => 'info@patchlevel.de']); - } - }; - - $serializer = new DefaultEventSerializer( - (new AttributeEventRegistryFactory())->create([__DIR__ . '/../Fixture']), - new MetadataHydrator(), - new JsonEncoder(), - new UpcasterChain([$upcasterOne, $upcasterTwo]), - ); - - $expected = new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ); - - $event = $serializer->deserialize( - new SerializedEvent( - 'profile_created_very_old', - '{"id":"1"}', - ), - ); - - self::assertEquals($expected, $event); - } } diff --git a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php index 720976f7e..f7ae6e1f2 100644 --- a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php +++ b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php @@ -23,13 +23,13 @@ final class IdNormalizerTest extends TestCase public function testNormalizeWithNull(): void { $normalizer = new IdNormalizer(CustomId::class); - $this->assertEquals(null, $normalizer->normalize(null)); + $this->assertEquals(null, $normalizer->normalize(null, [])); } public function testDenormalizeWithNull(): void { $normalizer = new IdNormalizer(CustomId::class); - $this->assertEquals(null, $normalizer->denormalize(null)); + $this->assertEquals(null, $normalizer->denormalize(null, [])); } public function testNormalizeWithInvalidArgument(): void @@ -38,7 +38,7 @@ public function testNormalizeWithInvalidArgument(): void $this->expectExceptionMessage('type "Patchlevel\EventSourcing\Identifier\CustomId" was expected but "string" was passed.'); $normalizer = new IdNormalizer(CustomId::class); - $normalizer->normalize('foo'); + $normalizer->normalize('foo', []); } public function testDenormalizeWithInvalidArgument(): void @@ -46,19 +46,19 @@ public function testDenormalizeWithInvalidArgument(): void $this->expectException(InvalidUuidStringException::class); $normalizer = new IdNormalizer(Uuid::class); - $normalizer->denormalize('foo'); + $normalizer->denormalize('foo', []); } public function testNormalizeWithValue(): void { $normalizer = new IdNormalizer(CustomId::class); - $this->assertEquals('foo', $normalizer->normalize(new CustomId('foo'))); + $this->assertEquals('foo', $normalizer->normalize(new CustomId('foo'), [])); } public function testDenormalizeWithValue(): void { $normalizer = new IdNormalizer(CustomId::class); - $this->assertEquals(new CustomId('foo'), $normalizer->denormalize('foo')); + $this->assertEquals(new CustomId('foo'), $normalizer->denormalize('foo', [])); } public function testDenormalizeWithWrongValue(): void @@ -66,7 +66,7 @@ public function testDenormalizeWithWrongValue(): void $normalizer = new IdNormalizer(CustomId::class); $this->expectException(InvalidArgument::class); - $normalizer->denormalize(123); + $normalizer->denormalize(123, []); } public function testAutoDetect(): void diff --git a/tests/Unit/Serializer/Upcast/UpcastTest.php b/tests/Unit/Serializer/Upcast/UpcastTest.php deleted file mode 100644 index 1e28c62b4..000000000 --- a/tests/Unit/Serializer/Upcast/UpcastTest.php +++ /dev/null @@ -1,57 +0,0 @@ - 'max']); - - $newUpcast = $upcast->replaceEventName('bar'); - - $this->assertNotSame($upcast, $newUpcast); - $this->assertEquals('bar', $newUpcast->eventName); - $this->assertEquals(['name' => 'max'], $newUpcast->payload); - } - - public function testReplacePayload(): void - { - $upcast = new Upcast('foo', ['name' => 'max']); - - $newUpcast = $upcast->replacePayload(['name' => 'maxim']); - - $this->assertNotSame($upcast, $newUpcast); - $this->assertEquals('foo', $newUpcast->eventName); - $this->assertEquals(['name' => 'maxim'], $newUpcast->payload); - } - - public function testReplacePayloadByKey(): void - { - $upcast = new Upcast('foo', ['name' => 'max']); - - $newUpcast = $upcast->replacePayloadByKey('name', 'maxim'); - - $this->assertNotSame($upcast, $newUpcast); - $this->assertEquals('foo', $newUpcast->eventName); - $this->assertEquals(['name' => 'maxim'], $newUpcast->payload); - } - - public function testReplacePayloadByKeyWithoutExistingKey(): void - { - $upcast = new Upcast('foo', ['name' => 'max']); - - $newUpcast = $upcast->replacePayloadByKey('age', 20); - - $this->assertNotSame($upcast, $newUpcast); - $this->assertEquals('foo', $newUpcast->eventName); - $this->assertEquals(['name' => 'max', 'age' => 20], $newUpcast->payload); - } -} diff --git a/tests/Unit/Serializer/Upcast/UpcasterChainTest.php b/tests/Unit/Serializer/Upcast/UpcasterChainTest.php deleted file mode 100644 index df2995486..000000000 --- a/tests/Unit/Serializer/Upcast/UpcasterChainTest.php +++ /dev/null @@ -1,57 +0,0 @@ -counter++; - - return new Upcast('profile_1', $upcast->payload); - } - }; - - $upcasterTwo = new class implements Upcaster { - public int $counter = 0; - - public function __invoke(Upcast $upcast): Upcast - { - $this->counter++; - - return new Upcast('profile_2', $upcast->payload + ['foo' => 'bar']); - } - }; - - $inputPayload = ['bar' => 'baz']; - $inputEventName = 'profile'; - - $chain = new UpcasterChain([$upcasterOne, $upcasterTwo]); - $upcast = ($chain)(new Upcast($inputEventName, $inputPayload)); - - self::assertSame(1, $upcasterOne->counter); - self::assertSame(1, $upcasterTwo->counter); - self::assertSame('profile_2', $upcast->eventName); - self::assertSame( - [ - 'bar' => 'baz', - 'foo' => 'bar', - ], - $upcast->payload, - ); - } -} diff --git a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php index a97cc1267..c91292519 100644 --- a/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php +++ b/tests/Unit/Snapshot/DefaultSnapshotStoreTest.php @@ -29,7 +29,7 @@ public function testSave(): void 'payload' => ['id' => '1', 'email' => 'info@patchlevel.de', 'messages' => [], '_playhead' => 2], ]); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); $aggregate = ProfileWithSnapshot::createProfile( ProfileId::fromString('1'), @@ -51,7 +51,7 @@ public function testLoad(): void ], ); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); $aggregate = $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); @@ -69,7 +69,7 @@ public function testLoadNotFound(): void ->with('profile_with_snapshot-1') ->willThrowException(new RuntimeException()); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); $this->expectException(SnapshotNotFound::class); $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); @@ -82,7 +82,7 @@ public function testLoadLegacySnapshots(): void $adapter = $this->createMock(SnapshotAdapter::class); $adapter->method('load')->with('profile_with_snapshot-1')->willReturn(['id' => '1', 'email' => 'info@patchlevel.de', 'messages' => [], '_playhead' => 2]); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); } @@ -99,7 +99,7 @@ public function testLoadExpiredSnapshot(): void ], ); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); } @@ -108,7 +108,7 @@ public function testAdapterIsMissing(): void { $this->expectException(AdapterNotFound::class); - $store = new DefaultSnapshotStore([]); + $store = DefaultSnapshotStore::createDefault([]); $store->load(ProfileWithSnapshot::class, ProfileId::fromString('1')); } @@ -116,14 +116,14 @@ public function testSnapshotConfigIsMissing(): void { $this->expectException(SnapshotNotConfigured::class); - $store = new DefaultSnapshotStore([], null); + $store = DefaultSnapshotStore::createDefault(['memory' => $this->createMock(SnapshotAdapter::class)]); $store->load(Profile::class, ProfileId::fromString('1')); } public function testGetAdapter(): void { $adapter = $this->createMock(SnapshotAdapter::class); - $store = new DefaultSnapshotStore(['memory' => $adapter]); + $store = DefaultSnapshotStore::createDefault(['memory' => $adapter]); self::assertSame($adapter, $store->adapter(ProfileWithSnapshot::class)); }