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, + ); + } }