From 54db55fc461f0af7870c418af756c2451778b444 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 25 Feb 2026 11:05:46 +0100 Subject: [PATCH] use class normalizer --- phpstan-baseline.neon | 18 ++++------- src/ArrayDataRequired.php | 21 +++++++++++++ src/Hydrator.php | 11 ++----- src/Metadata/AttributeMetadataFactory.php | 20 ++++++++++++ src/Metadata/ClassMetadata.php | 5 +++ src/MetadataHydrator.php | 31 ++++++++++++++----- src/Normalizer/ObjectMapNormalizer.php | 5 +++ src/Normalizer/ObjectNormalizer.php | 14 ++------- src/ObjectRequired.php | 31 +++++++++++++++++++ .../Lifecycle/LifecycleExtensionTest.php | 1 + .../Metadata/AttributeMetadataFactoryTest.php | 8 +++++ tests/Unit/Metadata/ClassMetadataTest.php | 1 + tests/Unit/MetadataHydratorTest.php | 31 +++++++++++++++++++ .../Unit/Normalizer/ObjectNormalizerTest.php | 13 -------- 14 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 src/ArrayDataRequired.php create mode 100644 src/ObjectRequired.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ab4a1e59..d2301971 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -73,22 +73,16 @@ parameters: path: src/Metadata/ClassMetadata.php - - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' - identifier: assign.propertyType - count: 1 - path: src/Normalizer/EnumNormalizer.php - - - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array given\.$#' + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Middleware\\Middleware\:\:hydrate\(\) expects array\, array\ given\.$#' identifier: argument.type - count: 1 - path: src/Normalizer/ObjectMapNormalizer.php + count: 3 + path: src/MetadataHydrator.php - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array\ given\.$#' - identifier: argument.type + message: '#^Property Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer\:\:\$enum \(class\-string\\|null\) does not accept string\.$#' + identifier: assign.propertyType count: 1 - path: src/Normalizer/ObjectNormalizer.php + path: src/Normalizer/EnumNormalizer.php - message: '#^Property Patchlevel\\Hydrator\\Normalizer\\ObjectNormalizer\:\:\$className \(class\-string\|null\) does not accept string\.$#' diff --git a/src/ArrayDataRequired.php b/src/ArrayDataRequired.php new file mode 100644 index 00000000..9e8781e9 --- /dev/null +++ b/src/ArrayDataRequired.php @@ -0,0 +1,21 @@ + $class - * @param array $data * @param array $context * * @return T @@ -17,12 +16,8 @@ interface Hydrator * * @template T of object */ - public function hydrate(string $class, array $data, array $context = []): object; + public function hydrate(string $class, mixed $data, array $context = []): object; - /** - * @param array $context - * - * @return array - */ - public function extract(object $object, array $context = []): array; + /** @param array $context */ + public function extract(object $object, array $context = []): mixed; } diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index 2e488916..02ea94fa 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer; use ReflectionAttribute; use ReflectionClass; @@ -73,6 +74,7 @@ private function getClassMetadata(ReflectionClass $reflectionClass): ClassMetada { $metadata = new ClassMetadata( $reflectionClass, + $this->getNormalizerOnClass($reflectionClass), $this->getPropertyMetadataList($reflectionClass), $this->getLazy($reflectionClass), ); @@ -189,6 +191,7 @@ private function mergeMetadata(ClassMetadata $parent, ClassMetadata $child): Cla return new ClassMetadata( $parent->reflection, + $child->normalizer ?? $parent->normalizer, array_values($properties), $child->lazy ?? $parent->lazy, array_merge($parent->extras, $child->extras), @@ -212,6 +215,23 @@ private function getNormalizer(ReflectionProperty $reflectionProperty): Normaliz return $normalizer; } + /** @param ReflectionClass $reflectionClass */ + private function getNormalizerOnClass(ReflectionClass $reflectionClass): Normalizer|null + { + $type = Type::object($reflectionClass->getName()); + $normalizer = $this->inferNormalizerByType($type); + + if ($normalizer instanceof ObjectNormalizer) { + return null; + } + + if ($normalizer instanceof TypeAwareNormalizer) { + $normalizer->handleType($type); + } + + return $normalizer; + } + private function findNormalizerOnProperty(ReflectionProperty $reflectionProperty): Normalizer|null { /** @var list> $attributeReflectionList */ diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index 24b176d8..36681dfb 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -4,11 +4,13 @@ namespace Patchlevel\Hydrator\Metadata; +use Patchlevel\Hydrator\Normalizer\Normalizer; use ReflectionClass; /** * @phpstan-type serialized array{ * className: class-string, + * normalizer: Normalizer|null, * properties: array, * lazy: bool|null, * extras: array, @@ -30,6 +32,7 @@ final class ClassMetadata */ public function __construct( public readonly ReflectionClass $reflection, + public Normalizer|null $normalizer = null, array $properties = [], public bool|null $lazy = null, public array $extras = [], @@ -67,6 +70,7 @@ public function __serialize(): array { return [ 'className' => $this->className, + 'normalizer' => $this->normalizer, 'properties' => $this->properties, 'lazy' => $this->lazy, 'extras' => $this->extras, @@ -77,6 +81,7 @@ public function __serialize(): array public function __unserialize(array $data): void { $this->reflection = new ReflectionClass($data['className']); + $this->normalizer = $data['normalizer']; $this->properties = $data['properties']; $this->lazy = $data['lazy']; $this->extras = $data['extras']; diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 24920f58..afc507f4 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -15,6 +15,7 @@ use ReflectionClass; use function array_key_exists; +use function is_array; use const PHP_VERSION_ID; @@ -33,14 +34,13 @@ public function __construct( /** * @param class-string $class - * @param array $data * @param array $context * * @return T * * @template T of object */ - public function hydrate(string $class, array $data, array $context = []): object + public function hydrate(string $class, mixed $data, array $context = []): object { try { $metadata = $this->metadata($class); @@ -48,6 +48,20 @@ public function hydrate(string $class, array $data, array $context = []): object throw new ClassNotSupported($class, $e); } + if ($metadata->normalizer) { + $return = $metadata->normalizer->denormalize($data, $context); + + if (!$return instanceof $class) { + throw new ObjectRequired($class, $metadata->normalizer::class); + } + + return $return; + } + + if (!is_array($data)) { + throw new ArrayDataRequired($class); + } + if (PHP_VERSION_ID < 80400) { $stack = new Stack($this->middlewares); @@ -71,14 +85,15 @@ function () use ($metadata, $data, $context): object { ); } - /** - * @param array $context - * - * @return array - */ - public function extract(object $object, array $context = []): array + /** @param array $context */ + public function extract(object $object, array $context = []): mixed { $metadata = $this->metadata($object::class); + + if ($metadata->normalizer) { + return $metadata->normalizer->normalize($object, $context); + } + $stack = new Stack($this->middlewares); return $stack->next()->extract($metadata, $object, $context, $stack); diff --git a/src/Normalizer/ObjectMapNormalizer.php b/src/Normalizer/ObjectMapNormalizer.php index 3a847808..1f99571d 100644 --- a/src/Normalizer/ObjectMapNormalizer.php +++ b/src/Normalizer/ObjectMapNormalizer.php @@ -67,6 +67,11 @@ public function normalize(mixed $value, array $context): mixed } $data = $this->hydrator->extract($value); + + if (!is_array($data)) { + throw InvalidArgument::withWrongType('array', $data); + } + $data[$this->typeFieldName] = $this->classToTypeMap[$value::class]; return $data; diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index b7f0b878..f97d2135 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -12,8 +12,6 @@ use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\TemplateType; -use function is_array; - #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] final class ObjectNormalizer implements Normalizer, TypeAwareNormalizer, HydratorAwareNormalizer { @@ -25,12 +23,8 @@ public function __construct( ) { } - /** - * @param array $context - * - * @return array|null - */ - public function normalize(mixed $value, array $context): array|null + /** @param array $context */ + public function normalize(mixed $value, array $context): mixed { if (!$this->hydrator) { throw new MissingHydrator(); @@ -60,10 +54,6 @@ public function denormalize(mixed $value, array $context): object|null return null; } - if (!is_array($value)) { - throw InvalidArgument::withWrongType('array|null', $value); - } - $className = $this->getClassName(); return $this->hydrator->hydrate($className, $value, $context); diff --git a/src/ObjectRequired.php b/src/ObjectRequired.php new file mode 100644 index 00000000..782f28e5 --- /dev/null +++ b/src/ObjectRequired.php @@ -0,0 +1,31 @@ + $normalizerClass + */ + public function __construct( + string $class, + string $normalizerClass, + ) { + parent::__construct( + sprintf( + 'The result of the normalizer "%s" for the class "%s" must be an instance of "%s".', + $normalizerClass, + $class, + $class, + ), + ); + } +} diff --git a/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php b/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php index 5b8f3daf..2d60a05f 100644 --- a/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php +++ b/tests/Unit/Extension/Lifecycle/LifecycleExtensionTest.php @@ -28,6 +28,7 @@ public function testIntegration(): void $extractedData = $hydrator->extract($object); + self::assertIsArray($extractedData); self::assertSame('foo [preHydrate] [postHydrate] [preExtract] [postExtract]', $extractedData['name']); } } diff --git a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php index e47650ca..20052f94 100644 --- a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php +++ b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php @@ -359,4 +359,12 @@ class { self::assertTrue($metadata->lazy); } + + public function testClassMetadataWithNormalizer(): void + { + $metadataFactory = new AttributeMetadataFactory(); + $metadata = $metadataFactory->metadata(ProfileId::class); + + self::assertInstanceOf(IdNormalizer::class, $metadata->normalizer); + } } diff --git a/tests/Unit/Metadata/ClassMetadataTest.php b/tests/Unit/Metadata/ClassMetadataTest.php index 99db2e64..906a7f2e 100644 --- a/tests/Unit/Metadata/ClassMetadataTest.php +++ b/tests/Unit/Metadata/ClassMetadataTest.php @@ -30,6 +30,7 @@ public function testPropertiesHashmap(): void $classMetadata = new ClassMetadata( $reflection, + null, [$fooMetadata, $barMetadata], ); diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 7d27f898..16d72890 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -7,6 +7,7 @@ use DateTime; use DateTimeImmutable; use DateTimeZone; +use Patchlevel\Hydrator\ArrayDataRequired; use Patchlevel\Hydrator\CircularReference; use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\CoreExtension; @@ -190,6 +191,15 @@ public function testExtractWithInlineNormalizer(): void ); } + public function testExtractWithClassNormalizer(): void + { + $data = $this->hydrator->extract( + ProfileId::fromString('id'), + ); + + self::assertEquals('id', $data); + } + public function testHydrate(): void { $expected = new ProfileCreated( @@ -216,6 +226,17 @@ public function testHydrateUnknownClass(): void ); } + public function testHydrateWithArrayDataRequired(): void + { + $this->expectException(ArrayDataRequired::class); + $this->expectExceptionMessage('The data for the class "Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated" must be an array. If you want to use another data type, you need to add a normalizer to the class.'); + + $this->hydrator->hydrate( + ProfileCreated::class, + 'foo', + ); + } + public function testHydrateWithDefaults(): void { $object = $this->hydrator->hydrate( @@ -471,6 +492,16 @@ public function testHydrateWithInferNormalizerWitIterables(): void self::assertEquals($expected, $event); } + public function testHydrateWithClassNormalizer(): void + { + $object = $this->hydrator->hydrate( + ProfileId::class, + 'id', + ); + + self::assertEquals(ProfileId::fromString('id'), $object); + } + #[RequiresPhp('>=8.4')] public function testLazyHydrate(): void { diff --git a/tests/Unit/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Normalizer/ObjectNormalizerTest.php index b53f67e0..08a9d67c 100644 --- a/tests/Unit/Normalizer/ObjectNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectNormalizerTest.php @@ -72,19 +72,6 @@ public function testNormalizeWithInvalidArgument(): void $normalizer->normalize('foo', []); } - public function testDenormalizeWithInvalidArgument(): void - { - $this->expectException(InvalidArgument::class); - $this->expectExceptionCode(0); - $this->expectExceptionMessage('array|null" was expected but "string" was passed.'); - - $hydrator = $this->createMock(Hydrator::class); - - $normalizer = new ObjectNormalizer(ProfileCreated::class); - $normalizer->setHydrator($hydrator); - $normalizer->denormalize('foo', []); - } - public function testNormalizeWithValue(): void { $hydrator = $this->createMock(Hydrator::class);