From 0e72e49313f3d0e539f56f8e99f30a6060862f3e Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 2 May 2026 11:14:52 +0200 Subject: [PATCH] add upcaster extension --- src/Extension/Upcast/CallbackUpcaster.php | 51 +++++++ src/Extension/Upcast/UpcastExtension.php | 44 ++++++ src/Extension/Upcast/UpcastMiddleware.php | 51 +++++++ src/Extension/Upcast/Upcaster.php | 22 +++ .../Extension/Upcast/CallbackUpcasterTest.php | 53 ++++++++ .../Upcast/Fixture/UpcastFixture.php | 13 ++ .../Extension/Upcast/UpcastExtensionTest.php | 110 +++++++++++++++ .../Extension/Upcast/UpcastMiddlewareTest.php | 125 ++++++++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 src/Extension/Upcast/CallbackUpcaster.php create mode 100644 src/Extension/Upcast/UpcastExtension.php create mode 100644 src/Extension/Upcast/UpcastMiddleware.php create mode 100644 src/Extension/Upcast/Upcaster.php create mode 100644 tests/Unit/Extension/Upcast/CallbackUpcasterTest.php create mode 100644 tests/Unit/Extension/Upcast/Fixture/UpcastFixture.php create mode 100644 tests/Unit/Extension/Upcast/UpcastExtensionTest.php create mode 100644 tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php diff --git a/src/Extension/Upcast/CallbackUpcaster.php b/src/Extension/Upcast/CallbackUpcaster.php new file mode 100644 index 0000000..fee20b2 --- /dev/null +++ b/src/Extension/Upcast/CallbackUpcaster.php @@ -0,0 +1,51 @@ +, array): array */ + private readonly Closure $callback; + + /** + * @param class-string $className + * @param callable(array, array): array $callback + */ + public function __construct(private readonly string $className, callable $callback) + { + $this->callback = Closure::fromCallable($callback); + } + + /** + * @param class-string $className + * @param callable(array, array): array $callback + */ + public static function forClass(string $className, callable $callback): self + { + return new self($className, $callback); + } + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return array + * + * @template T of object + */ + public function upcast(ClassMetadata $metadata, array $data, array $context): array + { + if ($metadata->className !== $this->className) { + return $data; + } + + return ($this->callback)($data, $context); + } +} diff --git a/src/Extension/Upcast/UpcastExtension.php b/src/Extension/Upcast/UpcastExtension.php new file mode 100644 index 0000000..f38c191 --- /dev/null +++ b/src/Extension/Upcast/UpcastExtension.php @@ -0,0 +1,44 @@ + $beforeCryptography + * @param list $afterCryptography + */ + public function __construct( + private array $beforeCryptography = [], + private array $afterCryptography = [], + ) { + } + + public function configure(StackHydratorBuilder $builder): void + { + if ($this->beforeCryptography !== []) { + $builder->addMiddleware( + new UpcastMiddleware($this->beforeCryptography), + self::BEFORE_CRYPTOGRAPHY_PRIORITY, + ); + } + + if ($this->afterCryptography === []) { + return; + } + + $builder->addMiddleware( + new UpcastMiddleware($this->afterCryptography), + self::AFTER_CRYPTOGRAPHY_PRIORITY, + ); + } +} diff --git a/src/Extension/Upcast/UpcastMiddleware.php b/src/Extension/Upcast/UpcastMiddleware.php new file mode 100644 index 0000000..3d0692f --- /dev/null +++ b/src/Extension/Upcast/UpcastMiddleware.php @@ -0,0 +1,51 @@ + $upcasters */ + public function __construct( + private array $upcasters, + ) { + } + + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + foreach ($this->upcasters as $upcaster) { + $data = $upcaster->upcast($metadata, $data, $context); + } + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + return $stack->next()->extract($metadata, $object, $context, $stack); + } +} diff --git a/src/Extension/Upcast/Upcaster.php b/src/Extension/Upcast/Upcaster.php new file mode 100644 index 0000000..bed39a7 --- /dev/null +++ b/src/Extension/Upcast/Upcaster.php @@ -0,0 +1,22 @@ + $metadata + * @param array $data + * @param array $context + * + * @return array + * + * @template T of object + */ + public function upcast(ClassMetadata $metadata, array $data, array $context): array; +} diff --git a/tests/Unit/Extension/Upcast/CallbackUpcasterTest.php b/tests/Unit/Extension/Upcast/CallbackUpcasterTest.php new file mode 100644 index 0000000..8e5114e --- /dev/null +++ b/tests/Unit/Extension/Upcast/CallbackUpcasterTest.php @@ -0,0 +1,53 @@ +metadata(UpcastFixture::class); + $upcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static function (array $data, array $context): array { + $prefix = $context['prefix'] ?? ''; + $name = $data['name'] ?? ''; + assert(is_string($prefix)); + assert(is_string($name)); + + $data['name'] = $prefix . $name; + + return $data; + }, + ); + + self::assertSame( + ['name' => 'Upcast: foo'], + $upcaster->upcast($metadata, ['name' => 'foo'], ['prefix' => 'Upcast: ']), + ); + } + + public function testSkipDifferentClass(): void + { + $metadata = (new AttributeMetadataFactory())->metadata(LifecycleFixture::class); + $upcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => ['name' => 'changed'], + ); + + self::assertSame(['name' => 'foo'], $upcaster->upcast($metadata, ['name' => 'foo'], [])); + } +} diff --git a/tests/Unit/Extension/Upcast/Fixture/UpcastFixture.php b/tests/Unit/Extension/Upcast/Fixture/UpcastFixture.php new file mode 100644 index 0000000..bdf028d --- /dev/null +++ b/tests/Unit/Extension/Upcast/Fixture/UpcastFixture.php @@ -0,0 +1,13 @@ +useExtension(new CoreExtension()) + ->useExtension(new UpcastExtension( + afterCryptography: [ + CallbackUpcaster::forClass( + UpcastFixture::class, + static function (array $data): array { + $firstName = $data['firstName'] ?? ''; + $lastName = $data['lastName'] ?? ''; + assert(is_string($firstName)); + assert(is_string($lastName)); + + $data['name'] = $firstName . ' ' . $lastName; + + return $data; + }, + ), + ], + )) + ->build(); + + $object = $hydrator->hydrate(UpcastFixture::class, ['firstName' => 'Jane', 'lastName' => 'Doe']); + + self::assertSame('Jane Doe', $object->name); + } + + public function testConfigureAroundCryptography(): void + { + $beforeCryptographyUpcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ); + $afterCryptographyUpcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ); + + $builder = new StackHydratorBuilder(); + $builder->useExtension(new UpcastExtension( + beforeCryptography: [$beforeCryptographyUpcaster], + afterCryptography: [$afterCryptographyUpcaster], + )); + $builder->useExtension(new CryptographyExtension($this->createMock(Cryptographer::class))); + + $middlewares = $builder->middlewares(); + + self::assertCount(3, $middlewares); + self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[1]); + self::assertInstanceOf(UpcastMiddleware::class, $middlewares[2]); + } + + public function testConfigureAroundLegacyCryptography(): void + { + $beforeCryptographyUpcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ); + $afterCryptographyUpcaster = CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ); + + $builder = new StackHydratorBuilder(); + $builder->useExtension(new UpcastExtension( + beforeCryptography: [$beforeCryptographyUpcaster], + afterCryptography: [$afterCryptographyUpcaster], + )); + $builder->useExtension(new CryptographyExtension( + $this->createMock(Cryptographer::class), + $this->createMock(PayloadCryptographer::class), + )); + + $middlewares = $builder->middlewares(); + + self::assertCount(4, $middlewares); + self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]); + self::assertInstanceOf(LegacyCryptographyDecryptMiddleware::class, $middlewares[1]); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[2]); + self::assertInstanceOf(UpcastMiddleware::class, $middlewares[3]); + } +} diff --git a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php new file mode 100644 index 0000000..fb9f022 --- /dev/null +++ b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php @@ -0,0 +1,125 @@ +metadata(UpcastFixture::class); + $stack = new Stack([ + new class implements Middleware { + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $name = $data['name'] ?? ''; + assert(is_string($name)); + + $object = new UpcastFixture($name); + assert($object instanceof $metadata->className); + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + return []; + } + }, + ]); + + $object = $middleware->hydrate($metadata, ['firstName' => 'Jane', 'lastName' => 'Doe'], [], $stack); + + self::assertInstanceOf(UpcastFixture::class, $object); + self::assertSame('Jane Doe', $object->name); + } + + public function testExtract(): void + { + $middleware = new UpcastMiddleware([]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + $object = new UpcastFixture('Jane Doe'); + $stack = new Stack([ + new class implements Middleware { + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + $object = new stdClass(); + assert($object instanceof $metadata->className); + + return $object; + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + if ($object instanceof UpcastFixture) { + return ['name' => $object->name]; + } + + return []; + } + }, + ]); + + self::assertSame(['name' => 'Jane Doe'], $middleware->extract($metadata, $object, [], $stack)); + } +}