diff --git a/README.md b/README.md index b826123..2a91e1c 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,29 @@ final class AnotherDto } ``` +#### Union Object + +If you have a union type from multiple classes, then you can use the `UnionObjectNormalizer` normalizer + +```php +use Patchlevel\Hydrator\Normalizer\UnionObjectNormalizer; + +final class DTO +{ + #[UnionObjectNormalizer([ + Foo::class => 'foo', + Bar::class => 'bar' + ])] + public Foo|Bar|null $object; +} +``` + > [!WARNING] > Circular references are not supported and will result in an exception. +> [!NOTE] +> Auto detection of the type is not possible. You have to specify the type yourself. + ### Custom Normalizer Since we only offer normalizers for PHP native things, diff --git a/baseline.xml b/baseline.xml index d3e4ee0..411e039 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + @@ -8,4 +8,9 @@ + + + + + diff --git a/src/Normalizer/UnionObjectNormalizer.php b/src/Normalizer/UnionObjectNormalizer.php new file mode 100644 index 0000000..28aabf5 --- /dev/null +++ b/src/Normalizer/UnionObjectNormalizer.php @@ -0,0 +1,121 @@ + */ + private array $typeToClassMap; + + /** @param array $classToTypeMap */ + public function __construct( + private readonly array|null $classToTypeMap = null, + private readonly string $typeFieldName = '_type', + ) { + $this->typeToClassMap = array_flip($classToTypeMap); + } + + public function setHydrator(Hydrator $hydrator): void + { + $this->hydrator = $hydrator; + } + + public function normalize(mixed $value): mixed + { + if (!$this->hydrator) { + throw new MissingHydrator(); + } + + if ($value === null) { + return null; + } + + if (!is_object($value)) { + throw InvalidArgument::withWrongType( + sprintf('%s|null', implode('|', array_keys($this->classToTypeMap))), + $value, + ); + } + + if (!array_key_exists($value::class, $this->classToTypeMap)) { + throw InvalidArgument::withWrongType( + sprintf('%s|null', implode('|', array_keys($this->classToTypeMap))), + $value, + ); + } + + $data = $this->hydrator->extract($value); + $data[$this->typeFieldName] = $this->classToTypeMap[$value::class]; + + return $data; + } + + public function denormalize(mixed $value): mixed + { + if (!$this->hydrator) { + throw new MissingHydrator(); + } + + if ($value === null) { + return null; + } + + if (!is_array($value)) { + throw InvalidArgument::withWrongType('array|null', $value); + } + + if (!array_key_exists($this->typeFieldName, $value)) { + throw new InvalidArgument(sprintf('missing type field "%s"', $this->typeFieldName)); + } + + $type = $value[$this->typeFieldName]; + + if (!is_string($type)) { + throw InvalidArgument::withWrongType('string', $type); + } + + if (!array_key_exists($type, $this->typeToClassMap)) { + throw new InvalidArgument(sprintf('unknown type "%s"', $type)); + } + + $className = $this->typeToClassMap[$type]; + unset($value[$this->typeFieldName]); + + return $this->hydrator->hydrate($className, $value); + } + + /** + * @return array{ + * typeToClassMap: array, + * classToTypeMap: array, + * typeFieldName: string, + * hydrator: null + * } + */ + public function __serialize(): array + { + return [ + 'typeToClassMap' => $this->typeToClassMap, + 'classToTypeMap' => $this->classToTypeMap, + 'typeFieldName' => $this->typeFieldName, + 'hydrator' => null, + ]; + } +} diff --git a/tests/Unit/Normalizer/UnionObjectNormalizerTest.php b/tests/Unit/Normalizer/UnionObjectNormalizerTest.php new file mode 100644 index 0000000..eb57a85 --- /dev/null +++ b/tests/Unit/Normalizer/UnionObjectNormalizerTest.php @@ -0,0 +1,143 @@ +expectException(MissingHydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $this->assertEquals(null, $normalizer->normalize(null)); + } + + public function testDenormalizeMissingHydrator(): void + { + $this->expectException(MissingHydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $this->assertEquals(null, $normalizer->denormalize(null)); + } + + public function testNormalizeWithNull(): void + { + $hydrator = $this->prophesize(Hydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + + $this->assertEquals(null, $normalizer->normalize(null)); + } + + public function testDenormalizeWithNull(): void + { + $hydrator = $this->prophesize(Hydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + + $this->assertEquals(null, $normalizer->denormalize(null)); + } + + public function testNormalizeWithInvalidArgument(): void + { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('type "Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated|null" was expected but "string" was passed.'); + + $hydrator = $this->prophesize(Hydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + $normalizer->normalize('foo'); + } + + public function testDenormalizeWithInvalidArgument(): void + { + $this->expectException(InvalidArgument::class); + $this->expectExceptionMessage('array|null" was expected but "string" was passed.'); + + $hydrator = $this->prophesize(Hydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + $normalizer->denormalize('foo'); + } + + public function testNormalizeWithValue(): void + { + $hydrator = $this->prophesize(Hydrator::class); + + $event = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator->extract($event) + ->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']) + ->shouldBeCalledOnce(); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + + self::assertEquals( + $normalizer->normalize($event), + ['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'], + ); + } + + public function testDenormalizeWithValue(): void + { + $hydrator = $this->prophesize(Hydrator::class); + + $expected = new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('info@patchlevel.de'), + ); + + $hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de']) + ->willReturn($expected) + ->shouldBeCalledOnce(); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + + $this->assertEquals( + $expected, + $normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created']), + ); + } + + public function testSerialize(): void + { + $hydrator = $this->prophesize(Hydrator::class); + + $normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']); + $normalizer->setHydrator($hydrator->reveal()); + + $serialized = serialize($normalizer); + $normalizer2 = unserialize($serialized); + + self::assertInstanceOf(UnionObjectNormalizer::class, $normalizer2); + self::assertEquals(new UnionObjectNormalizer([ProfileCreated::class => 'created']), $normalizer2); + } +}