Skip to content

Commit d2a1e3d

Browse files
committed
add union object normalizer
1 parent 5050280 commit d2a1e3d

4 files changed

Lines changed: 290 additions & 1 deletion

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,29 @@ final class AnotherDto
208208
}
209209
```
210210

211+
#### Union Object
212+
213+
If you have a union type from multiple classes, then you can use the `UnionObjectNormalizer` normalizer
214+
215+
```php
216+
use Patchlevel\Hydrator\Normalizer\UnionObjectNormalizer;
217+
218+
final class DTO
219+
{
220+
#[UnionObjectNormalizer([
221+
Foo::class => 'foo',
222+
Bar::class => 'bar'
223+
])]
224+
public Foo|Bar|null $object;
225+
}
226+
```
227+
211228
> [!WARNING]
212229
> Circular references are not supported and will result in an exception.
213230
231+
> [!NOTE]
232+
> Auto detection of the type is not possible. You have to specify the type yourself.
233+
214234
### Custom Normalizer
215235

216236
Since we only offer normalizers for PHP native things,

baseline.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<files psalm-version="5.22.2@d768d914152dbbf3486c36398802f74e80cfde48">
2+
<files psalm-version="5.23.1@8471a896ccea3526b26d082f4461eeea467f10a4">
33
<file src="src/Normalizer/ObjectNormalizer.php">
44
<MixedArgument>
55
<code><![CDATA[$value]]></code>
@@ -8,4 +8,9 @@
88
<code><![CDATA[$value]]></code>
99
</MixedArgumentTypeCoercion>
1010
</file>
11+
<file src="src/Normalizer/UnionObjectNormalizer.php">
12+
<MixedArgumentTypeCoercion>
13+
<code><![CDATA[$value]]></code>
14+
</MixedArgumentTypeCoercion>
15+
</file>
1116
</files>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Normalizer;
6+
7+
use Attribute;
8+
use Patchlevel\Hydrator\Hydrator;
9+
10+
use function array_flip;
11+
use function array_key_exists;
12+
use function array_keys;
13+
use function implode;
14+
use function is_array;
15+
use function is_object;
16+
use function is_string;
17+
use function sprintf;
18+
19+
#[Attribute(Attribute::TARGET_PROPERTY)]
20+
final class UnionObjectNormalizer implements Normalizer, HydratorAwareNormalizer
21+
{
22+
private Hydrator|null $hydrator = null;
23+
24+
/** @var array<string, class-string> */
25+
private array $typeToClassMap;
26+
27+
/** @param array<class-string, string> $classToTypeMap */
28+
public function __construct(
29+
private readonly array|null $classToTypeMap = null,
30+
private readonly string $typeFieldName = '_type',
31+
) {
32+
$this->typeToClassMap = array_flip($classToTypeMap);
33+
}
34+
35+
public function setHydrator(Hydrator $hydrator): void
36+
{
37+
$this->hydrator = $hydrator;
38+
}
39+
40+
public function normalize(mixed $value): mixed
41+
{
42+
if (!$this->hydrator) {
43+
throw new MissingHydrator();
44+
}
45+
46+
if ($value === null) {
47+
return null;
48+
}
49+
50+
if (!is_object($value)) {
51+
throw InvalidArgument::withWrongType(
52+
sprintf('%s|null', implode('|', array_keys($this->classToTypeMap))),
53+
$value,
54+
);
55+
}
56+
57+
if (!array_key_exists($value::class, $this->classToTypeMap)) {
58+
throw InvalidArgument::withWrongType(
59+
sprintf('%s|null', implode('|', array_keys($this->classToTypeMap))),
60+
$value,
61+
);
62+
}
63+
64+
$data = $this->hydrator->extract($value);
65+
$data[$this->typeFieldName] = $this->classToTypeMap[$value::class];
66+
67+
return $data;
68+
}
69+
70+
public function denormalize(mixed $value): mixed
71+
{
72+
if (!$this->hydrator) {
73+
throw new MissingHydrator();
74+
}
75+
76+
if ($value === null) {
77+
return null;
78+
}
79+
80+
if (!is_array($value)) {
81+
throw InvalidArgument::withWrongType('array<string, mixed>|null', $value);
82+
}
83+
84+
if (!array_key_exists($this->typeFieldName, $value)) {
85+
throw new InvalidArgument(sprintf('missing type field "%s"', $this->typeFieldName));
86+
}
87+
88+
$type = $value[$this->typeFieldName];
89+
90+
if (!is_string($type)) {
91+
throw InvalidArgument::withWrongType('string', $type);
92+
}
93+
94+
if (!array_key_exists($type, $this->typeToClassMap)) {
95+
throw new InvalidArgument(sprintf('unknown type "%s"', $type));
96+
}
97+
98+
$className = $this->typeToClassMap[$type];
99+
unset($value[$this->typeFieldName]);
100+
101+
return $this->hydrator->hydrate($className, $value);
102+
}
103+
104+
/**
105+
* @return array{
106+
* typeToClassMap: array<string, class-string>,
107+
* classToTypeMap: array<class-string, string>,
108+
* typeFieldName: string,
109+
* hydrator: null
110+
* }
111+
*/
112+
public function __serialize(): array
113+
{
114+
return [
115+
'typeToClassMap' => $this->typeToClassMap,
116+
'classToTypeMap' => $this->classToTypeMap,
117+
'typeFieldName' => $this->typeFieldName,
118+
'hydrator' => null,
119+
];
120+
}
121+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Normalizer;
6+
7+
use Attribute;
8+
use Patchlevel\Hydrator\Hydrator;
9+
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
10+
use Patchlevel\Hydrator\Normalizer\MissingHydrator;
11+
use Patchlevel\Hydrator\Normalizer\UnionObjectNormalizer;
12+
use Patchlevel\Hydrator\Tests\Unit\Fixture\Email;
13+
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated;
14+
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId;
15+
use PHPUnit\Framework\TestCase;
16+
use Prophecy\PhpUnit\ProphecyTrait;
17+
18+
use function serialize;
19+
use function unserialize;
20+
21+
#[Attribute(Attribute::TARGET_PROPERTY)]
22+
final class UnionObjectNormalizerTest extends TestCase
23+
{
24+
use ProphecyTrait;
25+
26+
public function testNormalizeMissingHydrator(): void
27+
{
28+
$this->expectException(MissingHydrator::class);
29+
30+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
31+
$this->assertEquals(null, $normalizer->normalize(null));
32+
}
33+
34+
public function testDenormalizeMissingHydrator(): void
35+
{
36+
$this->expectException(MissingHydrator::class);
37+
38+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
39+
$this->assertEquals(null, $normalizer->denormalize(null));
40+
}
41+
42+
public function testNormalizeWithNull(): void
43+
{
44+
$hydrator = $this->prophesize(Hydrator::class);
45+
46+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
47+
$normalizer->setHydrator($hydrator->reveal());
48+
49+
$this->assertEquals(null, $normalizer->normalize(null));
50+
}
51+
52+
public function testDenormalizeWithNull(): void
53+
{
54+
$hydrator = $this->prophesize(Hydrator::class);
55+
56+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
57+
$normalizer->setHydrator($hydrator->reveal());
58+
59+
$this->assertEquals(null, $normalizer->denormalize(null));
60+
}
61+
62+
public function testNormalizeWithInvalidArgument(): void
63+
{
64+
$this->expectException(InvalidArgument::class);
65+
$this->expectExceptionMessage('type "Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated|null" was expected but "string" was passed.');
66+
67+
$hydrator = $this->prophesize(Hydrator::class);
68+
69+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
70+
$normalizer->setHydrator($hydrator->reveal());
71+
$normalizer->normalize('foo');
72+
}
73+
74+
public function testDenormalizeWithInvalidArgument(): void
75+
{
76+
$this->expectException(InvalidArgument::class);
77+
$this->expectExceptionMessage('array<string, mixed>|null" was expected but "string" was passed.');
78+
79+
$hydrator = $this->prophesize(Hydrator::class);
80+
81+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
82+
$normalizer->setHydrator($hydrator->reveal());
83+
$normalizer->denormalize('foo');
84+
}
85+
86+
public function testNormalizeWithValue(): void
87+
{
88+
$hydrator = $this->prophesize(Hydrator::class);
89+
90+
$event = new ProfileCreated(
91+
ProfileId::fromString('1'),
92+
Email::fromString('info@patchlevel.de'),
93+
);
94+
95+
$hydrator->extract($event)
96+
->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de'])
97+
->shouldBeCalledOnce();
98+
99+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
100+
$normalizer->setHydrator($hydrator->reveal());
101+
102+
self::assertEquals(
103+
$normalizer->normalize($event),
104+
['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'],
105+
);
106+
}
107+
108+
public function testDenormalizeWithValue(): void
109+
{
110+
$hydrator = $this->prophesize(Hydrator::class);
111+
112+
$expected = new ProfileCreated(
113+
ProfileId::fromString('1'),
114+
Email::fromString('info@patchlevel.de'),
115+
);
116+
117+
$hydrator->hydrate(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de'])
118+
->willReturn($expected)
119+
->shouldBeCalledOnce();
120+
121+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
122+
$normalizer->setHydrator($hydrator->reveal());
123+
124+
$this->assertEquals(
125+
$expected,
126+
$normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created']),
127+
);
128+
}
129+
130+
public function testSerialize(): void
131+
{
132+
$hydrator = $this->prophesize(Hydrator::class);
133+
134+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
135+
$normalizer->setHydrator($hydrator->reveal());
136+
137+
$serialized = serialize($normalizer);
138+
$normalizer2 = unserialize($serialized);
139+
140+
self::assertInstanceOf(UnionObjectNormalizer::class, $normalizer2);
141+
self::assertEquals(new UnionObjectNormalizer([ProfileCreated::class => 'created']), $normalizer2);
142+
}
143+
}

0 commit comments

Comments
 (0)