From af353b925d606cc27ff69b6f0d648658f4de0cbb Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 15:38:43 +0200 Subject: [PATCH 1/5] Upgrade to patchlevel/hydrator 2.0 Replace our own upcaster with the UpcastExtension of the hydrator and switch the serializer and snapshot store to take a configured hydrator instead of an upcaster and cryptographer. The legacy cryptography is gone in hydrator 2.0, so the old DoctrineCipherKeyStore is removed and the extension based store takes over its name. --- composer.json | 2 +- composer.lock | 23 +- docs/UPGRADE-4.0.md | 215 ++++++++++++++++++ docs/normalizer.md | 7 +- docs/personal-data.md | 56 +++-- docs/upcasting.md | 110 +++++---- phpstan-baseline.neon | 16 +- src/Cryptography/DoctrineCipherKeyStore.php | 83 ++++--- .../ExtensionDoctrineCipherKeyStore.php | 127 ----------- .../Serializer/DefaultHeadersSerializer.php | 6 +- src/Serializer/DefaultEventSerializer.php | 35 ++- src/Serializer/EventPayloadNotAnArray.php | 23 ++ src/Serializer/Normalizer/IdNormalizer.php | 6 +- src/Serializer/Upcast/Upcast.php | 35 --- src/Serializer/Upcast/Upcaster.php | 10 - src/Serializer/Upcast/UpcasterChain.php | 23 -- src/Snapshot/DefaultSnapshotStore.php | 9 +- .../Events/EmailChanged.php | 6 +- .../Events/ProfileCreated.php | 6 +- tests/Benchmark/PersonalDataBench.php | 14 +- .../PersonalData/Events/NameChanged.php | 4 - .../PersonalData/Events/ProfileCreated.php | 4 - .../PersonalData/PersonalDataTest.php | 181 ++------------- .../Processor/DeletePersonalDataProcessor.php | 4 +- tests/Integration/PersonalData/Profile.php | 6 +- ...est.php => DoctrineCipherKeyStoreTest.php} | 26 +-- tests/Unit/Fixture/EmailNormalizer.php | 6 +- tests/Unit/Fixture/MessageNormalizer.php | 11 +- .../DefaultHeadersSerializerTest.php | 14 +- .../Serializer/DefaultEventSerializerTest.php | 108 +++------ .../Normalizer/IdNormalizerTest.php | 14 +- tests/Unit/Serializer/Upcast/UpcastTest.php | 57 ----- .../Serializer/Upcast/UpcasterChainTest.php | 57 ----- 33 files changed, 544 insertions(+), 760 deletions(-) delete mode 100644 src/Cryptography/ExtensionDoctrineCipherKeyStore.php create mode 100644 src/Serializer/EventPayloadNotAnArray.php delete mode 100644 src/Serializer/Upcast/Upcast.php delete mode 100644 src/Serializer/Upcast/Upcaster.php delete mode 100644 src/Serializer/Upcast/UpcasterChain.php rename tests/Unit/Cryptography/{ExtensionDoctrineCipherKeyStoreTest.php => DoctrineCipherKeyStoreTest.php} (89%) delete mode 100644 tests/Unit/Serializer/Upcast/UpcastTest.php delete mode 100644 tests/Unit/Serializer/Upcast/UpcasterChainTest.php diff --git a/composer.json b/composer.json index d08321907..41db83202 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.24.0", + "patchlevel/hydrator": "^2.0.1", "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 14e8b126c..63b62c077 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": "0e9502d1c80c844be7148084542ae739", + "content-hash": "4ba36719f1d1fecef8a880ef9882cc9b", "packages": [ { "name": "brick/math", @@ -416,16 +416,16 @@ }, { "name": "patchlevel/hydrator", - "version": "1.24.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "b33d9f92b25114156e9935c12c563195afdbeb13" + "reference": "dc71f773317829fe8d395aff81889b530e7dffbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/b33d9f92b25114156e9935c12c563195afdbeb13", - "reference": "b33d9f92b25114156e9935c12c563195afdbeb13", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/dc71f773317829fe8d395aff81889b530e7dffbc", + "reference": "dc71f773317829fe8d395aff81889b530e7dffbc", "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.1" }, - "time": "2026-06-13T11:46:58+00:00" + "time": "2026-09-01T07:51:00+00:00" }, { "name": "patchlevel/worker", diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 8992147a0..8c7d1c3ee 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -510,6 +510,221 @@ and replaced with the following headers: `Patchlevel\EventSourcing\Store\AggregateToStreamHeaderTranslator` has been removed. +## Hydrator + +`patchlevel/hydrator` has been updated to version 2.0. +It was rebuilt on top of a middleware stack and brings its own breaking changes, +for example the `MetadataHydrator` was replaced by the `StackHydrator`, +and custom normalizers now receive a `$context` array in `normalize` and `denormalize`. +Follow the [hydrator upgrade guide](https://github.com/patchlevel/hydrator/blob/2.0.x/UPGRADE-2.0.md) for these. + +## Serializer + +### Upcasting + +The upcaster of this library has been removed in favor of the `UpcastExtension` of the hydrator. +This affects the following classes: + +* `Patchlevel\EventSourcing\Serializer\Upcast\Upcast` +* `Patchlevel\EventSourcing\Serializer\Upcast\Upcaster` +* `Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain` + +Implement `Patchlevel\Hydrator\Extension\Upcast\Upcaster` instead and register it on the hydrator. +The upcaster now gets the class metadata of the resolved event instead of the event name. + +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 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; + } +} +``` +Upcasters can no longer rename events. Use the `aliases` option of the `#[Event]` attribute instead. + +before: + +```php +use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; +use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; + +final class EventNameRenameUpcaster implements Upcaster +{ + public function __invoke(Upcast $upcast): Upcast + { + if ($upcast->eventName === 'profile.created') { + return $upcast->replaceEventName('profile.registered'); + } + + return $upcast; + } +} +``` +after: + +```php +use Patchlevel\EventSourcing\Attribute\Event; + +#[Event(name: 'profile.registered', aliases: ['profile.created'])] +final class ProfileRegistered +{ +} +``` +### DefaultEventSerializer + +The `$upcaster` constructor argument of `DefaultEventSerializer` has been removed. +`DefaultEventSerializer::createFromPaths()` no longer accepts an upcaster and a cryptographer, +pass a configured hydrator instead. + +before: + +```php +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Serializer\Upcast\UpcasterChain; +use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; + +/** @var PayloadCryptographer $cryptographer */ +$serializer = DefaultEventSerializer::createFromPaths( + ['src/Domain'], + new UpcasterChain([new ProfileCreatedEmailLowerCastUpcaster()]), + $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\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +/** @var CipherKeyStore $cipherKeyStore */ +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension(beforeTransform: [new ProfileCreatedEmailLowerCastUpcaster()])) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); + +$serializer = DefaultEventSerializer::createFromPaths(['src/Domain'], $hydrator); +``` +## Snapshot + +### DefaultSnapshotStore + +`DefaultSnapshotStore::createDefault()` no longer accepts a `PayloadCryptographer` as second argument, +pass a configured hydrator instead. + +before: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; + +/** @var PayloadCryptographer $cryptographer */ +$snapshotStore = DefaultSnapshotStore::createDefault($adapters, $cryptographer); +``` +after: + +```php +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\Hydrator\Hydrator; + +/** @var Hydrator $hydrator */ +$snapshotStore = DefaultSnapshotStore::createDefault($adapters, $hydrator); +``` +## Personal Data + +The legacy cryptography of the hydrator (`PersonalDataPayloadCryptographer`, `#[PersonalData]`, ...) +has been removed. Use the `CryptographyExtension` of the hydrator instead, +see the [hydrator upgrade guide](https://github.com/patchlevel/hydrator/blob/2.0.x/UPGRADE-2.0.md#cryptography) +and the [personal data](personal-data.md) documentation. + +:::danger +Data encrypted with the legacy `PersonalDataPayloadCryptographer` can no longer be decrypted. +The new cryptographer does not recognize the legacy format and passes the encrypted value through unchanged. +Migrate your store and snapshots to the new format while you are still on 3.x, +where the `CryptographyExtension` can read legacy data with the legacy cryptographer as fallback. +::: + +### DoctrineCipherKeyStore + +The legacy `Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore`, which used the `crypto_keys` table, +has been removed. +`Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore` has been renamed to +`Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore`. It still uses the `cryptography_keys` table. + +before: + +```php +use Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore; + +$cipherKeyStore = new ExtensionDoctrineCipherKeyStore($connection); +``` +after: + +```php +use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; + +$cipherKeyStore = new DoctrineCipherKeyStore($connection); +``` +To delete the personal data of a subject, call `removeWithSubjectId()`. +`remove()` now expects the id of a single cipher key. + +before: + +```php +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; + +/** @var CipherKeyStore $cipherKeyStore */ +$cipherKeyStore->remove($profileId); +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore; + +/** @var CipherKeyStore $cipherKeyStore */ +$cipherKeyStore->removeWithSubjectId($profileId); +``` ## Schema ### DoctrineSchemaSubscriber diff --git a/docs/normalizer.md b/docs/normalizer.md index 260618d70..0fe39d147 100644 --- a/docs/normalizer.md +++ b/docs/normalizer.md @@ -351,6 +351,7 @@ final class Name For this we now need a custom normalizer. This normalizer must implement the `Normalizer` interface. You also need to implement a `normalize` and `denormalize` method. +Both methods receive the hydration context as second parameter. Finally, you have to allow the normalizer to be used as an attribute. ```php @@ -360,7 +361,8 @@ use Patchlevel\Hydrator\Normalizer\Normalizer; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] class NameNormalizer implements Normalizer { - public function normalize(mixed $value): string + /** @param array $context */ + public function normalize(mixed $value, array $context): string { if (!$value instanceof Name) { throw InvalidArgument::withWrongType(Name::class, $value); @@ -369,7 +371,8 @@ class NameNormalizer implements Normalizer return $value->toString(); } - public function denormalize(mixed $value): Name|null + /** @param array $context */ + public function denormalize(mixed $value, array $context): Name|null { if ($value === null) { return null; diff --git a/docs/personal-data.md b/docs/personal-data.md index 1d0420d71..48dc42a3e 100644 --- a/docs/personal-data.md +++ b/docs/personal-data.md @@ -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 { @@ -43,28 +43,28 @@ final class EmailChanged 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, ) { } } ``` :::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. @@ -72,16 +72,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, ) { } @@ -138,33 +138,39 @@ $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 have to put the whole thing together in a hydrator with the `CryptographyExtension`. ```php -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +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); +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->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, ); ``` :::note @@ -177,14 +183,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, ); ``` :::note @@ -203,7 +209,7 @@ 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 @@ -218,7 +224,7 @@ final class DeletePersonalDataProcessor { $event = $message->event(); - $this->cipherKeyStore->remove($event->personId); + $this->cipherKeyStore->removeWithSubjectId($event->personId); } } ``` diff --git a/docs/upcasting.md b/docs/upcasting.md index e7d122c83..ba06ff47c 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -3,8 +3,8 @@ 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 use the upcasting feature of the [hydrator](https://github.com/patchlevel/hydrator). +An `Upcaster` operates on the payload before it is denormalized to an event object. ## Adjust payload @@ -14,84 +14,106 @@ 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 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; + 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. +Keep in mind that all hydrated classes are passed to the upcaster, so an early return for unrelated classes is recommended. ::: +For simple cases you can use the `CallbackUpcaster`, which only gets called for the given class. + +```php +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; + +$upcaster = CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + $data['email'] = strtolower($data['email']); + + return $data; + }, +); +``` ## 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. +Upcasters work on the payload of an already resolved event class, so they can't change the event name. +Use [aliases](events.md#alias) instead, the old name will still be resolved to the new event class. ```php -use Patchlevel\EventSourcing\Serializer\Upcast\Upcast; -use Patchlevel\EventSourcing\Serializer\Upcast\Upcaster; +use Patchlevel\EventSourcing\Attribute\Event; -final class EventNameRenameUpcaster implements Upcaster +#[Event(name: 'profile.registered', aliases: ['profile.created'])] +final class ProfileRegistered { - /** @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]); - } - - return $upcast; - } } ``` -:::tip -Events can also have [aliases](events.md#alias). This is usually sufficient. -::: - ## 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 have to register them in the hydrator with 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( + beforeTransform: [ + new ProfileCreatedEmailLowerCastUpcaster(), + ], + )) + ->build(); $serializer = DefaultEventSerializer::createFromPaths( ['src/Domain'], - $upcaster, + $hydrator, ); ``` +The `UpcastExtension` has two stages where upcasters can be registered. +Upcasters in `beforeEncoding` get the raw stored payload, before any values are decoded, +for example before [personal data](personal-data.md) is decrypted. +This is the right place to rename or move fields. +Upcasters in `beforeTransform` run right before the object is built and see the decoded values. +Use this stage if you want to change the value of an encrypted field. + +:::tip +The snapshot store uses its own hydrator. If you need upcasting for snapshots as well, +pass a hydrator with the `UpcastExtension` to the [snapshot store](snapshots.md) too. +::: + ## Learn more * [How to create messages](message.md) * [How to define events](events.md) * [How to configure store](store.md) +* [How to use the hydrator](https://github.com/patchlevel/hydrator) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3e22d7980..aa5f7e8ed 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\.$#' 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 46eebe40e..992cc5ae1 100644 --- a/src/Message/Serializer/DefaultHeadersSerializer.php +++ b/src/Message/Serializer/DefaultHeadersSerializer.php @@ -11,7 +11,7 @@ use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\Hydrator\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydrator; use function in_array; use function is_array; @@ -98,7 +98,7 @@ public static function createFromPaths(array $paths, array $gracefulMissingHeade { return new self( (new AttributeMessageHeaderRegistryFactory())->create($paths), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), $gracefulMissingHeaders, ); @@ -108,7 +108,7 @@ public static function createDefault(): static { return new self( MessageHeaderRegistry::createWithInternalHeaders(), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), [], ); diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index 260098936..895acd735 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -8,19 +8,17 @@ 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\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydrator; + +use function is_array; final class DefaultEventSerializer implements EventSerializer { public function __construct( private EventRegistry $eventRegistry, - private Hydrator $hydrator = new MetadataHydrator(), + private Hydrator $hydrator = new StackHydrator(), private Encoder $encoder = new JsonEncoder(), - private Upcaster|null $upcaster = null, ) { } @@ -30,9 +28,16 @@ public function serialize(object $event, array $options = []): SerializedEvent $name = $this->eventRegistry->eventName($event::class); $data = $this->hydrator->extract($event); + if (!is_array($data)) { + throw new EventPayloadNotAnArray($event::class, $data); + } + + /** @var array $payload */ + $payload = $data; + return new SerializedEvent( $name, - $this->encoder->encode($data, $options), + $this->encoder->encode($payload, $options), ); } @@ -40,15 +45,7 @@ public function serialize(object $event, array $options = []): SerializedEvent public function deserialize(SerializedEvent $data, array $options = []): object { $payload = $this->encoder->decode($data->payload, $options); - - $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); + $class = $this->eventRegistry->eventClass($data->name); return $this->hydrator->hydrate($class, $payload); } @@ -56,14 +53,12 @@ public function deserialize(SerializedEvent $data, array $options = []): object /** @param list $paths */ public static function createFromPaths( array $paths, - Upcaster|null $upcaster = null, - PayloadCryptographer|null $cryptographer = null, + Hydrator $hydrator = new StackHydrator(), ): static { return new self( (new AttributeEventRegistryFactory())->create($paths), - new MetadataHydrator(cryptographer: $cryptographer), + $hydrator, new JsonEncoder(), - $upcaster, ); } } diff --git a/src/Serializer/EventPayloadNotAnArray.php b/src/Serializer/EventPayloadNotAnArray.php new file mode 100644 index 000000000..832395842 --- /dev/null +++ b/src/Serializer/EventPayloadNotAnArray.php @@ -0,0 +1,23 @@ + $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..3fc04a03d 100644 --- a/src/Snapshot/DefaultSnapshotStore.php +++ b/src/Snapshot/DefaultSnapshotStore.php @@ -9,9 +9,8 @@ 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\Hydrator; -use Patchlevel\Hydrator\MetadataHydrator; +use Patchlevel\Hydrator\StackHydrator; use Throwable; use function array_key_exists; @@ -38,7 +37,7 @@ public function __construct( $this->adapterRepository = $adapterRepository; } - $this->hydrator = $hydrator ?? new MetadataHydrator(); + $this->hydrator = $hydrator ?? new StackHydrator(); $this->metadataFactory = $metadataFactory ?? new AggregateRootMetadataAwareMetadataFactory(); } @@ -118,11 +117,11 @@ 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 $hydrator = new StackHydrator()): self { return new self( new ArrayAdapterRepository($snapshotAdapters), - new MetadataHydrator(cryptographer: $cryptographer), + $hydrator, ); } } 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/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php index 0e7b93a92..ff1bd4401 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,16 @@ public function setUp(): void $cipherKeyStore = new DoctrineCipherKeyStore($connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl( - $cipherKeyStore, - ); + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); $this->store = new StreamDoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths( [__DIR__ . '/BasicImplementation/Events'], - cryptographer: $cryptographer, + $hydrator, ), ); diff --git a/tests/Integration/PersonalData/Events/NameChanged.php b/tests/Integration/PersonalData/Events/NameChanged.php index bc6c1edd0..bddb58109 100644 --- a/tests/Integration/PersonalData/Events/NameChanged.php +++ b/tests/Integration/PersonalData/Events/NameChanged.php @@ -6,8 +6,6 @@ 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\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/ProfileCreated.php b/tests/Integration/PersonalData/Events/ProfileCreated.php index bb9e775f3..b7ac3ddc8 100644 --- a/tests/Integration/PersonalData/Events/ProfileCreated.php +++ b/tests/Integration/PersonalData/Events/ProfileCreated.php @@ -6,8 +6,6 @@ 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\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/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index 3c71f04be..31879b6ce 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -6,9 +6,7 @@ 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; @@ -25,9 +23,10 @@ use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\PersonalData\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\Extension\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; @@ -50,11 +49,11 @@ public function tearDown(): void public function testSuccessfulWithEvent(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + $hydrator = $this->createHydrator($cipherKeyStore); $store = new StreamDoctrineDbalStore( $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], $hydrator), ); $manager = new DefaultRepositoryManager( @@ -99,7 +98,7 @@ public function testSuccessfulWithEvent(): void public function testRemoveKeyWithEvent(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + $hydrator = $this->createHydrator($cipherKeyStore); $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, @@ -107,7 +106,7 @@ public function testRemoveKeyWithEvent(): void $store = new StreamDoctrineDbalStore( $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], $hydrator), ); $manager = new DefaultRepositoryManager( @@ -174,7 +173,7 @@ public function testRemoveKeyWithEvent(): void public function testRemoveKeyWithEventAndSnapshot(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + $hydrator = $this->createHydrator($cipherKeyStore); $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, @@ -182,7 +181,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 +192,7 @@ public function testRemoveKeyWithEventAndSnapshot(): void null, DefaultSnapshotStore::createDefault( ['default' => $snapshotAdapter], - $cryptographer, + $hydrator, ), ); @@ -232,7 +231,7 @@ public function testRemoveKeyWithEventAndSnapshot(): void self::assertSame(2, $profile->playhead()); self::assertSame('John 2', $profile->name()); - $cipherKeyStore->remove($profileId->toString()); + $cipherKeyStore->removeWithSubjectId($profileId->toString()); $profile = $repository->load($profileId); @@ -242,161 +241,11 @@ public function testRemoveKeyWithEventAndSnapshot(): void self::assertSame('unknown', $profile->name()); } - public function testWithStackHydrator(): void + private function createHydrator(CipherKeyStore $cipherKeyStore): Hydrator { - $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::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), - ); - - $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']); + return (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); } } diff --git a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php index ab07bcc9d..53d051871 100644 --- a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php +++ b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php @@ -7,7 +7,7 @@ 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\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/PersonalData/Profile.php index 50d324df0..864558783 100644 --- a/tests/Integration/PersonalData/Profile.php +++ b/tests/Integration/PersonalData/Profile.php @@ -12,8 +12,8 @@ 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\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/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php b/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php similarity index 89% rename from tests/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php rename to tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php index 55e0a2045..b56b9a48f 100644 --- a/tests/Unit/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php +++ b/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; -use Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore; +use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use PHPUnit\Framework\Attributes\CoversClass; @@ -18,8 +18,8 @@ use function base64_encode; -#[CoversClass(ExtensionDoctrineCipherKeyStore::class)] -final class ExtensionDoctrineCipherKeyStoreTest extends TestCase +#[CoversClass(DoctrineCipherKeyStore::class)] +final class DoctrineCipherKeyStoreTest extends TestCase { public function testGet(): void { @@ -40,7 +40,7 @@ public function testGet(): void ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); self::assertEquals( new CipherKey( @@ -63,7 +63,7 @@ public function testGetNotFound(): void ->with('SELECT * FROM cryptography_keys WHERE id = :id', ['id' => 'foo']) ->willReturn(false); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $this->expectException(CipherKeyNotExists::class); @@ -89,7 +89,7 @@ public function testCurrentKeyFor(): void ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); self::assertEquals( new CipherKey( @@ -112,7 +112,7 @@ public function testCurrentKeyForNotFound(): void ->with('SELECT * FROM cryptography_keys WHERE subject_id = :subject_id', ['subject_id' => 'profile-1']) ->willReturn(false); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $this->expectException(CipherKeyNotExists::class); @@ -141,7 +141,7 @@ public function testStore(): void 'created_at' => $expectedDate, ]); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $store->store(new CipherKey( 'foo', @@ -160,7 +160,7 @@ public function testRemove(): void ->method('delete') ->with('cryptography_keys', ['id' => 'foo']); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $store->remove('foo'); } @@ -173,7 +173,7 @@ public function testRemoveWithSubjectId(): void ->method('delete') ->with('cryptography_keys', ['subject_id' => 'profile-1']); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $store->removeWithSubjectId('profile-1'); } @@ -186,7 +186,7 @@ public function testCustomTableName(): void ->method('delete') ->with('my_keys', ['id' => 'foo']); - $store = new ExtensionDoctrineCipherKeyStore($connection, 'my_keys'); + $store = new DoctrineCipherKeyStore($connection, 'my_keys'); $store->remove('foo'); } @@ -195,7 +195,7 @@ public function testConfigureSchema(): void { $connection = $this->createMock(Connection::class); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $expectedSchema = new Schema(); $table = $expectedSchema->createTable('cryptography_keys'); @@ -236,7 +236,7 @@ public function testConfigureSchemaWithDifferentDatabase(): void ->method('getParams') ->willReturn(['dbname' => 'db2']); - $store = new ExtensionDoctrineCipherKeyStore($connection); + $store = new DoctrineCipherKeyStore($connection); $schema = new Schema(); $store->configureSchema($schema, $differentConnection); 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 dc8695ffe..0686c2055 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -15,7 +15,7 @@ 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\StackHydrator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -47,7 +47,7 @@ public function testDeserialize(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ); @@ -70,7 +70,7 @@ public function testDeserializeUnknownHeadersAsMissingHeaders(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ['removed', 'alsoRemoved'], ); @@ -95,7 +95,7 @@ public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ['removed'], ); @@ -111,7 +111,7 @@ public function testDeserializeWildcardHandlesAllUnknownHeaders(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ['*'], ); @@ -136,7 +136,7 @@ public function testSerializeMissingHeadersRoundTrip(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ); @@ -160,7 +160,7 @@ public function testDeserializeWithInvalidHeaderPayload(): void (new AttributeMessageHeaderRegistryFactory())->create([ __DIR__ . '/../../Fixture', ]), - new MetadataHydrator(), + new StackHydrator(), new JsonEncoder(), ); diff --git a/tests/Unit/Serializer/DefaultEventSerializerTest.php b/tests/Unit/Serializer/DefaultEventSerializerTest.php index cba9905c6..b4661222e 100644 --- a/tests/Unit/Serializer/DefaultEventSerializerTest.php +++ b/tests/Unit/Serializer/DefaultEventSerializerTest.php @@ -4,20 +4,22 @@ 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\EventPayloadNotAnArray; 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 Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function sprintf; + #[CoversClass(DefaultEventSerializer::class)] final class DefaultEventSerializerTest extends TestCase { @@ -41,88 +43,52 @@ public function testSerialize(): void ); } - public function testDeserialize(): void + public function testSerializeWithNonArrayPayload(): void { - $expected = new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ); + $hydrator = $this->createStub(Hydrator::class); + $hydrator->method('extract')->willReturn('foo'); - $event = $this->serializer->deserialize( - new SerializedEvent( - 'profile_created', - '{"profileId":"1","email":"info@patchlevel.de"}', - ), - ); + $serializer = DefaultEventSerializer::createFromPaths([__DIR__ . '/../Fixture'], $hydrator); - self::assertEquals($expected, $event); + $this->expectException(EventPayloadNotAnArray::class); + $this->expectExceptionMessage(sprintf('The event "%s" has to be extracted to an array, "string" given.', ProfileCreated::class)); + + $serializer->serialize(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + )); } - public function testSerializeWithUpcasting(): void + public function testDeserialize(): 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( + $event = $this->serializer->deserialize( new SerializedEvent( - 'profile_created_old', - '{"profileId":"1"}', + 'profile_created', + '{"profileId":"1","email":"info@patchlevel.de"}', ), ); self::assertEquals($expected, $event); } - public function testSerializeWithUpcastingChain(): void + public function testDeserializeWithUpcasting(): 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]), - ); + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension([ + CallbackUpcaster::forClass( + ProfileCreated::class, + static fn (array $data): array => $data + ['email' => 'info@patchlevel.de'], + ), + ])) + ->build(); + + $serializer = DefaultEventSerializer::createFromPaths([__DIR__ . '/../Fixture'], $hydrator); $expected = new ProfileCreated( ProfileId::fromString('1'), @@ -131,8 +97,8 @@ public function __invoke(Upcast $upcast): Upcast $event = $serializer->deserialize( new SerializedEvent( - 'profile_created_very_old', - '{"id":"1"}', + 'profile_created', + '{"profileId":"1"}', ), ); diff --git a/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php b/tests/Unit/Serializer/Normalizer/IdNormalizerTest.php index ac2dd3dc4..842463b7a 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, - ); - } -} From a58b1d232340a17fad2f1c4d5cee6cc81c891bcc Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 16:25:08 +0200 Subject: [PATCH 2/5] Pass event context to the hydrator and rename personal data docs The event serializer now passes the stored event name and the event class as context to the hydrator, so upcasters can tell under which name an event was stored, which matters since renames are handled with aliases. The headers serializer factories accept a custom hydrator as well. The personal data page is renamed to sensitive data to match the new SensitiveData attribute, and the upgrade guide covers the moved attributes. --- README.md | 2 +- docs/UPGRADE-4.0.md | 48 ++++++++++++++- docs/introduction.md | 2 +- docs/normalizer.md | 4 +- docs/project.json | 4 +- docs/{personal-data.md => sensitive-data.md} | 23 +++---- docs/snapshots.md | 2 +- docs/upcasting.md | 24 +++++++- .../Serializer/DefaultHeadersSerializer.php | 17 +++--- src/Serializer/DefaultEventSerializer.php | 13 +++- .../DefaultHeadersSerializerTest.php | 51 ++++++++++++++++ .../Serializer/DefaultEventSerializerTest.php | 60 +++++++++++++++++++ 12 files changed, 221 insertions(+), 29 deletions(-) rename docs/{personal-data.md => sensitive-data.md} (85%) diff --git a/README.md b/README.md index 5d85c3eb1..5b697168e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ powered by the reliable Doctrine ecosystem and focused on developer experience. * Automatic [snapshot](https://patchlevel.dev/docs/event-sourcing/latest/snapshots)-system to boost your performance * [Split](https://patchlevel.dev/docs/event-sourcing/latest/split-stream) big aggregates into multiple streams * Versioned and managed lifecycle of [subscriptions](https://patchlevel.dev/docs/event-sourcing/latest/subscription) like projections and processors -* Safe usage of [personal data](https://patchlevel.dev/docs/event-sourcing/latest/personal-data) with crypto-shredding +* Safe usage of [sensitive and personal data](https://patchlevel.dev/docs/event-sourcing/latest/sensitive-data) with crypto-shredding * Smooth [upcasting](https://patchlevel.dev/docs/event-sourcing/latest/upcasting) of old events * Simple setup with [schema management](https://patchlevel.dev/docs/event-sourcing/latest/store) and [doctrine migration](https://patchlevel.dev/docs/event-sourcing/latest/store) * Built in [cli commands](https://patchlevel.dev/docs/event-sourcing/latest/cli) with [symfony](https://symfony.com/) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 8c7d1c3ee..c84de67ff 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -671,12 +671,12 @@ use Patchlevel\Hydrator\Hydrator; /** @var Hydrator $hydrator */ $snapshotStore = DefaultSnapshotStore::createDefault($adapters, $hydrator); ``` -## Personal Data +## Sensitive Data The legacy cryptography of the hydrator (`PersonalDataPayloadCryptographer`, `#[PersonalData]`, ...) has been removed. Use the `CryptographyExtension` of the hydrator instead, see the [hydrator upgrade guide](https://github.com/patchlevel/hydrator/blob/2.0.x/UPGRADE-2.0.md#cryptography) -and the [personal data](personal-data.md) documentation. +and the [sensitive data](sensitive-data.md) documentation. :::danger Data encrypted with the legacy `PersonalDataPayloadCryptographer` can no longer be decrypted. @@ -685,6 +685,50 @@ Migrate your store and snapshots to the new format while you are still on 3.x, where the `CryptographyExtension` can read legacy data with the legacy cryptographer as fallback. ::: +### Attributes + +The attributes have been moved to the cryptography extension and `PersonalData` has been renamed to `SensitiveData`: + +* `Patchlevel\Hydrator\Attribute\DataSubjectId` is now `Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId` +* `Patchlevel\Hydrator\Attribute\PersonalData` is now `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`, which used the `crypto_keys` table, diff --git a/docs/introduction.md b/docs/introduction.md index b75d15139..eb1890aff 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 [sensitive and 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 0fe39d147..ef1687f7e 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 @@ -462,4 +462,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 sensitive and personal data](sensitive-data.md) diff --git a/docs/project.json b/docs/project.json index 447e91cb3..3ba963303 100644 --- a/docs/project.json +++ b/docs/project.json @@ -65,8 +65,8 @@ "file": "snapshots.md" }, { - "title": "Personal Data", - "file": "personal-data.md" + "title": "Sensitive Data", + "file": "sensitive-data.md" }, { "title": "Upcasting", diff --git a/docs/personal-data.md b/docs/sensitive-data.md similarity index 85% rename from docs/personal-data.md rename to docs/sensitive-data.md index 48dc42a3e..3c356848a 100644 --- a/docs/personal-data.md +++ b/docs/sensitive-data.md @@ -1,18 +1,21 @@ -# Personal Data (GDPR) +# Sensitive and Personal Data (GDPR) -According to GDPR, personal data must be able to be deleted upon request. +Events are immutable, but some data in them must not stay readable forever. +The most common case is personal data (PII) like names, email addresses or phone numbers. +According to the GDPR, personal data must be deleted upon request (the "right to be forgotten"). +The same applies to other sensitive data, like payment details or data that is only allowed to be kept for a certain time. But here we have the problem that our events are immutable and we cannot easily manipulate the event store. -The first solution is not to save the personal data in the Event Store at all +The first solution is not to save the sensitive data in the Event Store at all and use something different for this, for example a separate table or an ORM. -The other option the library offers is crypto shredding. -In this process, the personal data is encrypted with a key that is assigned to a subject (like person). +The other option the library offers is crypto-shredding. +In this process, the sensitive or personal data is encrypted with a key that is assigned to a subject (like a person). When saving and reading the events, this key is then used to convert the data. This key with the subject is saved in a database. -As soon as a request for data deletion comes, -you can simply delete the key and the personal data can no longer be decrypted. +As soon as the data has to be deleted, +you can simply delete the key and the sensitive or personal data can no longer be decrypted. ## Configuration @@ -23,7 +26,7 @@ And if you use snapshots, you have to configure your aggregates too. ### DataSubjectId In order for the correct key to be used, a subject ID must be defined. -Without Subject Id, no personal data can be encrypted or decrypted. +Without Subject Id, no sensitive data can be encrypted or decrypted. ```php use Patchlevel\EventSourcing\Identifier\Uuid; @@ -198,12 +201,12 @@ More information can be found in the [snapshots](snapshots.md) documentation. ::: :::success -Now you can save and read events with personal data. +Now you can save and read events with sensitive data. ::: ## Remove personal data -To remove personal data, you can either remove the key manually or do it with a processor. +To remove personal or other sensitive data, you can either remove the key manually or do it with a processor. ```php use Patchlevel\EventSourcing\Attribute\Processor; diff --git a/docs/snapshots.md b/docs/snapshots.md index 6e8bf93da..ccb30b60d 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 sensitive and personal data](sensitive-data.md) diff --git a/docs/upcasting.md b/docs/upcasting.md index ba06ff47c..f5457b42f 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -74,6 +74,28 @@ final class ProfileRegistered { } ``` +If the payload changed together with the name, the upcaster needs to know under which name the event was stored. +The serializer passes it in the context as `DefaultEventSerializer::CONTEXT_EVENT_NAME`, +the resolved class is available as `DefaultEventSerializer::CONTEXT_EVENT_CLASS`. + +```php +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; + +$upcaster = CallbackUpcaster::forClass( + ProfileRegistered::class, + static function (array $data, array $context): array { + if ($context[DefaultEventSerializer::CONTEXT_EVENT_NAME] !== 'profile.created') { + return $data; + } + + $data['registeredAt'] = $data['createdAt']; + unset($data['createdAt']); + + return $data; + }, +); +``` ## Configure After we have defined the upcasting rules, we have to register them in the hydrator with the `UpcastExtension` @@ -101,7 +123,7 @@ $serializer = DefaultEventSerializer::createFromPaths( ``` The `UpcastExtension` has two stages where upcasters can be registered. Upcasters in `beforeEncoding` get the raw stored payload, before any values are decoded, -for example before [personal data](personal-data.md) is decrypted. +for example before [personal data](sensitive-data.md) is decrypted. This is the right place to rename or move fields. Upcasters in `beforeTransform` run right before the object is built and see the decoded values. Use this stage if you want to change the value of an encrypted field. diff --git a/src/Message/Serializer/DefaultHeadersSerializer.php b/src/Message/Serializer/DefaultHeadersSerializer.php index 992cc5ae1..d75fc643a 100644 --- a/src/Message/Serializer/DefaultHeadersSerializer.php +++ b/src/Message/Serializer/DefaultHeadersSerializer.php @@ -23,8 +23,8 @@ final class DefaultHeadersSerializer implements HeadersSerializer /** @param list $gracefulMissingHeaders */ public function __construct( private readonly MessageHeaderRegistry $messageHeaderRegistry, - private readonly Hydrator $hydrator, - private readonly Encoder $encoder, + private readonly Hydrator $hydrator = new StackHydrator(), + private readonly Encoder $encoder = new JsonEncoder(), private readonly array $gracefulMissingHeaders = [], ) { $this->handleAllHeadersGraceful = in_array('*', $this->gracefulMissingHeaders, true); @@ -94,21 +94,24 @@ public function deserialize(string $string, array $options = []): array * @param list $paths * @param list $gracefulMissingHeaders */ - public static function createFromPaths(array $paths, array $gracefulMissingHeaders = []): static - { + public static function createFromPaths( + array $paths, + array $gracefulMissingHeaders = [], + Hydrator $hydrator = new StackHydrator(), + ): static { return new self( (new AttributeMessageHeaderRegistryFactory())->create($paths), - new StackHydrator(), + $hydrator, new JsonEncoder(), $gracefulMissingHeaders, ); } - public static function createDefault(): static + public static function createDefault(Hydrator $hydrator = new StackHydrator()): static { return new self( MessageHeaderRegistry::createWithInternalHeaders(), - new StackHydrator(), + $hydrator, new JsonEncoder(), [], ); diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index 895acd735..0bbb2c64d 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -15,6 +15,9 @@ final class DefaultEventSerializer implements EventSerializer { + public const CONTEXT_EVENT_NAME = 'event_name'; + public const CONTEXT_EVENT_CLASS = 'event_class'; + public function __construct( private EventRegistry $eventRegistry, private Hydrator $hydrator = new StackHydrator(), @@ -26,7 +29,10 @@ public function __construct( public function serialize(object $event, array $options = []): SerializedEvent { $name = $this->eventRegistry->eventName($event::class); - $data = $this->hydrator->extract($event); + $data = $this->hydrator->extract($event, [ + self::CONTEXT_EVENT_NAME => $name, + self::CONTEXT_EVENT_CLASS => $event::class, + ]); if (!is_array($data)) { throw new EventPayloadNotAnArray($event::class, $data); @@ -47,7 +53,10 @@ public function deserialize(SerializedEvent $data, array $options = []): object $payload = $this->encoder->decode($data->payload, $options); $class = $this->eventRegistry->eventClass($data->name); - 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 */ diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 0686c2055..beb3f95d9 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -15,7 +15,11 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; use Patchlevel\Hydrator\StackHydrator; +use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -179,4 +183,51 @@ public function testCreateDefault(): void self::assertEquals('{"streamName":{"streamName":"profile-1"}}', $content); self::assertEquals([new StreamNameHeader('profile-1')], $serializer->deserialize($content)); } + + public function testDeserializeWithCustomHydrator(): void + { + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension([ + CallbackUpcaster::forClass( + StreamNameHeader::class, + static function (array $data): array { + self::assertIsString($data['id']); + + return ['streamName' => 'profile-' . $data['id']]; + }, + ), + ])) + ->build(); + + $serializer = DefaultHeadersSerializer::createFromPaths( + [__DIR__ . '/../../Fixture'], + hydrator: $hydrator, + ); + + self::assertEquals( + [new StreamNameHeader('profile-1')], + $serializer->deserialize('{"streamName":{"id":"1"}}'), + ); + } + + public function testCreateDefaultWithCustomHydrator(): void + { + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension([ + CallbackUpcaster::forClass( + PlayheadHeader::class, + static fn (array $data): array => ['playhead' => 42], + ), + ])) + ->build(); + + $serializer = DefaultHeadersSerializer::createDefault($hydrator); + + self::assertEquals( + [new PlayheadHeader(42)], + $serializer->deserialize('{"playhead":{"playhead":1}}'), + ); + } } diff --git a/tests/Unit/Serializer/DefaultEventSerializerTest.php b/tests/Unit/Serializer/DefaultEventSerializerTest.php index b4661222e..be0c1a5bc 100644 --- a/tests/Unit/Serializer/DefaultEventSerializerTest.php +++ b/tests/Unit/Serializer/DefaultEventSerializerTest.php @@ -104,4 +104,64 @@ public function testDeserializeWithUpcasting(): void self::assertEquals($expected, $event); } + + public function testSerializePassesEventContextToHydrator(): void + { + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator = $this->createMock(Hydrator::class); + $hydrator + ->expects($this->once()) + ->method('extract') + ->with($event, [ + DefaultEventSerializer::CONTEXT_EVENT_NAME => 'profile_created', + DefaultEventSerializer::CONTEXT_EVENT_CLASS => ProfileCreated::class, + ]) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); + + $serializer = DefaultEventSerializer::createFromPaths([__DIR__ . '/../Fixture'], $hydrator); + + self::assertEquals( + new SerializedEvent('profile_created', '{"profileId":"1","email":"info@patchlevel.de"}'), + $serializer->serialize($event), + ); + } + + public function testDeserializePassesEventContextToUpcaster(): void + { + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension([ + CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data, array $context): array { + self::assertSame('profile_created', $context[DefaultEventSerializer::CONTEXT_EVENT_NAME]); + self::assertSame(ProfileCreated::class, $context[DefaultEventSerializer::CONTEXT_EVENT_CLASS]); + + return $data; + }, + ), + ])) + ->build(); + + $serializer = DefaultEventSerializer::createFromPaths([__DIR__ . '/../Fixture'], $hydrator); + + $event = $serializer->deserialize( + new SerializedEvent( + 'profile_created', + '{"profileId":"1","email":"info@patchlevel.de"}', + ), + ); + + self::assertEquals( + new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ), + $event, + ); + } } From e252ec6dd234941e63bf7c746a77f77af35e2eaa Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 19:57:39 +0200 Subject: [PATCH 3/5] Cache cipher keys in the personal data benchmark The old DoctrineCipherKeyStore cached keys internally, the new one does not, so loading an aggregate ran one query per encrypted value. Instead of an unbounded cache in the store, which never gets cleared in long running workers, the benchmark and the docs now use the cache decorator of the hydrator with a lifetime and a limit. This needs hydrator 2.0.2, which fixes the cache keys and eviction of the decorators. --- composer.json | 3 +- composer.lock | 244 ++++++++++++++++++++++---- docs/sensitive-data.md | 28 +++ tests/Benchmark/PersonalDataBench.php | 9 +- 4 files changed, 250 insertions(+), 34 deletions(-) diff --git a/composer.json b/composer.json index 41db83202..989853114 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": "^2.0.1", + "patchlevel/hydrator": "^2.0.2", "patchlevel/worker": "^1.4.0", "psr/cache": "^2.0.0 || ^3.0.0", "psr/clock": "^1.0", @@ -59,6 +59,7 @@ "phpstan/phpstan": "^2.1.11", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^11.5.15", + "symfony/cache": "^6.4.0 || ^7.0.0 || ^8.0.0", "symfony/messenger": "^5.4.31 || ^6.4.0 || ^7.0.1 || ^8.0.0", "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", "wnx/commonmark-markdown-renderer": "^1.5.0" diff --git a/composer.lock b/composer.lock index 63b62c077..c75201dc5 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": "4ba36719f1d1fecef8a880ef9882cc9b", + "content-hash": "937390d066d947789911d3e870eba9a5", "packages": [ { "name": "brick/math", @@ -416,16 +416,16 @@ }, { "name": "patchlevel/hydrator", - "version": "2.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "dc71f773317829fe8d395aff81889b530e7dffbc" + "reference": "73aee5f6d9e35ba074ff2d4ade17cb1429475af7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/dc71f773317829fe8d395aff81889b530e7dffbc", - "reference": "dc71f773317829fe8d395aff81889b530e7dffbc", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/73aee5f6d9e35ba074ff2d4ade17cb1429475af7", + "reference": "73aee5f6d9e35ba074ff2d4ade17cb1429475af7", "shasum": "" }, "require": { @@ -443,6 +443,7 @@ "phpstan/phpstan": "^2.1.39", "phpstan/phpstan-phpunit": "^2.0.15", "phpunit/phpunit": "^11.5.53", + "symfony/cache": "^6.4.0 || ^7.0.0 || ^8.0.0", "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0" }, "type": "library", @@ -477,9 +478,9 @@ ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/2.0.1" + "source": "https://github.com/patchlevel/hydrator/tree/2.0.2" }, - "time": "2026-09-01T07:51:00+00:00" + "time": "2026-09-23T17:48:35+00:00" }, { "name": "patchlevel/worker", @@ -1098,16 +1099,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -1145,7 +1146,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1165,7 +1166,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", @@ -1486,16 +1487,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.40.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", - "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/70ba0627efc68e97ea392843458a2dd9d6dbd156", + "reference": "70ba0627efc68e97ea392843458a2dd9d6dbd156", "shasum": "" }, "require": { @@ -1549,7 +1550,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.42.0" }, "funding": [ { @@ -1569,7 +1570,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T07:27:17+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -1905,16 +1906,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -1968,7 +1969,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -1988,7 +1989,7 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/stopwatch", @@ -2230,22 +2231,22 @@ }, { "name": "symfony/var-exporter", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34" + "reference": "802362f41128c430fd6685491e1fb72127fae78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/2dd18582c5f6c024db9fc0ff9c76d873af726f34", - "reference": "2dd18582c5f6c024db9fc0ff9c76d873af726f34", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/802362f41128c430fd6685491e1fb72127fae78a", + "reference": "802362f41128c430fd6685491e1fb72127fae78a", "shasum": "" }, "require": { "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-deepclone": "^1.37" + "symfony/polyfill-deepclone": "^1.40" }, "require-dev": { "symfony/property-access": "^7.4|^8.0", @@ -2289,7 +2290,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.0" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.6" }, "funding": [ { @@ -2309,7 +2310,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-29T06:30:03+00:00" } ], "packages-dev": [ @@ -6997,6 +6998,185 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/cache", + "version": "v8.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "47616ab077b5aff4d0f718b96680a48599fc1a3f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/47616ab077b5aff4d0f718b96680a48599fc1a3f", + "reference": "47616ab077b5aff4d0f718b96680a48599fc1a3f", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" + }, + "conflict": { + "ext-redis": "<6.1", + "ext-relay": "<0.12.1" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v8.1.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-08T13:39:13+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", diff --git a/docs/sensitive-data.md b/docs/sensitive-data.md index 3c356848a..e93d13e78 100644 --- a/docs/sensitive-data.md +++ b/docs/sensitive-data.md @@ -141,6 +141,34 @@ $schemaDirector = new DoctrineSchemaDirector( ]), ); ``` +### Cache + +The `DoctrineCipherKeyStore` does not cache the keys, every encrypted value triggers a query. +If you load an aggregate with many events of the same subject, this adds up quickly. +Wrap the store with the `Psr6CacheStoreDecorator` or `Psr16CacheStoreDecorator` of the hydrator. + +```php +use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; +use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +/** @var DoctrineCipherKeyStore $doctrineCipherKeyStore */ +$cipherKeyStore = new Psr6CacheStoreDecorator( + $doctrineCipherKeyStore, + new ArrayAdapter(defaultLifetime: 60, maxItems: 1000), +); +``` +:::warning +Use a cache with a lifetime and a limit, especially in long running processes like workers. +Keys that are removed in another process stay in this cache until they expire, +so the data can still be decrypted there until then. +::: + +:::note +The decorator removes the cached keys when you remove them through it, +so removing personal data in the same process takes effect immediately. +::: + ### Hydrator Now we have to put the whole thing together in a hydrator with the `CryptographyExtension`. diff --git a/tests/Benchmark/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php index ff1bd4401..7170a4d05 100644 --- a/tests/Benchmark/PersonalDataBench.php +++ b/tests/Benchmark/PersonalDataBench.php @@ -19,8 +19,10 @@ use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; use Patchlevel\Hydrator\StackHydratorBuilder; use PhpBench\Attributes as Bench; +use Symfony\Component\Cache\Adapter\ArrayAdapter; #[Bench\BeforeMethods('setUp')] final class PersonalDataBench @@ -39,7 +41,12 @@ public function setUp(): void $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl( + new Psr6CacheStoreDecorator( + $cipherKeyStore, + new ArrayAdapter(defaultLifetime: 60, maxItems: 1000), + ), + ))) ->build(); $this->store = new StreamDoctrineDbalStore( From 1fc9b4045ec47ad1505735282fc23163d1f10dc9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 20:15:06 +0200 Subject: [PATCH 4/5] Disable xdebug and raise the memory limit for benchmarks phpbench runs the benchmarks in a subprocess, which did not get the memory limit from the Makefile. With xdebug enabled locally, benchSave10000Events ran out of memory, and xdebug also skews the measured times. --- phpbench.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/phpbench.json b/phpbench.json index 89bcf4eb7..1c346fdb3 100644 --- a/phpbench.json +++ b/phpbench.json @@ -2,6 +2,10 @@ "$schema":"./vendor/phpbench/phpbench/phpbench.schema.json", "runner.bootstrap": "vendor/autoload.php", "runner.file_pattern": "*Bench.php", + "runner.php_config": { + "memory_limit": "512M", + "xdebug.mode": "off" + }, "report.generators": { "diff": { "generator": "component", From c09c5fb18beb5d151bdc05408798acc2087bba82 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 21:35:48 +0200 Subject: [PATCH 5/5] Fix infection runs and cover EventPayloadNotAnArray The COLUMNS env in the phpunit config did not override an existing value, so console tests failed in the infection subprocess, where the SymfonyStyle error blocks got wrapped at 80 columns. The infection-diff target now uses the same options as CI, since --only-covered no longer exists. --- Makefile | 2 +- phpunit.xml.dist | 2 +- .../Serializer/EventPayloadNotAnArrayTest.php | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Serializer/EventPayloadNotAnArrayTest.php diff --git a/Makefile b/Makefile index c68c8f173..fc1a58d59 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ infection: vendor .PHONY: infection-diff infection-diff: vendor ## run infection on differences - php -d memory_limit=312M vendor/bin/infection --threads=max --git-diff-lines --git-diff-base=origin/HEAD --ignore-msi-with-no-mutations --only-covered --min-msi=80 --min-covered-msi=95 + php -d memory_limit=312M vendor/bin/infection --threads=max --git-diff-lines --git-diff-base=origin/HEAD --ignore-msi-with-no-mutations --with-uncovered --min-msi=90 --min-covered-msi=95 .PHONY: static static: phpstan cs ## run static analyser diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f953bade..3bc60af1a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -27,7 +27,7 @@ - + diff --git a/tests/Unit/Serializer/EventPayloadNotAnArrayTest.php b/tests/Unit/Serializer/EventPayloadNotAnArrayTest.php new file mode 100644 index 000000000..3f6d78bca --- /dev/null +++ b/tests/Unit/Serializer/EventPayloadNotAnArrayTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +}