diff --git a/docs/UPGRADE-3.0.md b/docs/UPGRADE-3.0.md new file mode 100644 index 00000000..18e1050e --- /dev/null +++ b/docs/UPGRADE-3.0.md @@ -0,0 +1,162 @@ +--- +searchable: false +--- +# Upgrade 3.0 + +## Normalizer + +### HydratorAwareNormalizer + +`HydratorAwareNormalizer` has been removed. The hydrator is no longer injected +into the normalizers of the metadata, it is passed with every call in the +context under the key `Hydrator::HYDRATOR`. The `StackHydrator` adds itself to +the context, unless another hydrator which wraps it is already there. Remove +`setHydrator()` from your normalizers and read the hydrator from the context. + +before: + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; +use Patchlevel\Hydrator\Normalizer\MissingHydrator; +use Patchlevel\Hydrator\Normalizer\Normalizer; + +final class ProfileNormalizer implements Normalizer, HydratorAwareNormalizer +{ + private Hydrator|null $hydrator = null; + + public function setHydrator(Hydrator $hydrator): void + { + $this->hydrator = $hydrator; + } + + public function normalize(mixed $value, array $context): mixed + { + if (!$this->hydrator) { + throw new MissingHydrator(); + } + + return $this->hydrator->extract($value, $context); + } + + public function denormalize(mixed $value, array $context): mixed + { + if (!$this->hydrator) { + throw new MissingHydrator(); + } + + return $this->hydrator->hydrate(Profile::class, $value, $context); + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Normalizer\MissingHydrator; +use Patchlevel\Hydrator\Normalizer\Normalizer; + +final class ProfileNormalizer implements Normalizer +{ + public function normalize(mixed $value, array $context): mixed + { + return $this->hydrator($context)->extract($value, $context); + } + + public function denormalize(mixed $value, array $context): mixed + { + return $this->hydrator($context)->hydrate(Profile::class, $value, $context); + } + + private function hydrator(array $context): Hydrator + { + $hydrator = $context[Hydrator::HYDRATOR] ?? null; + + if (!$hydrator instanceof Hydrator) { + throw new MissingHydrator(); + } + + return $hydrator; + } +} +``` +:::warning +Pass the context on when you call the hydrator from a normalizer, otherwise +nested objects lose the hydrator and all other context values. +::: + +### Built-in normalizers + +`ObjectNormalizer`, `ObjectMapNormalizer`, `ArrayNormalizer` and +`ArrayShapeNormalizer` no longer have a `setHydrator()` method. If you use them +outside of a hydrator, for example in a test, pass the hydrator in the context +instead. + +before: + +```php +use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; + +$normalizer = new ObjectNormalizer(Profile::class); +$normalizer->setHydrator($hydrator); + +$data = $normalizer->normalize($profile, []); +``` +after: + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; + +$normalizer = new ObjectNormalizer(Profile::class); + +$data = $normalizer->normalize($profile, [Hydrator::HYDRATOR => $hydrator]); +``` +`ObjectNormalizer` and `ObjectMapNormalizer` throw a `MissingHydrator` +exception only if they actually need the hydrator. A `null` value is now +normalized and denormalized without one. + +## Hydrator + +### Context + +The context which middlewares, normalizers, upcasters and lifecycle hooks +receive now contains the hydrator under the key `Hydrator::HYDRATOR`. Don't use +the key `hydrator` for your own context values. If you serialize, log or compare +the context, leave this key out. + +### Wrapping a hydrator + +If you wrap the hydrator in your own `Hydrator` implementation, add it to the +context before you call the inner hydrator. Nested objects then also go +through your wrapper. + +```php +use Patchlevel\Hydrator\Hydrator; + +final class LoggingHydrator implements Hydrator +{ + public function __construct( + private readonly Hydrator $hydrator, + ) { + } + + public function hydrate(string $class, mixed $data, array $context = []): object + { + $context[Hydrator::HYDRATOR] ??= $this; + + return $this->hydrator->hydrate($class, $data, $context); + } + + public function extract(object $object, array $context = []): mixed + { + $context[Hydrator::HYDRATOR] ??= $this; + + return $this->hydrator->extract($object, $context); + } +} +``` +## Caching + +The serialized form of the normalizers changed. Clear the +[metadata cache](caching.md) when you deploy the new version. diff --git a/docs/normalizer.md b/docs/normalizer.md index 5e0d27fe..3cd94186 100644 --- a/docs/normalizer.md +++ b/docs/normalizer.md @@ -334,6 +334,35 @@ final class Profile public Name $name; } ``` +### Nested objects + +If your normalizer has to hydrate or extract another object, take the hydrator +from the context. The hydrator puts itself there under the key +`Hydrator::HYDRATOR` before it calls any normalizer. Pass the context on, so the +nested object gets the same hydrator and context values. + +```php +use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\Normalizer\MissingHydrator; +use Patchlevel\Hydrator\Normalizer\Normalizer; + +final class AddressNormalizer implements Normalizer +{ + public function normalize(mixed $value, array $context): mixed + { + $hydrator = $context[Hydrator::HYDRATOR] ?? throw new MissingHydrator(); + + return $hydrator->extract($value, $context); + } + + public function denormalize(mixed $value, array $context): mixed + { + $hydrator = $context[Hydrator::HYDRATOR] ?? throw new MissingHydrator(); + + return $hydrator->hydrate(Address::class, $value, $context); + } +} +``` ## Define a normalizer on class level Instead of specifying the normalizer on each property, you can also set the diff --git a/docs/project.json b/docs/project.json index 5fae563a..1181fb49 100644 --- a/docs/project.json +++ b/docs/project.json @@ -24,6 +24,7 @@ { "title": "Miscellaneous", "subEntries": [ + { "title": "Upgrade from 2.x", "file": "UPGRADE-3.0.md" }, { "title": "Upgrade from 1.x", "file": "UPGRADE-2.0md" } ] } diff --git a/src/Hydrator.php b/src/Hydrator.php index c9e6906c..a56cb57a 100644 --- a/src/Hydrator.php +++ b/src/Hydrator.php @@ -8,6 +8,12 @@ interface Hydrator { public const OBJECT_TO_POPULATE = 'object_to_populate'; + /** + * The hydrator which started the call, normalizers use it to hydrate and extract nested objects. It is set by the + * outermost hydrator, so a hydrator which wraps another one also sees the nested objects. + */ + public const HYDRATOR = 'hydrator'; + /** * @param class-string $class * @param array $context diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index e0c64b74..111a9ae7 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -5,7 +5,6 @@ namespace Patchlevel\Hydrator\Normalizer; use Attribute; -use Patchlevel\Hydrator\Hydrator; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\NullableType; @@ -13,7 +12,7 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class ArrayNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final readonly class ArrayNormalizer implements Normalizer, TypeAwareNormalizer { public function __construct( private Normalizer $normalizer, @@ -68,15 +67,6 @@ public function denormalize(mixed $value, array $context): array|null return $result; } - public function setHydrator(Hydrator $hydrator): void - { - if (!$this->normalizer instanceof HydratorAwareNormalizer) { - return; - } - - $this->normalizer->setHydrator($hydrator); - } - public function handleType(Type|null $type): void { if ($type === null) { diff --git a/src/Normalizer/ArrayShapeNormalizer.php b/src/Normalizer/ArrayShapeNormalizer.php index 23d99619..3294f3b3 100644 --- a/src/Normalizer/ArrayShapeNormalizer.php +++ b/src/Normalizer/ArrayShapeNormalizer.php @@ -5,7 +5,6 @@ namespace Patchlevel\Hydrator\Normalizer; use Attribute; -use Patchlevel\Hydrator\Hydrator; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\NullableType; @@ -13,7 +12,7 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY)] -final readonly class ArrayShapeNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final readonly class ArrayShapeNormalizer implements Normalizer, TypeAwareNormalizer { /** @param array $normalizerMap */ public function __construct( @@ -77,17 +76,6 @@ public function denormalize(mixed $value, array $context): array|null return $result; } - public function setHydrator(Hydrator $hydrator): void - { - foreach ($this->normalizerMap as $normalizer) { - if (!$normalizer instanceof HydratorAwareNormalizer) { - continue; - } - - $normalizer->setHydrator($hydrator); - } - } - public function handleType(Type|null $type): void { if ($type === null) { diff --git a/src/Normalizer/HydratorAwareNormalizer.php b/src/Normalizer/HydratorAwareNormalizer.php deleted file mode 100644 index c964ea21..00000000 --- a/src/Normalizer/HydratorAwareNormalizer.php +++ /dev/null @@ -1,12 +0,0 @@ - */ private array $typeToClassMap; @@ -32,11 +30,6 @@ public function __construct( $this->typeToClassMap = array_flip($classToTypeMap); } - public function setHydrator(Hydrator $hydrator): void - { - $this->hydrator = $hydrator; - } - /** * @param array $context * @@ -44,10 +37,6 @@ public function setHydrator(Hydrator $hydrator): void */ public function normalize(mixed $value, array $context): mixed { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } @@ -66,7 +55,7 @@ public function normalize(mixed $value, array $context): mixed ); } - $data = $this->hydrator->extract($value, $context); + $data = self::hydrator($context)->extract($value, $context); if (!is_array($data)) { throw InvalidArgument::withWrongType('array', $data); @@ -84,10 +73,6 @@ public function normalize(mixed $value, array $context): mixed */ public function denormalize(mixed $value, array $context): mixed { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } @@ -113,24 +98,18 @@ public function denormalize(mixed $value, array $context): mixed $className = $this->typeToClassMap[$type]; unset($value[$this->typeFieldName]); - return $this->hydrator->hydrate($className, $value, $context); + return self::hydrator($context)->hydrate($className, $value, $context); } - /** - * @return array{ - * typeToClassMap: array, - * classToTypeMap: array, - * typeFieldName: string, - * hydrator: null - * } - */ - public function __serialize(): array + /** @param array $context */ + private static function hydrator(array $context): Hydrator { - return [ - 'typeToClassMap' => $this->typeToClassMap, - 'classToTypeMap' => $this->classToTypeMap, - 'typeFieldName' => $this->typeFieldName, - 'hydrator' => null, - ]; + $hydrator = $context[Hydrator::HYDRATOR] ?? null; + + if (!$hydrator instanceof Hydrator) { + throw new MissingHydrator(); + } + + return $hydrator; } } diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index f254b359..331355b3 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -15,10 +15,8 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -final class ObjectNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final class ObjectNormalizer implements Normalizer, TypeAwareNormalizer { - private Hydrator|null $hydrator = null; - /** @param class-string|null $className */ public function __construct( private string|null $className = null, @@ -28,10 +26,6 @@ public function __construct( /** @param array $context */ public function normalize(mixed $value, array $context): mixed { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } @@ -42,16 +36,12 @@ public function normalize(mixed $value, array $context): mixed throw InvalidArgument::withWrongType($className . '|null', $value); } - return $this->hydrator->extract($value, $context); + return self::hydrator($context)->extract($value, $context); } /** @param array $context */ public function denormalize(mixed $value, array $context): object|null { - if (!$this->hydrator) { - throw new MissingHydrator(); - } - if ($value === null) { return null; } @@ -62,12 +52,7 @@ public function denormalize(mixed $value, array $context): object|null $className = $this->className(); - return $this->hydrator->hydrate($className, $value, $context); - } - - public function setHydrator(Hydrator $hydrator): void - { - $this->hydrator = $hydrator; + return self::hydrator($context)->hydrate($className, $value, $context); } public function handleType(Type|null $type): void @@ -105,12 +90,15 @@ public function className(): string return $this->className; } - /** @return array{className: class-string|null, hydrator: null} */ - public function __serialize(): array + /** @param array $context */ + private static function hydrator(array $context): Hydrator { - return [ - 'className' => $this->className, - 'hydrator' => null, - ]; + $hydrator = $context[Hydrator::HYDRATOR] ?? null; + + if (!$hydrator instanceof Hydrator) { + throw new MissingHydrator(); + } + + return $hydrator; } } diff --git a/src/StackHydrator.php b/src/StackHydrator.php index 568b0cc5..c48b6b90 100644 --- a/src/StackHydrator.php +++ b/src/StackHydrator.php @@ -14,7 +14,6 @@ use Patchlevel\Hydrator\Middleware\SkippableMiddleware; use Patchlevel\Hydrator\Middleware\Stack; use Patchlevel\Hydrator\Middleware\TransformMiddleware; -use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use ReflectionClass; use function array_key_exists; @@ -68,6 +67,8 @@ public function __construct( */ public function hydrate(string $class, mixed $data, array $context = []): object { + $context[self::HYDRATOR] ??= $this; + try { $metadata = $this->metadata($class); } catch (ClassNotFound $e) { @@ -118,6 +119,8 @@ function () use ($metadata, $data, $context): object { */ public function extract(object $object, array $context = []): mixed { + $context[self::HYDRATOR] ??= $this; + $metadata = $this->metadata($object::class); if ($metadata->normalizer) { @@ -189,16 +192,6 @@ public function metadata(string $class): ClassMetadata return $this->classMetadata[$class]; } - $this->classMetadata[$class] = $metadata = $this->metadataFactory->metadata($class); - - foreach ($metadata->properties as $property) { - if (!($property->normalizer instanceof HydratorAwareNormalizer)) { - continue; - } - - $property->normalizer->setHydrator($this); - } - - return $metadata; + return $this->classMetadata[$class] = $this->metadataFactory->metadata($class); } } diff --git a/tests/Unit/Normalizer/ArrayNormalizerTest.php b/tests/Unit/Normalizer/ArrayNormalizerTest.php index 037edb0b..9ece94ad 100644 --- a/tests/Unit/Normalizer/ArrayNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayNormalizerTest.php @@ -5,9 +5,7 @@ namespace Patchlevel\Hydrator\Tests\Unit\Normalizer; use InvalidArgumentException; -use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; -use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; use PHPUnit\Framework\Attributes\CoversClass; @@ -235,16 +233,6 @@ public function denormalize(mixed $value, array $context): int self::assertSame([101, 102, 103], $source); } - public function testPassHydrator(): void - { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = $this->createMockForIntersectionOfInterfaces([Normalizer::class, HydratorAwareNormalizer::class]); - $normalizer->expects($this->once())->method('setHydrator')->with($hydrator); - - $normalizer = new ArrayNormalizer($normalizer); - $normalizer->setHydrator($hydrator); - } - public function testInnerNormalizer(): void { $innerNormalizer = $this->createMock(Normalizer::class); diff --git a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php index 78be01dc..58456496 100644 --- a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php @@ -4,9 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Unit\Normalizer; -use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; -use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; use PHPUnit\Framework\Attributes\CoversClass; @@ -155,16 +153,6 @@ public function denormalize(mixed $value, array $context = []): mixed $this->assertSame([$context, $context], $innerNormalizer->contexts); } - public function testPassHydrator(): void - { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = $this->createMockForIntersectionOfInterfaces([Normalizer::class, HydratorAwareNormalizer::class]); - $normalizer->expects($this->once())->method('setHydrator')->with($hydrator); - - $normalizer = new ArrayShapeNormalizer(['foo' => $normalizer]); - $normalizer->setHydrator($hydrator); - } - public function testInnerNormalizers(): void { $innerNormalizer = $this->createMock(Normalizer::class); diff --git a/tests/Unit/Normalizer/ObjectMapNormalizerTest.php b/tests/Unit/Normalizer/ObjectMapNormalizerTest.php index 1ae19159..6c897555 100644 --- a/tests/Unit/Normalizer/ObjectMapNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectMapNormalizerTest.php @@ -25,7 +25,7 @@ public function testNormalizeMissingHydrator(): void $this->expectException(MissingHydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $this->assertEquals(null, $normalizer->normalize(null, [])); + $normalizer->normalize(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), []); } public function testDenormalizeMissingHydrator(): void @@ -33,7 +33,7 @@ public function testDenormalizeMissingHydrator(): void $this->expectException(MissingHydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $this->assertEquals(null, $normalizer->denormalize(null, [])); + $normalizer->denormalize(['_type' => 'created', 'profileId' => '1'], ['hydrator' => 'not a hydrator']); } public function testNormalizeWithNull(): void @@ -41,9 +41,8 @@ public function testNormalizeWithNull(): void $hydrator = $this->createStub(Hydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); - $this->assertEquals(null, $normalizer->normalize(null, [])); + $this->assertEquals(null, $normalizer->normalize(null, [Hydrator::HYDRATOR => $hydrator])); } public function testDenormalizeWithNull(): void @@ -51,9 +50,8 @@ public function testDenormalizeWithNull(): void $hydrator = $this->createStub(Hydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); - $this->assertEquals(null, $normalizer->denormalize(null, [])); + $this->assertEquals(null, $normalizer->denormalize(null, [Hydrator::HYDRATOR => $hydrator])); } public function testNormalizeWithInvalidArgument(): void @@ -64,8 +62,7 @@ public function testNormalizeWithInvalidArgument(): void $hydrator = $this->createStub(Hydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); - $normalizer->normalize('foo', []); + $normalizer->normalize('foo', [Hydrator::HYDRATOR => $hydrator]); } public function testDenormalizeWithInvalidArgument(): void @@ -76,8 +73,7 @@ public function testDenormalizeWithInvalidArgument(): void $hydrator = $this->createStub(Hydrator::class); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); - $normalizer->denormalize('foo', []); + $normalizer->denormalize('foo', [Hydrator::HYDRATOR => $hydrator]); } public function testNormalizeWithValue(): void @@ -96,10 +92,9 @@ public function testNormalizeWithValue(): void ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); self::assertEquals( - $normalizer->normalize($event, []), + $normalizer->normalize($event, [Hydrator::HYDRATOR => $hydrator]), ['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], ); } @@ -120,18 +115,17 @@ public function testDenormalizeWithValue(): void ->willReturn($expected); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); $this->assertEquals( $expected, - $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], []), + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], [Hydrator::HYDRATOR => $hydrator]), ); } public function testNormalizePassesContextToHydrator(): void { - $context = ['key' => 'value']; $hydrator = $this->createMock(Hydrator::class); + $context = ['key' => 'value', Hydrator::HYDRATOR => $hydrator]; $event = new ProfileCreated( ProfileId::fromString('1'), @@ -145,7 +139,6 @@ public function testNormalizePassesContextToHydrator(): void ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); self::assertEquals( ['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], @@ -155,8 +148,8 @@ public function testNormalizePassesContextToHydrator(): void public function testDenormalizePassesContextToHydrator(): void { - $context = ['key' => 'value']; $hydrator = $this->createMock(Hydrator::class); + $context = ['key' => 'value', Hydrator::HYDRATOR => $hydrator]; $expected = new ProfileCreated( ProfileId::fromString('1'), @@ -170,7 +163,6 @@ public function testDenormalizePassesContextToHydrator(): void ->willReturn($expected); $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); $this->assertEquals( $expected, @@ -180,10 +172,7 @@ public function testDenormalizePassesContextToHydrator(): void public function testSerialize(): void { - $hydrator = $this->createStub(Hydrator::class); - $normalizer = new ObjectMapNormalizer([ProfileCreated::class => 'created']); - $normalizer->setHydrator($hydrator); $serialized = serialize($normalizer); $normalizer2 = unserialize($serialized); diff --git a/tests/Unit/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Normalizer/ObjectNormalizerTest.php index 6744f612..589901a9 100644 --- a/tests/Unit/Normalizer/ObjectNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectNormalizerTest.php @@ -28,7 +28,7 @@ public function testNormalizeMissingHydrator(): void $this->expectException(MissingHydrator::class); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $this->assertEquals(null, $normalizer->normalize(null, [])); + $normalizer->normalize(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de')), ['hydrator' => 'not a hydrator']); } public function testDenormalizeMissingHydrator(): void @@ -36,7 +36,15 @@ public function testDenormalizeMissingHydrator(): void $this->expectException(MissingHydrator::class); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $this->assertEquals(null, $normalizer->denormalize(null, [])); + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de'], []); + } + + public function testNullDoesNotNeedHydrator(): void + { + $normalizer = new ObjectNormalizer(ProfileCreated::class); + + self::assertNull($normalizer->normalize(null, [])); + self::assertNull($normalizer->denormalize(null, [])); } public function testNormalizeWithNull(): void @@ -44,9 +52,8 @@ public function testNormalizeWithNull(): void $hydrator = $this->createMock(Hydrator::class); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); - $this->assertEquals(null, $normalizer->normalize(null, [])); + $this->assertEquals(null, $normalizer->normalize(null, [Hydrator::HYDRATOR => $hydrator])); } public function testDenormalizeWithNull(): void @@ -54,9 +61,8 @@ public function testDenormalizeWithNull(): void $hydrator = $this->createMock(Hydrator::class); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); - $this->assertEquals(null, $normalizer->denormalize(null, [])); + $this->assertEquals(null, $normalizer->denormalize(null, [Hydrator::HYDRATOR => $hydrator])); } public function testNormalizeWithInvalidArgument(): void @@ -68,8 +74,7 @@ public function testNormalizeWithInvalidArgument(): void $hydrator = $this->createMock(Hydrator::class); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); - $normalizer->normalize('foo', []); + $normalizer->normalize('foo', [Hydrator::HYDRATOR => $hydrator]); } public function testNormalizeWithValue(): void @@ -85,10 +90,9 @@ public function testNormalizeWithValue(): void ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); self::assertEquals( - $normalizer->normalize($event, []), + $normalizer->normalize($event, [Hydrator::HYDRATOR => $hydrator]), ['profileId' => '1', 'email' => 'info@patchlevel.de'], ); } @@ -109,18 +113,17 @@ public function testDenormalizeWithValue(): void ->willReturn($expected); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); $this->assertEquals( $expected, - $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de'], []), + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de'], [Hydrator::HYDRATOR => $hydrator]), ); } public function testNormalizePassesContextToHydrator(): void { - $context = ['key' => 'value']; $hydrator = $this->createMock(Hydrator::class); + $context = ['key' => 'value', Hydrator::HYDRATOR => $hydrator]; $event = new ProfileCreated( ProfileId::fromString('1'), @@ -131,7 +134,6 @@ public function testNormalizePassesContextToHydrator(): void ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); self::assertEquals( ['profileId' => '1', 'email' => 'info@patchlevel.de'], @@ -141,8 +143,8 @@ public function testNormalizePassesContextToHydrator(): void public function testDenormalizePassesContextToHydrator(): void { - $context = ['key' => 'value']; $hydrator = $this->createMock(Hydrator::class); + $context = ['key' => 'value', Hydrator::HYDRATOR => $hydrator]; $expected = new ProfileCreated( ProfileId::fromString('1'), @@ -156,7 +158,6 @@ public function testDenormalizePassesContextToHydrator(): void )->willReturn($expected); $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); $this->assertEquals( $expected, @@ -166,10 +167,7 @@ public function testDenormalizePassesContextToHydrator(): void public function testAutoDetect(): void { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(); - $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::object(ProfileCreated::class)); self::assertEquals(ProfileCreated::class, $normalizer->className()); @@ -177,10 +175,7 @@ public function testAutoDetect(): void public function testAutoDetectOverrideNotPossible(): void { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(AutoTypeDto::class); - $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::object(ProfileCreated::class)); self::assertEquals(AutoTypeDto::class, $normalizer->className()); @@ -190,10 +185,7 @@ public function testAutoDetectMissingType(): void { $this->expectException(InvalidType::class); - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(); - $normalizer->setHydrator($hydrator); $normalizer->className(); } @@ -202,10 +194,7 @@ public function testAutoDetectMissingTypeBecauseNull(): void { $this->expectException(InvalidType::class); - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(); - $normalizer->setHydrator($hydrator); $normalizer->handleType(null); $normalizer->className(); @@ -213,10 +202,7 @@ public function testAutoDetectMissingTypeBecauseNull(): void public function testGeneric(): void { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(); - $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::generic(Type::object(ProfileCreated::class))); self::assertEquals(ProfileCreated::class, $normalizer->className()); @@ -224,10 +210,7 @@ public function testGeneric(): void public function testTemplate(): void { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(); - $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::template('T', Type::object(ProfileCreated::class))); self::assertEquals(ProfileCreated::class, $normalizer->className()); @@ -235,10 +218,7 @@ public function testTemplate(): void public function testSerialize(): void { - $hydrator = $this->createMock(Hydrator::class); - $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); $serialized = serialize($normalizer); diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 8b8e3195..fd194aa7 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\DenormalizationFailure; +use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\AllMiddlewaresSkipped; @@ -22,7 +23,6 @@ use Patchlevel\Hydrator\Middleware\TransformMiddleware; use Patchlevel\Hydrator\MissingMiddlewares; use Patchlevel\Hydrator\NormalizationFailure; -use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use Patchlevel\Hydrator\StackHydrator; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; @@ -53,7 +53,6 @@ use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; use ReflectionClass; -use ReflectionProperty; #[CoversClass(StackHydrator::class)] #[CoversClass(TransformMiddleware::class)] @@ -201,7 +200,8 @@ public function testExtractWithContext(): void ->with( $this->isInstanceOf(ClassMetadata::class), $object, - ['context' => '123'], + $this->callback(static fn (array $context): bool => $context['context'] === '123' + && $context[Hydrator::HYDRATOR] instanceof StackHydrator), $this->isInstanceOf(Stack::class), )->willReturn($expect); @@ -367,7 +367,8 @@ public function testHydrateWithContext(): void ->with( $this->isInstanceOf(ClassMetadata::class), $data, - ['context' => '123'], + $this->callback(static fn (array $context): bool => $context['context'] === '123' + && $context[Hydrator::HYDRATOR] instanceof StackHydrator), $this->isInstanceOf(Stack::class), )->willReturn($expect); @@ -626,17 +627,104 @@ public function testMetadata(): void self::assertSame($metadata, $metadata2); } - public function testMetadataWithHydratorAwareNormalizer(): void + public function testHydratorIsPassedInContext(): void + { + $middleware = new class implements Middleware { + /** @var list> */ + public array $contexts = []; + + /** + * @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 + { + $this->contexts[] = $context; + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + /** + * @param array $context + * + * @return array + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + return $stack->next()->extract($metadata, $object, $context, $stack); + } + }; + + $hydrator = new StackHydrator(middlewares: [$middleware, new TransformMiddleware()]); + $hydrator->hydrate( + ProfileCreatedWrapper::class, + ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']], + ['key' => 'value'], + ); + + self::assertSame( + [ + ['key' => 'value', Hydrator::HYDRATOR => $hydrator], + ['key' => 'value', Hydrator::HYDRATOR => $hydrator], + ], + $middleware->contexts, + ); + } + + public function testOuterHydratorIsUsedForNestedObjects(): void { - $metadata = $this->hydrator->metadata(ProfileCreatedWrapper::class); + $outer = new class (new StackHydrator()) implements Hydrator { + /** @var list */ + public array $calls = []; + + public function __construct(private readonly Hydrator $inner) + { + } + + /** + * @param class-string $class + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, mixed $data, array $context = []): object + { + $context[Hydrator::HYDRATOR] ??= $this; + $this->calls[] = 'hydrate ' . $class; + + return $this->inner->hydrate($class, $data, $context); + } - $propertyMetadata = $metadata->propertyForField('event'); - $normalizer = $propertyMetadata->normalizer; + /** @param array $context */ + public function extract(object $object, array $context = []): mixed + { + $context[Hydrator::HYDRATOR] ??= $this; + $this->calls[] = 'extract ' . $object::class; - self::assertInstanceOf(HydratorAwareNormalizer::class, $normalizer); + return $this->inner->extract($object, $context); + } + }; - $reflection = new ReflectionProperty($normalizer, 'hydrator'); - self::assertSame($this->hydrator, $reflection->getValue($normalizer)); + $data = ['event' => ['profileId' => '1', 'email' => 'info@patchlevel.de']]; + $object = $outer->hydrate(ProfileCreatedWrapper::class, $data); + + self::assertSame($data, $outer->extract($object)); + self::assertSame( + [ + 'hydrate ' . ProfileCreatedWrapper::class, + 'hydrate ' . ProfileCreated::class, + 'extract ' . ProfileCreatedWrapper::class, + 'extract ' . ProfileCreated::class, + ], + $outer->calls, + ); } public function testSkippableMiddlewareIsSkipped(): void