From d8e4829889fc2f92119fc842800f1d143863c4b2 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 19 Nov 2025 17:57:57 +0100 Subject: [PATCH] add inline normalizer --- README.md | 36 +++++++ src/Normalizer/InlineNormalizer.php | 53 ++++++++++ .../ProfileCreatedWithInlineNormalizer.php | 14 +++ tests/Unit/Fixture/ValueObject.php | 33 +++++++ tests/Unit/MetadataHydratorTest.php | 34 ++++++- .../Unit/Normalizer/InlineNormalizerTest.php | 98 +++++++++++++++++++ 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/Normalizer/InlineNormalizer.php create mode 100644 tests/Unit/Fixture/ProfileCreatedWithInlineNormalizer.php create mode 100644 tests/Unit/Fixture/ValueObject.php create mode 100644 tests/Unit/Normalizer/InlineNormalizerTest.php diff --git a/README.md b/README.md index ec11a49..41484bd 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,42 @@ final class AnotherDto > [!WARNING] > Circular references are not supported and will result in an exception. +#### Inline + +The `InlineNormalizer` allows you to define normalization and denormalization logic directly via closures. +This is useful for simple value objects or when you don't want to create a separate normalizer class. + +```php +use Patchlevel\Hydrator\Normalizer\InlineNormalizer; + +#[InlineNormalizer( + normalize: static function (self $object): string { + return $object->toString(); + }, + denormalize: static function (string $value): self { + return new self($value); + }, +)] +final class Email +{ + public function __construct( + private string $value + ) {} + + public function toString(): string + { + return $this->value; + } +} +``` + +> [!NOTE] +> Closures in attributes can only be used since PHP 8.5, therefore this normalizer can only be used with PHP 8.5. + +> [!TIP] +> If you want to handle `null` values within your closures, you can set the `passNull` option to `true`. +> By default, `null` values are not passed to the closures and are returned as `null` directly. + ### Custom Normalizer Since we only offer normalizers for PHP native things, diff --git a/src/Normalizer/InlineNormalizer.php b/src/Normalizer/InlineNormalizer.php new file mode 100644 index 0000000..bcc6161 --- /dev/null +++ b/src/Normalizer/InlineNormalizer.php @@ -0,0 +1,53 @@ +passNull && $value === null) { + return null; + } + + try { + return ($this->normalize)($value); + } catch (TypeError) { + throw new InvalidType(); + } + } + + public function denormalize(mixed $value): mixed + { + if (!$this->passNull && $value === null) { + return null; + } + + try { + return ($this->denormalize)($value); + } catch (TypeError) { + throw new InvalidType(); + } + } +} diff --git a/tests/Unit/Fixture/ProfileCreatedWithInlineNormalizer.php b/tests/Unit/Fixture/ProfileCreatedWithInlineNormalizer.php new file mode 100644 index 0000000..fe9c352 --- /dev/null +++ b/tests/Unit/Fixture/ProfileCreatedWithInlineNormalizer.php @@ -0,0 +1,14 @@ +toString(); + }, + denormalize: static function (string $value): self { + return self::fromString($value); + }, +)] +final class ValueObject +{ + private function __construct( + private readonly string $value, + ) { + } + + public function toString(): string + { + return $this->value; + } + + public static function fromString(string $value): self + { + return new self($value); + } +} diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 46ce134..0a2ce9d 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -33,12 +33,14 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper; use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; use Patchlevel\Hydrator\Tests\Unit\Fixture\Skill; use Patchlevel\Hydrator\Tests\Unit\Fixture\Status; use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ValueObject; use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer; use Patchlevel\Hydrator\TypeMismatch; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -114,7 +116,7 @@ public function testExtractCircularReference(): void $this->hydrator->extract($dto1); } - public function testExtractWithInferNormalizer2(): void + public function testExtractWithInferNormalizer(): void { $result = $this->hydrator->extract( new InferNormalizerWithNullableDto( @@ -149,6 +151,20 @@ public function testExtractWithInferNormalizerFailed(): void ); } + #[RequiresPhp('>=8.5')] + public function testExtractWithInlineNormalizer(): void + { + $event = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + self::assertEquals( + ['profileId' => '1', 'valueObject' => 'foo'], + $this->hydrator->extract($event), + ); + } + public function testExtractWithHooks(): void { $data = $this->hydrator->extract(new DtoWithHooks()); @@ -238,6 +254,22 @@ public function testHydrateWithTypeMismatch(): void ); } + #[RequiresPhp('>=8.5')] + public function testHydrateWithInlineNormalizer(): void + { + $expected = new ProfileCreatedWithInlineNormalizer( + ProfileId::fromString('1'), + ValueObject::fromString('foo'), + ); + + $event = $this->hydrator->hydrate( + ProfileCreatedWithInlineNormalizer::class, + ['profileId' => '1', 'valueObject' => 'foo'], + ); + + self::assertEquals($expected, $event); + } + public function testDenormalizationFailure(): void { $this->expectException(DenormalizationFailure::class); diff --git a/tests/Unit/Normalizer/InlineNormalizerTest.php b/tests/Unit/Normalizer/InlineNormalizerTest.php new file mode 100644 index 0000000..f3e81f3 --- /dev/null +++ b/tests/Unit/Normalizer/InlineNormalizerTest.php @@ -0,0 +1,98 @@ + (string)$value, + static fn (string $value): int => (int)$value, + ); + + $this->assertEquals('123', $normalizer->normalize(123)); + } + + public function testDenormalizeWithValue(): void + { + $normalizer = new InlineNormalizer( + static fn (int $value): string => (string)$value, + static fn (string $value): int => (int)$value, + ); + + $this->assertEquals(123, $normalizer->denormalize('123')); + } + + public function testNormalizeWithNull(): void + { + $normalizer = new InlineNormalizer( + static fn (mixed $value) => 'not null', + static fn (mixed $value) => 'not null', + ); + + $this->assertNull($normalizer->normalize(null)); + } + + public function testDenormalizeWithNull(): void + { + $normalizer = new InlineNormalizer( + static fn (mixed $value) => 'not null', + static fn (mixed $value) => 'not null', + ); + + $this->assertNull($normalizer->denormalize(null)); + } + + public function testNormalizePassNull(): void + { + $normalizer = new InlineNormalizer( + static fn (mixed $value) => $value === null ? 'is null' : 'is not null', + static fn (mixed $value) => $value, + true, + ); + + $this->assertEquals('is null', $normalizer->normalize(null)); + } + + public function testDenormalizePassNull(): void + { + $normalizer = new InlineNormalizer( + static fn (mixed $value) => $value, + static fn (mixed $value) => $value === null ? 'is null' : 'is not null', + true, + ); + + $this->assertEquals('is null', $normalizer->denormalize(null)); + } + + public function testNormalizeInvalidType(): void + { + $this->expectException(InvalidType::class); + + $normalizer = new InlineNormalizer( + static fn (string $value) => $value, + static fn (mixed $value) => $value, + ); + + $normalizer->normalize(123); + } + + public function testDenormalizeInvalidType(): void + { + $this->expectException(InvalidType::class); + + $normalizer = new InlineNormalizer( + static fn (mixed $value) => $value, + static fn (string $value) => $value, + ); + + $normalizer->denormalize(123); + } +}