From 9c0d8ddad8efcc05c05590ca53de461d838b82d7 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 22:38:13 +0200 Subject: [PATCH] Run upcasters only for the versions they were written for An upcaster can declare the version of the data it expects with from, in the UpcasterFor attribute or as argument of the CallbackUpcaster. The version of the hydrated data is passed per class in the context with UpcastExtension::VERSIONS. Versioned upcasters only run for data of their version or older, without a version the data is treated as up to date. Upcasters without a version run for every version as before. The versioned upcasters of a class have to be registered in the order of their versions, the middleware throws an exception otherwise. --- docs/upcasting.md | 64 +++++++++ .../Upcast/Attribute/UpcasterFor.php | 8 +- src/Extension/Upcast/CallbackUpcaster.php | 13 +- src/Extension/Upcast/UpcastExtension.php | 6 + src/Extension/Upcast/UpcastMiddleware.php | 87 ++++++++++-- .../Upcast/UpcastersNotInVersionOrder.php | 24 ++++ .../Extension/Upcast/CallbackUpcasterTest.php | 7 + .../Extension/Upcast/UpcastExtensionTest.php | 39 ++++++ .../Extension/Upcast/UpcastMiddlewareTest.php | 128 ++++++++++++++++++ 9 files changed, 361 insertions(+), 15 deletions(-) create mode 100644 src/Extension/Upcast/UpcastersNotInVersionOrder.php diff --git a/docs/upcasting.md b/docs/upcasting.md index 507333b8..a91791e4 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -117,6 +117,70 @@ values, while a `beforeMapping` upcaster sees the decrypted ones. Pick the stage that matches the data you need. ::: +## Versions + +Upcasters usually have to find out the shape of the stored data themselves, +for example by checking which fields exist. If you store a version together +with the data, the upcasters can rely on it instead. Give each upcaster the +version of the data it expects with `from`, it returns the data of the next +version. Pass the version of the stored data per class in the context with +`UpcastExtension::VERSIONS`. + +```php +use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new UpcastExtension( + beforeMapping: [ + CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + $data['name'] = $data['firstName'] . ' ' . $data['lastName']; + unset($data['firstName'], $data['lastName']); + + return $data; + }, + from: 1, + ), + CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + $data['email'] = strtolower($data['email']); + + return $data; + }, + from: 2, + ), + ], + )) + ->build(); + +$profile = $hydrator->hydrate( + ProfileCreated::class, + $payload, + [UpcastExtension::VERSIONS => [ProfileCreated::class => $version]], +); +``` +Data of version 1 runs through both upcasters, data of version 2 only through +the second one and data of version 3, the current one, through none of them. +Upcasters without a version run for data of every version, as before. An +upcaster class gets its version with the attribute, +`#[UpcasterFor(ProfileCreated::class, from: 1)]`. + +:::note +Without a version in the context the data is treated as up to date, the +versioned upcasters don't run. The version only applies to the given class, the +data of nested objects is part of its shape. +::: + +:::warning +Register the versioned upcasters of a class in the order of their versions, +the `UpcastMiddleware` throws an `UpcastersNotInVersionOrder` exception +otherwise. +::: + ## Learn more * [How to write your own extension](extensions.md) diff --git a/src/Extension/Upcast/Attribute/UpcasterFor.php b/src/Extension/Upcast/Attribute/UpcasterFor.php index 02efaedc..9a527b59 100644 --- a/src/Extension/Upcast/Attribute/UpcasterFor.php +++ b/src/Extension/Upcast/Attribute/UpcasterFor.php @@ -9,9 +9,15 @@ #[Attribute(Attribute::TARGET_CLASS)] final class UpcasterFor { - /** @param class-string $className */ + /** + * @param class-string $className + * @param int|null $from the version of the data the upcaster expects, it returns the data of the next + * version. The upcaster then only runs for data of this or an older version, see + * {@see \Patchlevel\Hydrator\Extension\Upcast\UpcastExtension::VERSIONS}. + */ public function __construct( public readonly string $className, + public readonly int|null $from = null, ) { } } diff --git a/src/Extension/Upcast/CallbackUpcaster.php b/src/Extension/Upcast/CallbackUpcaster.php index 17910e25..bc5b4b78 100644 --- a/src/Extension/Upcast/CallbackUpcaster.php +++ b/src/Extension/Upcast/CallbackUpcaster.php @@ -15,19 +15,24 @@ final class CallbackUpcaster implements Upcaster /** * @param class-string $className * @param callable(array, array): array $callback + * @param int|null $from the version of the data the callback expects, see {@see Attribute\UpcasterFor::$from} */ - public function __construct(public readonly string $className, callable $callback) - { + public function __construct( + public readonly string $className, + callable $callback, + public readonly int|null $from = null, + ) { $this->callback = Closure::fromCallable($callback); } /** * @param class-string $className * @param callable(array, array): array $callback + * @param int|null $from the version of the data the callback expects, see {@see Attribute\UpcasterFor::$from} */ - public static function forClass(string $className, callable $callback): self + public static function forClass(string $className, callable $callback, int|null $from = null): self { - return new self($className, $callback); + return new self($className, $callback, $from); } /** diff --git a/src/Extension/Upcast/UpcastExtension.php b/src/Extension/Upcast/UpcastExtension.php index 956f7bfa..2618cbbd 100644 --- a/src/Extension/Upcast/UpcastExtension.php +++ b/src/Extension/Upcast/UpcastExtension.php @@ -9,6 +9,12 @@ final readonly class UpcastExtension implements Extension { + /** + * Context key with the version of the hydrated data by class, for example [ProfileCreated::class => 1]. Versioned + * upcasters only run for data of their version or older, without a version the data is treated as up to date. + */ + public const VERSIONS = 'upcast_versions'; + /** * @param list $beforeEncoding upcasters that reshape the raw stored payload before its values are decoded * @param list $beforeMapping upcasters that run after value decoding, right before the object is built diff --git a/src/Extension/Upcast/UpcastMiddleware.php b/src/Extension/Upcast/UpcastMiddleware.php index 1a2f7571..c5dbbd83 100644 --- a/src/Extension/Upcast/UpcastMiddleware.php +++ b/src/Extension/Upcast/UpcastMiddleware.php @@ -11,18 +11,52 @@ use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use ReflectionClass; -use function array_map; +use function is_array; +use function is_int; final readonly class UpcastMiddleware implements SkippableMiddleware { /** @var list */ private array $targets; - /** @param list $upcasters */ + /** @var list the version of the data each upcaster expects, null for data of every version */ + private array $versions; + + private bool $versioned; + + /** + * @param list $upcasters + * + * @throws UpcastersNotInVersionOrder if a versioned upcaster comes after one of a newer version of the same class. + */ public function __construct( private array $upcasters, ) { - $this->targets = array_map(self::resolveTarget(...), $upcasters); + $targets = []; + $versions = []; + + /** @var array $previous */ + $previous = []; + + foreach ($upcasters as $upcaster) { + [$target, $version] = self::resolveTarget($upcaster); + $targets[] = $target; + $versions[] = $version; + + if ($target === null || $version === null) { + continue; + } + + if (isset($previous[$target]) && $version < $previous[$target]) { + throw new UpcastersNotInVersionOrder($target, $previous[$target], $version); + } + + $previous[$target] = $version; + } + + $this->targets = $targets; + $this->versions = $versions; + $this->versioned = $previous !== []; } /** @@ -37,6 +71,8 @@ public function __construct( */ public function hydrate(ClassMetadata $metadata, array $data, array $context, Mapper $next): object { + $version = $this->versioned ? self::version($metadata, $context) : null; + foreach ($this->upcasters as $index => $upcaster) { $target = $this->targets[$index]; @@ -44,6 +80,13 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ma continue; } + $from = $this->versions[$index]; + + // the data is already newer than the version the upcaster expects, or up to date without a version + if ($from !== null && ($version === null || $from < $version)) { + continue; + } + $data = $upcaster->upcast($metadata, $data, $context); } @@ -89,24 +132,48 @@ public function skip(ClassMetadata $metadata): Skip } /** - * Resolve the class an upcaster is restricted to, either from its - * `#[UpcasterFor]` attribute or, for a `CallbackUpcaster`, from the class - * name it was built with. Null means the upcaster applies to every class. + * Resolve the class an upcaster is restricted to and the version of the data + * it expects, either from its `#[UpcasterFor]` attribute or, for a + * `CallbackUpcaster`, from the values it was built with. A null class means + * the upcaster applies to every class, a null version to data of every version. * - * @return class-string|null + * @return array{class-string|null, int|null} */ - private static function resolveTarget(Upcaster $upcaster): string|null + private static function resolveTarget(Upcaster $upcaster): array { if ($upcaster instanceof CallbackUpcaster) { - return $upcaster->className; + return [$upcaster->className, $upcaster->from]; } $attributes = (new ReflectionClass($upcaster))->getAttributes(UpcasterFor::class); if ($attributes === []) { + return [null, null]; + } + + $attribute = $attributes[0]->newInstance(); + + return [$attribute->className, $attribute->from]; + } + + /** + * The version of the hydrated data, see {@see UpcastExtension::VERSIONS}. + * + * @param ClassMetadata $metadata + * @param array $context + * + * @template T of object + */ + private static function version(ClassMetadata $metadata, array $context): int|null + { + $versions = $context[UpcastExtension::VERSIONS] ?? null; + + if (!is_array($versions)) { return null; } - return $attributes[0]->newInstance()->className; + $version = $versions[$metadata->className] ?? null; + + return is_int($version) ? $version : null; } } diff --git a/src/Extension/Upcast/UpcastersNotInVersionOrder.php b/src/Extension/Upcast/UpcastersNotInVersionOrder.php new file mode 100644 index 00000000..cb5b8d10 --- /dev/null +++ b/src/Extension/Upcast/UpcastersNotInVersionOrder.php @@ -0,0 +1,24 @@ + $data)->from); + self::assertSame(2, CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, 2)->from); + self::assertSame(3, (new CallbackUpcaster(UpcastFixture::class, static fn (array $data): array => $data, 3))->from); + } + public function testSkipDifferentClass(): void { $metadata = (new AttributeMetadataFactory())->metadata(LifecycleFixture::class); diff --git a/tests/Unit/Extension/Upcast/UpcastExtensionTest.php b/tests/Unit/Extension/Upcast/UpcastExtensionTest.php index 0037417c..c1b56b09 100644 --- a/tests/Unit/Extension/Upcast/UpcastExtensionTest.php +++ b/tests/Unit/Extension/Upcast/UpcastExtensionTest.php @@ -48,6 +48,45 @@ static function (array $data): array { self::assertSame('Jane Doe', $object->name); } + public function testIntegrationWithVersions(): void + { + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new UpcastExtension( + beforeMapping: [ + CallbackUpcaster::forClass( + UpcastFixture::class, + static function (array $data): array { + $firstName = $data['firstName'] ?? ''; + $lastName = $data['lastName'] ?? ''; + assert(is_string($firstName)); + assert(is_string($lastName)); + + return ['name' => $firstName . ' ' . $lastName]; + }, + from: 1, + ), + ], + )) + ->build(); + + $object = $hydrator->hydrate( + UpcastFixture::class, + ['firstName' => 'Jane', 'lastName' => 'Doe'], + [UpcastExtension::VERSIONS => [UpcastFixture::class => 1]], + ); + + self::assertSame('Jane Doe', $object->name); + + // data of the current version is left alone + $object = $hydrator->hydrate( + UpcastFixture::class, + ['name' => 'John Doe'], + [UpcastExtension::VERSIONS => [UpcastFixture::class => 2]], + ); + + self::assertSame('John Doe', $object->name); + } + public function testConfigureAroundCryptography(): void { $beforeEncodingUpcaster = CallbackUpcaster::forClass( diff --git a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php index 177e3a73..ae690ef0 100644 --- a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php +++ b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php @@ -7,6 +7,8 @@ use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\Upcaster; +use Patchlevel\Hydrator\Extension\Upcast\UpcastersNotInVersionOrder; +use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware; use Patchlevel\Hydrator\Mapper\Mapper; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; @@ -15,10 +17,16 @@ use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use function assert; +use function is_array; +use function sprintf; + #[CoversClass(UpcastMiddleware::class)] +#[CoversClass(UpcastersNotInVersionOrder::class)] final class UpcastMiddlewareTest extends TestCase { public function testHydrate(): void @@ -256,4 +264,124 @@ public function upcast(ClassMetadata $metadata, array $data, array $context): ar self::assertSame(Skip::Extract, $middleware->skip($metadata)); } + + /** @return iterable, list}> */ + public static function provideVersions(): iterable + { + yield 'version 1' => [[UpcastExtension::VERSIONS => [UpcastFixture::class => 1]], ['1 to 2', 'always', '2 to 3']]; + yield 'version 2' => [[UpcastExtension::VERSIONS => [UpcastFixture::class => 2]], ['always', '2 to 3']]; + yield 'current version' => [[UpcastExtension::VERSIONS => [UpcastFixture::class => 3]], ['always']]; + yield 'without version' => [[], ['always']]; + yield 'version of another class' => [[UpcastExtension::VERSIONS => [ProfileCreated::class => 1]], ['always']]; + yield 'invalid versions' => [[UpcastExtension::VERSIONS => 1], ['always']]; + yield 'invalid version' => [[UpcastExtension::VERSIONS => [UpcastFixture::class => '1']], ['always']]; + } + + /** + * @param array $context + * @param list $steps + */ + #[DataProvider('provideVersions')] + public function testHydrateVersioned(array $context, array $steps): void + { + $secondStep = new #[UpcasterFor(UpcastFixture::class, from: 2)] + class implements Upcaster { + /** + * @param array $data + * @param array $context + * + * @return array + */ + public function upcast(ClassMetadata $metadata, array $data, array $context): array + { + $steps = $data['steps'] ?? []; + assert(is_array($steps)); + $steps[] = '2 to 3'; + $data['steps'] = $steps; + + return $data; + } + }; + + $middleware = new UpcastMiddleware([ + self::step('1 to 2', 1), + self::step('always'), + $secondStep, + ]); + + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + $expectedObject = new UpcastFixture('Jane'); + + /** @var Mapper&MockObject $next */ + $next = $this->createMock(Mapper::class); + $next->expects(self::once()) + ->method('hydrate') + ->with(['name' => 'Jane', 'steps' => $steps], $context) + ->willReturn($expectedObject); + + self::assertSame($expectedObject, $middleware->hydrate($metadata, ['name' => 'Jane'], $context, $next)); + } + + public function testSkipExtractWithVersionedUpcasters(): void + { + $middleware = new UpcastMiddleware([ + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 1), + ]); + + self::assertSame( + Skip::Extract, + $middleware->skip((new AttributeMetadataFactory())->metadata(UpcastFixture::class)), + ); + self::assertSame( + Skip::Both, + $middleware->skip((new AttributeMetadataFactory())->metadata(ProfileCreated::class)), + ); + } + + public function testUpcastersHaveToBeInVersionOrder(): void + { + $this->expectException(UpcastersNotInVersionOrder::class); + $this->expectExceptionMessage(sprintf( + 'The upcasters of %s have to be registered in the order of their versions, the upcaster from version 1 comes after the one from version 2.', + UpcastFixture::class, + )); + + new UpcastMiddleware([ + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 2), + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data), + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 1), + ]); + } + + public function testUpcastersOfTheSameVersionOrOtherClasses(): void + { + $middleware = new UpcastMiddleware([ + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 2), + CallbackUpcaster::forClass(ProfileCreated::class, static fn (array $data): array => $data, from: 1), + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 2), + CallbackUpcaster::forClass(UpcastFixture::class, static fn (array $data): array => $data, from: 3), + ]); + + self::assertSame( + Skip::Extract, + $middleware->skip((new AttributeMetadataFactory())->metadata(UpcastFixture::class)), + ); + } + + /** An upcaster which adds its name to the steps in the data. */ + private static function step(string $name, int|null $from = null): CallbackUpcaster + { + return CallbackUpcaster::forClass( + UpcastFixture::class, + static function (array $data) use ($name): array { + $steps = $data['steps'] ?? []; + assert(is_array($steps)); + $steps[] = $name; + $data['steps'] = $steps; + + return $data; + }, + $from, + ); + } }