From 0a445bc2f330c8d43f2959e3eb7c51787729cd4a Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 19 Apr 2025 15:00:32 +0200 Subject: [PATCH 1/7] add guesser --- src/Guesser/BuiltInGuesser.php | 39 +++++++++++++++++++++++ src/Guesser/ChainGuesser.php | 33 +++++++++++++++++++ src/Guesser/Guesser.php | 13 ++++++++ src/Metadata/AttributeMetadataFactory.php | 30 ++++------------- 4 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 src/Guesser/BuiltInGuesser.php create mode 100644 src/Guesser/ChainGuesser.php create mode 100644 src/Guesser/Guesser.php diff --git a/src/Guesser/BuiltInGuesser.php b/src/Guesser/BuiltInGuesser.php new file mode 100644 index 0000000..3a8802b --- /dev/null +++ b/src/Guesser/BuiltInGuesser.php @@ -0,0 +1,39 @@ +getClassName()); + } + + return match ($type->getClassName()) { + DateTimeImmutable::class => new DateTimeImmutableNormalizer(), + DateTime::class => new DateTimeNormalizer(), + DateTimeZone::class => new DateTimeZoneNormalizer(), + default => $this->fallbackObjectNormalizer ? new ObjectNormalizer($type->getClassName()) : null, + }; + } +} diff --git a/src/Guesser/ChainGuesser.php b/src/Guesser/ChainGuesser.php new file mode 100644 index 0000000..d58bad8 --- /dev/null +++ b/src/Guesser/ChainGuesser.php @@ -0,0 +1,33 @@ + $guessers */ + public function __construct( + private readonly iterable $guessers, + ) { + } + + public function guess(ObjectType $type): Normalizer|null + { + foreach ($this->guessers as $guesser) { + $normalizer = $guesser->guess($type); + + if ($normalizer !== null) { + return $normalizer; + } + } + + return null; + } +} +{ + +} diff --git a/src/Guesser/Guesser.php b/src/Guesser/Guesser.php new file mode 100644 index 0000000..40fc568 --- /dev/null +++ b/src/Guesser/Guesser.php @@ -0,0 +1,13 @@ +typeResolver = $typeResolver ?: TypeResolver::create(); + $this->guesser = $guesser ?: new BuiltInGuesser(); } /** @@ -376,10 +374,6 @@ private function inferNormalizerByType(Type $type): Normalizer|null $type = $type->getWrappedType(); } - if ($type instanceof BackedEnumType) { - return new EnumNormalizer($type->getClassName()); - } - if ($type instanceof ObjectType) { $normalizer = $this->findNormalizerOnClass($type->getClassName()); @@ -387,7 +381,7 @@ private function inferNormalizerByType(Type $type): Normalizer|null return $normalizer; } - return $this->guessNormalizerByObjectType($type); + return $this->guesser->guess($type); } if ($type instanceof CollectionType) { @@ -451,14 +445,4 @@ private function findNormalizerOnClass(string $class): Normalizer|null return null; } - - private function guessNormalizerByObjectType(ObjectType $type): Normalizer|null - { - return match ($type->getClassName()) { - DateTimeImmutable::class => new DateTimeImmutableNormalizer(), - DateTime::class => new DateTimeNormalizer(), - DateTimeZone::class => new DateTimeZoneNormalizer(), - default => null, - }; - } } From 8da6d06337bfd9c87c7e1187f5be1264c5dd1b32 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 19 Apr 2025 15:28:20 +0200 Subject: [PATCH 2/7] add tests --- src/Guesser/ChainGuesser.php | 3 - tests/Unit/Guesser/BuiltInGuesserTest.php | 73 +++++++++++++++++++++++ tests/Unit/Guesser/ChainGuesserTest.php | 59 ++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Guesser/BuiltInGuesserTest.php create mode 100644 tests/Unit/Guesser/ChainGuesserTest.php diff --git a/src/Guesser/ChainGuesser.php b/src/Guesser/ChainGuesser.php index d58bad8..9a3a54d 100644 --- a/src/Guesser/ChainGuesser.php +++ b/src/Guesser/ChainGuesser.php @@ -28,6 +28,3 @@ public function guess(ObjectType $type): Normalizer|null return null; } } -{ - -} diff --git a/tests/Unit/Guesser/BuiltInGuesserTest.php b/tests/Unit/Guesser/BuiltInGuesserTest.php new file mode 100644 index 0000000..85d01ee --- /dev/null +++ b/tests/Unit/Guesser/BuiltInGuesserTest.php @@ -0,0 +1,73 @@ +guess(Type::object(Email::class))); + } + + public function testEnum(): void + { + $guesser = new BuiltInGuesser(); + self::assertInstanceOf( + EnumNormalizer::class, + $guesser->guess(Type::enum(Status::class)), + ); + } + + public function testDateTimeImmutable(): void + { + $guesser = new BuiltInGuesser(); + self::assertInstanceOf( + DateTimeImmutableNormalizer::class, + $guesser->guess(Type::object(DateTimeImmutable::class)), + ); + } + + public function testDateTime(): void + { + $guesser = new BuiltInGuesser(); + self::assertInstanceOf( + DateTimeNormalizer::class, + $guesser->guess(Type::object(DateTime::class)), + ); + } + + public function testDateTimeZone(): void + { + $guesser = new BuiltInGuesser(); + self::assertInstanceOf( + DateTimeZoneNormalizer::class, + $guesser->guess(Type::object(DateTimeZone::class)), + ); + } + + public function testFallbackObjectNormalizer(): void + { + $guesser = new BuiltInGuesser(true); + self::assertInstanceOf( + ObjectNormalizer::class, + $guesser->guess(Type::object(Email::class)), + ); + } +} diff --git a/tests/Unit/Guesser/ChainGuesserTest.php b/tests/Unit/Guesser/ChainGuesserTest.php new file mode 100644 index 0000000..cb85686 --- /dev/null +++ b/tests/Unit/Guesser/ChainGuesserTest.php @@ -0,0 +1,59 @@ +createMock(Guesser::class); + $guesser1->expects($this->once()) + ->method('guess') + ->willReturn(null); + + $expectedNormalizer = new DateTimeImmutableNormalizer(); + + $guesser2 = $this->createMock(Guesser::class); + $guesser2->expects($this->once()) + ->method('guess') + ->willReturn($expectedNormalizer); + + $guesser3 = $this->createMock(Guesser::class); + $guesser3->expects($this->never()) + ->method('guess'); + + $chainGuesser = new ChainGuesser([$guesser1, $guesser2, $guesser3]); + + $result = $chainGuesser->guess(Type::object(DateTimeImmutable::class)); + + $this->assertSame($expectedNormalizer, $result); + } + + public function testGuessReturnsNullIfAllGuessersReturnNull(): void + { + $guesser1 = $this->createMock(Guesser::class); + $guesser1->expects($this->once()) + ->method('guess') + ->willReturn(null); + + $guesser2 = $this->createMock(Guesser::class); + $guesser2->expects($this->once()) + ->method('guess') + ->willReturn(null); + + $chainGuesser = new ChainGuesser([$guesser1, $guesser2]); + + $result = $chainGuesser->guess(Type::object(DateTimeImmutable::class)); + + $this->assertNull($result); + } +} From 61532b534994f1828371408fb75baa0b257bd1ad Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 10:09:23 +0200 Subject: [PATCH 3/7] add create method for DX --- src/Guesser/BuiltInGuesser.php | 2 +- src/Metadata/AttributeMetadataFactory.php | 2 +- src/MetadataHydrator.php | 26 +++++++++++++++++++ tests/Benchmark/HydratorBench.php | 2 +- .../HydratorWithCryptographyBench.php | 11 +++++--- tests/Unit/Guesser/BuiltInGuesserTest.php | 2 +- 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/Guesser/BuiltInGuesser.php b/src/Guesser/BuiltInGuesser.php index 3a8802b..51d7f47 100644 --- a/src/Guesser/BuiltInGuesser.php +++ b/src/Guesser/BuiltInGuesser.php @@ -19,7 +19,7 @@ final class BuiltInGuesser implements Guesser { public function __construct( - private readonly bool $fallbackObjectNormalizer = false, + private readonly bool $fallbackObjectNormalizer = true, ) { } diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index 77aeef1..45948d7 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -44,7 +44,7 @@ public function __construct( Guesser|null $guesser = null, ) { $this->typeResolver = $typeResolver ?: TypeResolver::create(); - $this->guesser = $guesser ?: new BuiltInGuesser(); + $this->guesser = $guesser ?: new BuiltInGuesser(false); } /** diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index 33443c0..2e00b28 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -8,6 +8,9 @@ use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\Event\PostExtract; use Patchlevel\Hydrator\Event\PreHydrate; +use Patchlevel\Hydrator\Guesser\BuiltInGuesser; +use Patchlevel\Hydrator\Guesser\ChainGuesser; +use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\ClassNotFound; @@ -226,4 +229,27 @@ private function promotedConstructorParametersWithDefaultValue(ClassMetadata $me return $result; } + + /** @param iterable $guessers */ + public static function create( + iterable $guessers = [], + EventDispatcherInterface|null $eventDispatcher = null, + ): self { + $guesser = new BuiltInGuesser(); + + if ($guessers !== []) { + $guesser = new ChainGuesser([ + ...$guessers, + $guesser, + ]); + } + + return new self( + new AttributeMetadataFactory( + guesser: $guesser, + ), + null, + $eventDispatcher, + ); + } } diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/HydratorBench.php index 6ea2eb7..7c0c35b 100644 --- a/tests/Benchmark/HydratorBench.php +++ b/tests/Benchmark/HydratorBench.php @@ -18,7 +18,7 @@ final class HydratorBench public function __construct() { - $this->hydrator = new MetadataHydrator(); + $this->hydrator = MetadataHydrator::create(); } public function setUp(): void diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index f3b076b..5b8ebb2 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Benchmark; +use Patchlevel\Hydrator\Cryptography\CryptographySubscriber; use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; use Patchlevel\Hydrator\Hydrator; @@ -12,6 +13,7 @@ use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId; use Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill; use PhpBench\Attributes as Bench; +use Symfony\Component\EventDispatcher\EventDispatcher; #[Bench\BeforeMethods('setUp')] final class HydratorWithCryptographyBench @@ -24,9 +26,12 @@ public function __construct() { $this->store = new InMemoryCipherKeyStore(); - $this->hydrator = new MetadataHydrator( - cryptographer: PersonalDataPayloadCryptographer::createWithDefaultSettings($this->store), - ); + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber(new CryptographySubscriber( + PersonalDataPayloadCryptographer::createWithDefaultSettings($this->store), + )); + + $this->hydrator = MetadataHydrator::create(eventDispatcher: $eventDispatcher); } public function setUp(): void diff --git a/tests/Unit/Guesser/BuiltInGuesserTest.php b/tests/Unit/Guesser/BuiltInGuesserTest.php index 85d01ee..21b4543 100644 --- a/tests/Unit/Guesser/BuiltInGuesserTest.php +++ b/tests/Unit/Guesser/BuiltInGuesserTest.php @@ -22,7 +22,7 @@ final class BuiltInGuesserTest extends TestCase { public function testNoMatch(): void { - $guesser = new BuiltInGuesser(); + $guesser = new BuiltInGuesser(false); self::assertNull($guesser->guess(Type::object(Email::class))); } From 5923f0be236cd99445bab2e4c695c4ccc37ff41d Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 10:12:07 +0200 Subject: [PATCH 4/7] add phpstan errors into baseline --- phpstan-baseline.neon | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8873f69..0cdb2bb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,6 +18,36 @@ parameters: count: 1 path: src/Cryptography/PersonalDataPayloadCryptographer.php + - + message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: src/Guesser/BuiltInGuesser.php + + - + message: '#^Parameter \#1 \$className of class Patchlevel\\Hydrator\\Normalizer\\ObjectNormalizer constructor expects class\-string\|null, string given\.$#' + identifier: argument.type + count: 1 + path: src/Guesser/BuiltInGuesser.php + + - + message: '#^Parameter \#1 \$enum of class Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer constructor expects class\-string\\|null, string given\.$#' + identifier: argument.type + count: 1 + path: src/Guesser/BuiltInGuesser.php + + - + message: '#^Method Patchlevel\\Hydrator\\Guesser\\ChainGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: src/Guesser/ChainGuesser.php + + - + message: '#^Method Patchlevel\\Hydrator\\Guesser\\Guesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: src/Guesser/Guesser.php + - message: '#^Dead catch \- ReflectionException is never thrown in the try block\.$#' identifier: catch.neverThrown @@ -30,12 +60,6 @@ parameters: count: 1 path: src/Metadata/AttributeMetadataFactory.php - - - message: '#^Method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:guessNormalizerByObjectType\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: src/Metadata/AttributeMetadataFactory.php - - message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:findNormalizerOnClass\(\) expects class\-string, string given\.$#' identifier: argument.type @@ -43,8 +67,8 @@ parameters: path: src/Metadata/AttributeMetadataFactory.php - - message: '#^Parameter \#1 \$enum of class Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer constructor expects class\-string\\|null, string given\.$#' - identifier: argument.type + message: '#^Property Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:\$guesser \(Patchlevel\\Hydrator\\Guesser\\Guesser\|null\) is never assigned null so it can be removed from the property type\.$#' + identifier: property.unusedType count: 1 path: src/Metadata/AttributeMetadataFactory.php From a82d62595e3bb7afd7b642166dadde5f181e4424 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 10:14:57 +0200 Subject: [PATCH 5/7] fix test --- tests/Unit/Guesser/BuiltInGuesserTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Guesser/BuiltInGuesserTest.php b/tests/Unit/Guesser/BuiltInGuesserTest.php index 21b4543..48b4c9e 100644 --- a/tests/Unit/Guesser/BuiltInGuesserTest.php +++ b/tests/Unit/Guesser/BuiltInGuesserTest.php @@ -64,7 +64,7 @@ public function testDateTimeZone(): void public function testFallbackObjectNormalizer(): void { - $guesser = new BuiltInGuesser(true); + $guesser = new BuiltInGuesser(); self::assertInstanceOf( ObjectNormalizer::class, $guesser->guess(Type::object(Email::class)), From e2c4da86dfbc2546a4b7c2641e4bfa84fed5ab95 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 10:21:01 +0200 Subject: [PATCH 6/7] add create test --- tests/Unit/MetadataHydratorTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 3400ad4..87cb421 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -13,10 +13,12 @@ use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Event\PostExtract; use Patchlevel\Hydrator\Event\PreHydrate; +use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\NormalizationFailure; use Patchlevel\Hydrator\NormalizationMissing; +use Patchlevel\Hydrator\Normalizer\Normalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; @@ -41,6 +43,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; final class MetadataHydratorTest extends TestCase { @@ -510,4 +513,24 @@ public function testHydrateWithHooks(): void self::assertEquals(true, $object->postHydrateCalled); self::assertEquals(false, $object->preExtractCalled); } + + public function testCreate(): void + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + $hydrator = MetadataHydrator::create( + [ + new class implements Guesser + { + public function guess(ObjectType $type): Normalizer|null + { + return null; + } + }, + ], + $eventDispatcher, + ); + + self::assertInstanceOf(MetadataHydrator::class, $hydrator); + } } From 32d111427373f20dfb58e9eee7f43b0b9c18d3cd Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 20 Apr 2025 11:39:02 +0200 Subject: [PATCH 7/7] update docs --- README.md | 159 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 110 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index a544bb6..e753c1c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,17 @@ # Hydrator -With this library you can hydrate objects from array into objects and back again -with a focus on data processing from and into a database. -It has now been outsourced by the [event-sourcing](https://github.com/patchlevel/event-sourcing) library as a separate library. +This library enables seamless hydration of objects to arrays—and back again. +It’s optimized for both developer experience (DX) and performance. + +The library is a core component of [patchlevel/event-sourcing](ttps://github.com/patchlevel/event-sourcing), +where it powers the storage and retrieval of thousands of objects. + +Hydration is handled through normalizers, especially for complex data types. +The system can automatically determine the appropriate normalizer based on the data type and PHPStan/Psalm annotations. + +In most cases, no manual configuration is needed. +And if customization is required, it can be done easily using attributes. ## Installation @@ -22,17 +30,25 @@ To use the hydrator you just have to create an instance of it. ```php use Patchlevel\Hydrator\MetadataHydrator; -$hydrator = new MetadataHydrator(); +$hydrator = MetadataHydrator::create(); ``` After that you can hydrate any classes or objects. Also `final`, `readonly` classes with `property promotion`. +These objects or classes can have complex structures in the form of value objects, DTOs or collections. +Or all nested together. Here's an example: ```php final readonly class ProfileCreated { + /** + * @param list $skills + */ public function __construct( - public string $id, - public string $name + public int $id, + public string $name, + public Role $role, // enum, + public array $skills, // array of objects + public DateTimeImmutable $createdAt, ) { } } @@ -40,57 +56,98 @@ final readonly class ProfileCreated ### Extract Data -To convert objects into serializable arrays, you can use the `extract` method: +To convert objects into serializable arrays, you can use the `extract` method of the hydrator. ```php -$event = new ProfileCreated('1', 'patchlevel'); +$event = new ProfileCreated( + 1, + 'patchlevel', + Role::Admin, + [new Skill('php', 10), new Skill('event-sourcing', 10)], + new DateTimeImmutable('2023-10-01 12:00:00'), +); $data = $hydrator->extract($event); ``` +The result looks like this: + ```php [ - 'id' => '1', - 'name' => 'patchlevel' + 'id' => 1, + 'name' => 'patchlevel', + 'role' => 'admin', + 'skills' => [ + [ + 'name' => 'php', + 'level' => 10, + ], + [ + 'name' => 'event-sourcing', + 'level' => 10, + ], + ], + 'createdAt' => '2023-10-01T12:00:00+00:00', ] ``` +We could now convert the whole thing into JSON using `json_encode`. + ### Hydrate Object +The process can also be reversed. Hydrate an array back into an object. +To do this, we need to specify the class that should be created +and the data that should then be written into it. + ```php $event = $hydrator->hydrate( ProfileCreated::class, [ - 'id' => '1', - 'name' => 'patchlevel' + 'id' => 1, + 'name' => 'patchlevel', + 'role' => 'admin', + 'skills' => [ + [ + 'name' => 'php', + 'level' => 10, + ], + [ + 'name' => 'event-sourcing', + 'level' => 10, + ], + ], + 'createdAt' => '2023-10-01T12:00:00+00:00', ] ); $oldEvent == $event // true ``` +> [!WARNING] +> It is important to know that the constructor is not called! + ### Normalizer -For more complex structures, i.e. non-scalar data types, we use normalizers. -We have some built-in normalizers for standard structures such as objects, enums, datetime etc. +For more complex structures, i.e. non-scalar data types, we use normalizers. +We have some built-in normalizers for standard structures such as objects, arrays, enums, datetime etc. You can find the full list below. -The normalizers can be set on each property by using the specific attribute. -For example, `#[DateTimeImmutableNormalizer]`. This tells the Hydrator to normalize or denormalize this property. +The library attempts to independently determine which normalizers should be used. +For this purpose, normalizers of this order are determined: -Fortunately, we don't have to do this everywhere. -The library tries to independently recognize which normalizers are needed based on the data type. -For example, if you specify DateTimeImmutable Type, the DateTimeImmutableNormalizer is automatically added. -You can of course override this if you want. -This makes sense, for example, if you want to adjust the format of the normalized string. -You can do this by passing parameters to the normalizer. +1) Does the class property have a normalizer as an attribute? Use this. +2) The data type of the property is determined. + 1) If it is a collection, use the ArrayNormalizer (recursive). + 2) If it is an object, then look for a normalizer as attribute on the class or interfaces and use this. + 3) If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer. + +The normalizer is only determined once because it is cached in the metadata. +Below you will find the list of all normalizers and how to set them manually or explicitly. #### Array -If you have a list of objects that you want to normalize, then you must normalize each object individually. -That's what the `ArrayNormalizer` does for you. -In order to use the `ArrayNormaliser`, you still have to specify which normaliser should be applied to the individual -objects. Internally, it basically does an `array_map` and then runs the specified normalizer on each element. +If you have a collection (array, iterable, list) with a data type that needs to be normalized, +you can use the ArrayNormalizer and pass it the required normalizer. ```php use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; @@ -183,7 +240,6 @@ final class DTO #### Enum Backed enums can also be normalized. -For this, the enum FQCN must also be pass so that the `EnumNormalizer` knows which enum it is. ```php use Patchlevel\Hydrator\Normalizer\EnumNormalizer; @@ -252,14 +308,14 @@ final class Name For this we now need a custom normalizer. This normalizer must implement the `Normalizer` interface. -You also need to implement a `normalize` and `denormalize` method. -Finally, you have to allow the normalizer to be used as an attribute. +Finally, you have to allow the normalizer to be used as an attribute, +best to allow it for properties as well as classes. ```php use Patchlevel\Hydrator\Normalizer\Normalizer; use Patchlevel\Hydrator\Normalizer\InvalidArgument; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] class NameNormalizer implements Normalizer { public function normalize(mixed $value): string @@ -286,9 +342,6 @@ class NameNormalizer implements Normalizer } ``` -> [!WARNING] -> The important thing is that the result of Normalize is serializable! - Now we can also use the normalizer directly. ```php @@ -301,40 +354,48 @@ final class DTO ### Define normalizer on class level -You can also set the attribute on the value object on class level. -For that the normalizer needs to allow to be set on class level. +Instead of specifying the normalizer on each property, you can also set the normalizer on the class or on an interface. ```php -use Patchlevel\Hydrator\Normalizer\Normalizer; -use Patchlevel\Hydrator\Normalizer\InvalidArgument; - -#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -class NameNormalizer implements Normalizer +#[NameNormalizer] +final class Name { // ... same as before } ``` -Then set the attribute on the value object. +### Guess normalizer +It's also possible to write your own guesser that finds the correct normalizer based on the object. +This is useful if, for example, setting the normalizer on the class or interface isn't possible. ```php -#[NameNormalizer] -final class Name +use Patchlevel\Hydrator\Guesser\Guesser; +use Symfony\Component\TypeInfo\Type\ObjectType; + +class NameGuesser implements Guesser { - // ... same as before + public function guess(ObjectType $object): Normalizer|null + { + return match($object->getClassName()) { + case Name::class => new NameNormalizer(), + default => null, + }; + } } ``` -After that the DTO can then look like this. +To use this Guesser, you must specify it when creating the Hydrator: ```php -final class DTO -{ - public Name $name -} +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = MetadataHydrator::create([new NameGuesser()]); ``` +> [!NOTE] +> The guessers are queried in order, and the first match is returned. Finally, our built-in guesser is executed. + ### Normalized Name By default, the property name is used to name the field in the normalized result.