From 0127562564d7b6b72ca8a20bba514ddab4f03c0c Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 11:58:39 +0200 Subject: [PATCH 1/3] add skippable middleware feature --- docs/extensions.md | 48 +++++++ phpstan-baseline.neon | 6 +- .../Cryptography/CryptographyMiddleware.php | 25 +++- .../Lifecycle/LifecycleMiddleware.php | 29 +++- src/Middleware/AllMiddlewaresSkipped.php | 21 +++ src/Middleware/Skip.php | 20 +++ src/Middleware/SkippableMiddleware.php | 22 +++ src/StackHydrator.php | 78 +++++++++- .../CryptographyMiddlewareTest.php | 22 +++ .../Lifecycle/LifecycleMiddlewareTest.php | 48 +++++++ tests/Unit/StackHydratorTest.php | 136 ++++++++++++++++++ 11 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 src/Middleware/AllMiddlewaresSkipped.php create mode 100644 src/Middleware/Skip.php create mode 100644 src/Middleware/SkippableMiddleware.php diff --git a/docs/extensions.md b/docs/extensions.md index e278b66b..a89f5c8b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -66,6 +66,54 @@ always runs last. ```php $builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); ``` +## Skipping middlewares + +A middleware usually only has something to do for a few classes. Instead of +walking through the whole stack every time, a middleware can implement +`SkippableMiddleware` and tell the hydrator that it is not needed for a class. +The decision is made once per class and then reused, so it must only depend on +the metadata. + +The `skip` method returns a `Skip` case: `Skip::None` to always run, +`Skip::Hydrate` or `Skip::Extract` to be left out in one direction only and +`Skip::Both` to be left out completely. + +```php +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; +use Patchlevel\Hydrator\Middleware\Stack; + +final class RemoveNullValuesMiddleware implements SkippableMiddleware +{ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + return array_filter($data, static fn (mixed $value) => $value !== null); + } + + public function skip(ClassMetadata $metadata): Skip + { + // the middleware only does something while extracting + return Skip::Hydrate; + } +} +``` +The built-in middlewares use this as well: the `CryptographyMiddleware` is left +out for classes without sensitive data and the `LifecycleMiddleware` only runs +in the directions the class has hooks for. + +:::warning +At least one middleware has to run. If every middleware skips a class, an +`AllMiddlewaresSkipped` exception is thrown. +::: + ## Metadata enricher A metadata enricher runs once per class when the metadata is created. It can diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e65ba1c7..2fd04d44 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -121,9 +121,9 @@ parameters: path: src/Normalizer/ObjectNormalizer.php - - message: '#^Parameter \#1 \$middlewares of class Patchlevel\\Hydrator\\Middleware\\Stack constructor expects non\-empty\-list\, list\ given\.$#' - identifier: argument.type - count: 4 + message: '#^Method Patchlevel\\Hydrator\\StackHydrator\:\:middlewaresFor\(\) should return non\-empty\-list\ but returns list\\.$#' + identifier: return.type + count: 1 path: src/StackHydrator.php - diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index af40f893..e07c681f 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -8,7 +8,8 @@ use Patchlevel\Hydrator\Extension\Cryptography\Cipher\DecryptionFailed; use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use Stringable; @@ -18,7 +19,7 @@ use function is_int; use function is_string; -final class CryptographyMiddleware implements Middleware +final class CryptographyMiddleware implements SkippableMiddleware { public function __construct( private readonly Cryptographer $cryptographer, @@ -117,6 +118,26 @@ public function extract(ClassMetadata $metadata, object $object, array $context, return $data; } + /** + * @param ClassMetadata $metadata + * + * @template T of object + */ + public function skip(ClassMetadata $metadata): Skip + { + if (($metadata->extras[SubjectIdFieldMapping::class] ?? null) instanceof SubjectIdFieldMapping) { + return Skip::None; + } + + foreach ($metadata->properties as $propertyMetadata) { + if (($propertyMetadata->extras[SensitiveDataInfo::class] ?? null) instanceof SensitiveDataInfo) { + return Skip::None; + } + } + + return Skip::Both; + } + /** * @param array|object $data * @param array $context diff --git a/src/Extension/Lifecycle/LifecycleMiddleware.php b/src/Extension/Lifecycle/LifecycleMiddleware.php index 05e51cac..a202de42 100644 --- a/src/Extension/Lifecycle/LifecycleMiddleware.php +++ b/src/Extension/Lifecycle/LifecycleMiddleware.php @@ -5,12 +5,13 @@ namespace Patchlevel\Hydrator\Extension\Lifecycle; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use function assert; -final class LifecycleMiddleware implements Middleware +final class LifecycleMiddleware implements SkippableMiddleware { /** * @param ClassMetadata $metadata @@ -67,4 +68,28 @@ public function extract(ClassMetadata $metadata, object $object, array $context, return $data; } + + /** + * @param ClassMetadata $metadata + * + * @template T of object + */ + public function skip(ClassMetadata $metadata): Skip + { + $lifecycle = $metadata->extras[Lifecycle::class] ?? null; + + if (!$lifecycle instanceof Lifecycle) { + return Skip::Both; + } + + $hydrate = $lifecycle->preHydrate !== null || $lifecycle->postHydrate !== null; + $extract = $lifecycle->preExtract !== null || $lifecycle->postExtract !== null; + + return match (true) { + !$hydrate && !$extract => Skip::Both, + !$hydrate => Skip::Hydrate, + !$extract => Skip::Extract, + default => Skip::None, + }; + } } diff --git a/src/Middleware/AllMiddlewaresSkipped.php b/src/Middleware/AllMiddlewaresSkipped.php new file mode 100644 index 00000000..87bafab7 --- /dev/null +++ b/src/Middleware/AllMiddlewaresSkipped.php @@ -0,0 +1,21 @@ + $metadata + * + * @template T of object + */ + public function skip(ClassMetadata $metadata): Skip; +} diff --git a/src/StackHydrator.php b/src/StackHydrator.php index f069dedc..568b0cc5 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -8,7 +8,10 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; +use Patchlevel\Hydrator\Middleware\AllMiddlewaresSkipped; use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; @@ -24,6 +27,14 @@ final class StackHydrator implements Hydrator /** @var array */ private array $classMetadata = []; + /** @var array> */ + private array $hydrateMiddlewares = []; + + /** @var array> */ + private array $extractMiddlewares = []; + + private readonly bool $hasSkippableMiddlewares; + /** @param list $middlewares */ public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), @@ -33,6 +44,18 @@ public function __construct( if ($middlewares === []) { throw new MissingMiddlewares(); } + + $hasSkippableMiddlewares = false; + + foreach ($middlewares as $middleware) { + if ($middleware instanceof SkippableMiddleware) { + $hasSkippableMiddlewares = true; + + break; + } + } + + $this->hasSkippableMiddlewares = $hasSkippableMiddlewares; } /** @@ -66,7 +89,7 @@ public function hydrate(string $class, mixed $data, array $context = []): object } if (PHP_VERSION_ID < 80400) { - $stack = new Stack($this->middlewares); + $stack = new Stack($this->middlewaresFor($metadata, Skip::Hydrate)); return $stack->next()->hydrate($metadata, $data, $context, $stack); } @@ -74,14 +97,14 @@ public function hydrate(string $class, mixed $data, array $context = []): object $lazy = $metadata->lazy ?? $this->defaultLazy; if (!$lazy) { - $stack = new Stack($this->middlewares); + $stack = new Stack($this->middlewaresFor($metadata, Skip::Hydrate)); return $stack->next()->hydrate($metadata, $data, $context, $stack); } return (new ReflectionClass($class))->newLazyProxy( function () use ($metadata, $data, $context): object { - $stack = new Stack($this->middlewares); + $stack = new Stack($this->middlewaresFor($metadata, Skip::Hydrate)); return $stack->next()->hydrate($metadata, $data, $context, $stack); }, @@ -101,11 +124,58 @@ public function extract(object $object, array $context = []): mixed return $metadata->normalizer->normalize($object, $context); } - $stack = new Stack($this->middlewares); + $stack = new Stack($this->middlewaresFor($metadata, Skip::Extract)); return $stack->next()->extract($metadata, $object, $context, $stack); } + /** + * @param ClassMetadata $metadata + * @param Skip::Hydrate|Skip::Extract $direction + * + * @return non-empty-list + * + * @template T of object + */ + private function middlewaresFor(ClassMetadata $metadata, Skip $direction): array + { + if (!$this->hasSkippableMiddlewares) { + return $this->middlewares; + } + + $cached = $direction === Skip::Hydrate + ? $this->hydrateMiddlewares[$metadata->className] ?? null + : $this->extractMiddlewares[$metadata->className] ?? null; + + if ($cached !== null) { + return $cached; + } + + $middlewares = []; + + foreach ($this->middlewares as $middleware) { + if ($middleware instanceof SkippableMiddleware) { + $skip = $middleware->skip($metadata); + + if ($skip === $direction || $skip === Skip::Both) { + continue; + } + } + + $middlewares[] = $middleware; + } + + if ($middlewares === []) { + throw new AllMiddlewaresSkipped($metadata->className); + } + + if ($direction === Skip::Hydrate) { + return $this->hydrateMiddlewares[$metadata->className] = $middlewares; + } + + return $this->extractMiddlewares[$metadata->className] = $middlewares; + } + /** * @param class-string $class * diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php index 92648311..d6661e6d 100644 --- a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -15,6 +15,7 @@ use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated; @@ -242,6 +243,27 @@ public function testDecrypt(): void self::assertEquals(Email::fromString('info@patchlevel.de'), $result->email); } + public function testSkipClassWithoutSensitiveData(): void + { + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + self::assertSame(Skip::Both, $middleware->skip($this->metadata(ProfileCreated::class))); + } + + public function testDoNotSkipClassWithSensitiveData(): void + { + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + self::assertSame( + Skip::None, + $middleware->skip($this->metadata(SensitiveDataProfileCreated::class)), + ); + } + /** @param class-string $class */ private function metadata(string $class): ClassMetadata { diff --git a/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php index dd1de1e0..6ab50b8f 100644 --- a/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php +++ b/tests/Unit/Extension/Lifecycle/LifecycleMiddlewareTest.php @@ -9,6 +9,7 @@ use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle\Fixture\LifecycleFixture; use PHPUnit\Framework\Attributes\CoversClass; @@ -122,6 +123,53 @@ public function extract(ClassMetadata $metadata, object $object, array $context, self::assertSame('foo [preExtract] [postExtract]', $data['name']); } + public function testSkipWithoutLifecycle(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + + self::assertSame(Skip::Both, $middleware->skip($metadata)); + } + + public function testSkipWithEmptyLifecycle(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle(); + + self::assertSame(Skip::Both, $middleware->skip($metadata)); + } + + public function testSkipExtractWithOnlyHydrateHooks(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle(preHydrate: 'preHydrate'); + + self::assertSame(Skip::Extract, $middleware->skip($metadata)); + } + + public function testSkipHydrateWithOnlyExtractHooks(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle(postExtract: 'postExtract'); + + self::assertSame(Skip::Hydrate, $middleware->skip($metadata)); + } + + public function testSkipNothingWithHooksForBothDirections(): void + { + $middleware = new LifecycleMiddleware(); + $metadata = $this->metadata(LifecycleFixture::class); + $metadata->extras[Lifecycle::class] = new Lifecycle( + preHydrate: 'preHydrate', + preExtract: 'preExtract', + ); + + self::assertSame(Skip::None, $middleware->skip($metadata)); + } + /** @param class-string $class */ private function metadata(string $class): ClassMetadata { diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 0d7c1579..8597049b 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -14,7 +14,10 @@ use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Middleware\AllMiddlewaresSkipped; use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\MissingMiddlewares; @@ -623,4 +626,137 @@ public function testMetadataWithHydratorAwareNormalizer(): void $reflection = new ReflectionProperty($normalizer, 'hydrator'); self::assertSame($this->hydrator, $reflection->getValue($normalizer)); } + + public function testSkippableMiddlewareIsSkipped(): void + { + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware + ->expects($this->once()) + ->method('skip') + ->willReturn(Skip::Both); + $middleware + ->expects($this->never()) + ->method('extract'); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals( + ['profileId' => '1', 'email' => 'info@patchlevel.de'], + $hydrator->extract($event), + ); + + // the decision is cached per class, so skip is not called again + $hydrator->extract($event); + } + + public function testSkippableMiddlewareIsExecuted(): void + { + $expect = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware + ->expects($this->once()) + ->method('skip') + ->willReturn(Skip::None); + $middleware + ->expects($this->once()) + ->method('extract') + ->willReturn($expect); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + self::assertEquals($expect, $hydrator->extract($event)); + } + + public function testSkippableMiddlewareIsOnlySkippedWhileHydrating(): void + { + $data = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware + ->method('skip') + ->willReturn(Skip::Hydrate); + $middleware + ->expects($this->never()) + ->method('hydrate'); + $middleware + ->expects($this->once()) + ->method('extract') + ->willReturn($data); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + $event = $hydrator->hydrate(ProfileCreated::class, $data); + + self::assertEquals($data, $hydrator->extract($event)); + } + + public function testSkippableMiddlewareIsOnlySkippedWhileExtracting(): void + { + $data = ['profileId' => '1', 'email' => 'info@patchlevel.de']; + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware + ->method('skip') + ->willReturn(Skip::Extract); + $middleware + ->expects($this->once()) + ->method('hydrate') + ->willReturn($event); + $middleware + ->expects($this->never()) + ->method('extract'); + + $hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware($middleware) + ->build(); + + self::assertSame($event, $hydrator->hydrate(ProfileCreated::class, $data)); + self::assertEquals($data, $hydrator->extract($event)); + } + + public function testAllMiddlewaresSkipped(): void + { + $middleware = $this->createMock(SkippableMiddleware::class); + $middleware + ->method('skip') + ->willReturn(Skip::Both); + + $hydrator = new StackHydrator( + new AttributeMetadataFactory(), + [$middleware], + ); + + $this->expectException(AllMiddlewaresSkipped::class); + $this->expectExceptionMessage( + 'All middlewares were skipped for the class "' . ProfileCreated::class . '", at least one middleware must run.', + ); + + $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']); + } } From f197e75c6d8504ecbaa0bf96071b77fa35dc24b9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 12:12:52 +0200 Subject: [PATCH 2/3] make upcast middleware skippable --- docs/extensions.md | 7 +- src/Extension/Upcast/CallbackUpcaster.php | 2 +- src/Extension/Upcast/UpcastMiddleware.php | 28 ++++++- .../Extension/Upcast/UpcastMiddlewareTest.php | 79 +++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index a89f5c8b..3c156b84 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -106,8 +106,11 @@ final class RemoveNullValuesMiddleware implements SkippableMiddleware } ``` The built-in middlewares use this as well: the `CryptographyMiddleware` is left -out for classes without sensitive data and the `LifecycleMiddleware` only runs -in the directions the class has hooks for. +out for classes without sensitive data, the `LifecycleMiddleware` only runs in +the directions the class has hooks for, and the `UpcastMiddleware` is left out +while extracting, since upcasting only ever happens while hydrating. If every +upcaster is a `CallbackUpcaster`, it is also left out while hydrating classes +none of them target. :::warning At least one middleware has to run. If every middleware skips a class, an diff --git a/src/Extension/Upcast/CallbackUpcaster.php b/src/Extension/Upcast/CallbackUpcaster.php index 1821efe5..17910e25 100644 --- a/src/Extension/Upcast/CallbackUpcaster.php +++ b/src/Extension/Upcast/CallbackUpcaster.php @@ -16,7 +16,7 @@ final class CallbackUpcaster implements Upcaster * @param class-string $className * @param callable(array, array): array $callback */ - public function __construct(private readonly string $className, callable $callback) + public function __construct(public readonly string $className, callable $callback) { $this->callback = Closure::fromCallable($callback); } diff --git a/src/Extension/Upcast/UpcastMiddleware.php b/src/Extension/Upcast/UpcastMiddleware.php index 58ada4d0..6d9d3463 100644 --- a/src/Extension/Upcast/UpcastMiddleware.php +++ b/src/Extension/Upcast/UpcastMiddleware.php @@ -5,10 +5,11 @@ namespace Patchlevel\Hydrator\Extension\Upcast; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; -final readonly class UpcastMiddleware implements Middleware +final readonly class UpcastMiddleware implements SkippableMiddleware { /** @param list $upcasters */ public function __construct( @@ -47,4 +48,27 @@ public function extract(ClassMetadata $metadata, object $object, array $context, { return $stack->next()->extract($metadata, $object, $context, $stack); } + + /** + * @param ClassMetadata $metadata + * + * @template T of object + */ + public function skip(ClassMetadata $metadata): Skip + { + // upcasting only ever happens while hydrating, extract() never does anything + if ($this->upcasters === []) { + return Skip::Both; + } + + foreach ($this->upcasters as $upcaster) { + // an upcaster we cannot introspect might still target this class + if (!$upcaster instanceof CallbackUpcaster || $upcaster->className === $metadata->className) { + return Skip::Extract; + } + } + + // every upcaster is a CallbackUpcaster and none of them targets this class + return Skip::Both; + } } diff --git a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php index 87e90a8c..ceae228a 100644 --- a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php +++ b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php @@ -5,11 +5,15 @@ namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; +use Patchlevel\Hydrator\Extension\Upcast\Upcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; +use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Skip; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -71,4 +75,79 @@ public function testExtract(): void self::assertSame(['name' => 'Jane Doe'], $middleware->extract($metadata, $object, [], $stack)); } + + public function testSkipWithoutUpcasters(): void + { + $middleware = new UpcastMiddleware([]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Both, $middleware->skip($metadata)); + } + + public function testSkipExtractWithUpcasters(): void + { + $middleware = new UpcastMiddleware([ + CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ), + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Extract, $middleware->skip($metadata)); + } + + public function testSkipBothWhenNoCallbackUpcasterTargetsTheClass(): void + { + $middleware = new UpcastMiddleware([ + CallbackUpcaster::forClass( + ProfileCreated::class, + static fn (array $data): array => $data, + ), + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Both, $middleware->skip($metadata)); + } + + public function testSkipExtractWhenAnyCallbackUpcasterTargetsTheClass(): void + { + $middleware = new UpcastMiddleware([ + CallbackUpcaster::forClass( + ProfileCreated::class, + static fn (array $data): array => $data, + ), + CallbackUpcaster::forClass( + UpcastFixture::class, + static fn (array $data): array => $data, + ), + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Extract, $middleware->skip($metadata)); + } + + public function testSkipExtractWithUnknownUpcaster(): void + { + $middleware = new UpcastMiddleware([ + new class implements Upcaster { + /** + * @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 + { + return $data; + } + }, + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Extract, $middleware->skip($metadata)); + } } From f19efd9eb33fbc4abd12bdeca6ef5ccdea136a8e Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 23 Sep 2026 12:31:45 +0200 Subject: [PATCH 3/3] add UpcasterFor attribute for optimization and DX --- docs/extensions.md | 5 +- docs/upcasting.md | 21 ++-- .../Upcast/Attribute/UpcasterFor.php | 17 +++ src/Extension/Upcast/UpcastMiddleware.php | 46 +++++++- .../Extension/Upcast/UpcastMiddlewareTest.php | 109 ++++++++++++++++++ 5 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 src/Extension/Upcast/Attribute/UpcasterFor.php diff --git a/docs/extensions.md b/docs/extensions.md index 3c156b84..9cd0f7f1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -109,8 +109,9 @@ The built-in middlewares use this as well: the `CryptographyMiddleware` is left out for classes without sensitive data, the `LifecycleMiddleware` only runs in the directions the class has hooks for, and the `UpcastMiddleware` is left out while extracting, since upcasting only ever happens while hydrating. If every -upcaster is a `CallbackUpcaster`, it is also left out while hydrating classes -none of them target. +upcaster is a `CallbackUpcaster` or carries an +[`#[UpcasterFor]`](upcasting.md#writing-an-upcaster) attribute, it is also left +out while hydrating classes none of them target. :::warning At least one middleware has to run. If every middleware skips a class, an diff --git a/docs/upcasting.md b/docs/upcasting.md index d56efb6b..8e384cdf 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -43,21 +43,19 @@ up to date. An upcaster implements the `Upcaster` interface. It receives the [class metadata](hydrator.md), the data array and the context, and returns the -reshaped data. Because every registered upcaster runs for every class, check the -metadata and leave data you do not care about untouched. +reshaped data. Mark it with the `#[UpcasterFor]` attribute to restrict it to a +single class, so you do not have to check the metadata yourself: ```php +use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor; use Patchlevel\Hydrator\Extension\Upcast\Upcaster; use Patchlevel\Hydrator\Metadata\ClassMetadata; +#[UpcasterFor(ProfileCreated::class)] final class RenameEmailUpcaster implements Upcaster { public function upcast(ClassMetadata $metadata, array $data, array $context): array { - if ($metadata->className !== ProfileCreated::class) { - return $data; - } - $data['email'] = $data['mail']; unset($data['mail']); @@ -65,9 +63,18 @@ final class RenameEmailUpcaster implements Upcaster } } ``` +:::note +`#[UpcasterFor]` is more than a convenience: the `UpcastMiddleware` reads it to +know it can be [skipped](extensions.md#skipping-middlewares) entirely for +classes it does not target. Without it, the upcaster is called for every +class, so if you do check the metadata yourself, the middleware has no way of +knowing and always calls it. +::: + For the common case of a single class and a closure, use the `CallbackUpcaster`. It compares the class name for you and only invokes the -callback for a match. The callback receives the data and the context: +callback for a match, and the `UpcastMiddleware` recognizes it the same way it +recognizes `#[UpcasterFor]`. The callback receives the data and the context: ```php use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; diff --git a/src/Extension/Upcast/Attribute/UpcasterFor.php b/src/Extension/Upcast/Attribute/UpcasterFor.php new file mode 100644 index 00000000..02efaedc --- /dev/null +++ b/src/Extension/Upcast/Attribute/UpcasterFor.php @@ -0,0 +1,17 @@ + */ + private array $targets; + /** @param list $upcasters */ public function __construct( private array $upcasters, ) { + $this->targets = array_map(self::resolveTarget(...), $upcasters); } /** @@ -28,7 +36,13 @@ public function __construct( */ public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object { - foreach ($this->upcasters as $upcaster) { + foreach ($this->upcasters as $index => $upcaster) { + $target = $this->targets[$index]; + + if ($target !== null && $target !== $metadata->className) { + continue; + } + $data = $upcaster->upcast($metadata, $data, $context); } @@ -61,14 +75,36 @@ public function skip(ClassMetadata $metadata): Skip return Skip::Both; } - foreach ($this->upcasters as $upcaster) { - // an upcaster we cannot introspect might still target this class - if (!$upcaster instanceof CallbackUpcaster || $upcaster->className === $metadata->className) { + foreach ($this->targets as $target) { + // a target we could not resolve might still apply to this class + if ($target === null || $target === $metadata->className) { return Skip::Extract; } } - // every upcaster is a CallbackUpcaster and none of them targets this class + // none of the upcasters targets this class return Skip::Both; } + + /** + * 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. + * + * @return class-string|null + */ + private static function resolveTarget(Upcaster $upcaster): string|null + { + if ($upcaster instanceof CallbackUpcaster) { + return $upcaster->className; + } + + $attributes = (new ReflectionClass($upcaster))->getAttributes(UpcasterFor::class); + + if ($attributes === []) { + return null; + } + + return $attributes[0]->newInstance()->className; + } } diff --git a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php index ceae228a..55691bdd 100644 --- a/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php +++ b/tests/Unit/Extension/Upcast/UpcastMiddlewareTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast; +use Patchlevel\Hydrator\Extension\Upcast\Attribute\UpcasterFor; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\Upcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware; @@ -127,6 +128,114 @@ public function testSkipExtractWhenAnyCallbackUpcasterTargetsTheClass(): void self::assertSame(Skip::Extract, $middleware->skip($metadata)); } + public function testHydrateOnlyCallsUpcastersTargetingTheClass(): void + { + $matching = new #[UpcasterFor(UpcastFixture::class)] + class implements Upcaster { + /** + * @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 + { + $data['matching'] = true; + + return $data; + } + }; + + $notMatching = new #[UpcasterFor(ProfileCreated::class)] + class implements Upcaster { + /** + * @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 + { + $data['notMatching'] = true; + + return $data; + } + }; + + $middleware = new UpcastMiddleware([$notMatching, $matching]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + $expectedObject = new UpcastFixture('Jane Doe'); + + $nextMiddleware = $this->createMock(Middleware::class); + $nextMiddleware->expects(self::once()) + ->method('hydrate') + ->with($metadata, ['name' => 'Jane Doe', 'matching' => true], [], self::isInstanceOf(Stack::class)) + ->willReturn($expectedObject); + + $stack = new Stack([$nextMiddleware]); + + $object = $middleware->hydrate($metadata, ['name' => 'Jane Doe'], [], $stack); + + self::assertSame($expectedObject, $object); + } + + public function testSkipBothWhenNoAttributeUpcasterTargetsTheClass(): void + { + $middleware = new UpcastMiddleware([ + new #[UpcasterFor(ProfileCreated::class)] + class implements Upcaster { + /** + * @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 + { + return $data; + } + }, + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Both, $middleware->skip($metadata)); + } + + public function testSkipExtractWhenAttributeUpcasterTargetsTheClass(): void + { + $middleware = new UpcastMiddleware([ + new #[UpcasterFor(UpcastFixture::class)] + class implements Upcaster { + /** + * @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 + { + return $data; + } + }, + ]); + $metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class); + + self::assertSame(Skip::Extract, $middleware->skip($metadata)); + } + public function testSkipExtractWithUnknownUpcaster(): void { $middleware = new UpcastMiddleware([