Skip to content

Commit 9a356a3

Browse files
authored
Merge pull request #40 from patchlevel/union-object-normalizer
add union object normalizer
2 parents 490de80 + 8d95bb0 commit 9a356a3

4 files changed

Lines changed: 290 additions & 0 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,25 @@ final class AnotherDto
308308
> [!WARNING]
309309
> Circular references are not supported and will result in an exception.
310310
311+
#### Union Object
312+
313+
If you have a union type from multiple classes, then you can use the `UnionObjectNormalizer` normalizer
314+
315+
```php
316+
use Patchlevel\Hydrator\Normalizer\UnionObjectNormalizer;
317+
318+
#[UnionObjectNormalizer([
319+
ContentBlock::class => 'content',
320+
CodeBlock::class => 'code'
321+
])]
322+
interface Block
323+
{
324+
}
325+
```
326+
327+
> [!NOTE]
328+
> Auto detection of the type is not possible. You have to specify the type yourself.
329+
311330
#### Inline
312331

313332
The `InlineNormalizer` allows you to define normalization and denormalization logic directly via closures.

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ parameters:
102102
count: 1
103103
path: src/Normalizer/ReflectionTypeUtil.php
104104

105+
-
106+
message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\<string, mixed\>, array given\.$#'
107+
identifier: argument.type
108+
count: 1
109+
path: src/Normalizer/UnionObjectNormalizer.php
110+
105111
-
106112
message: '#^Method Patchlevel\\Hydrator\\Tests\\Unit\\Fixture\\DtoWithHooks\:\:postHydrate\(\) is unused\.$#'
107113
identifier: method.unused
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 | Attribute::TARGET_CLASS)]
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 $classToTypeMap,
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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Tests\Unit\Normalizer;
6+
7+
use Patchlevel\Hydrator\Hydrator;
8+
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
9+
use Patchlevel\Hydrator\Normalizer\MissingHydrator;
10+
use Patchlevel\Hydrator\Normalizer\UnionObjectNormalizer;
11+
use Patchlevel\Hydrator\Tests\Unit\Fixture\Email;
12+
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated;
13+
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId;
14+
use PHPUnit\Framework\Attributes\CoversClass;
15+
use PHPUnit\Framework\TestCase;
16+
17+
use function serialize;
18+
use function unserialize;
19+
20+
#[CoversClass(UnionObjectNormalizer::class)]
21+
final class UnionObjectNormalizerTest extends TestCase
22+
{
23+
public function testNormalizeMissingHydrator(): void
24+
{
25+
$this->expectException(MissingHydrator::class);
26+
27+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
28+
$this->assertEquals(null, $normalizer->normalize(null));
29+
}
30+
31+
public function testDenormalizeMissingHydrator(): void
32+
{
33+
$this->expectException(MissingHydrator::class);
34+
35+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
36+
$this->assertEquals(null, $normalizer->denormalize(null));
37+
}
38+
39+
public function testNormalizeWithNull(): void
40+
{
41+
$hydrator = $this->createStub(Hydrator::class);
42+
43+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
44+
$normalizer->setHydrator($hydrator);
45+
46+
$this->assertEquals(null, $normalizer->normalize(null));
47+
}
48+
49+
public function testDenormalizeWithNull(): void
50+
{
51+
$hydrator = $this->createStub(Hydrator::class);
52+
53+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
54+
$normalizer->setHydrator($hydrator);
55+
56+
$this->assertEquals(null, $normalizer->denormalize(null));
57+
}
58+
59+
public function testNormalizeWithInvalidArgument(): void
60+
{
61+
$this->expectException(InvalidArgument::class);
62+
$this->expectExceptionMessage('type "Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated|null" was expected but "string" was passed.');
63+
64+
$hydrator = $this->createStub(Hydrator::class);
65+
66+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
67+
$normalizer->setHydrator($hydrator);
68+
$normalizer->normalize('foo');
69+
}
70+
71+
public function testDenormalizeWithInvalidArgument(): void
72+
{
73+
$this->expectException(InvalidArgument::class);
74+
$this->expectExceptionMessage('array<string, mixed>|null" was expected but "string" was passed.');
75+
76+
$hydrator = $this->createStub(Hydrator::class);
77+
78+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
79+
$normalizer->setHydrator($hydrator);
80+
$normalizer->denormalize('foo');
81+
}
82+
83+
public function testNormalizeWithValue(): void
84+
{
85+
$hydrator = $this->createMock(Hydrator::class);
86+
87+
$event = new ProfileCreated(
88+
ProfileId::fromString('1'),
89+
Email::fromString('info@patchlevel.de'),
90+
);
91+
92+
$hydrator
93+
->expects($this->once())
94+
->method('extract')
95+
->with($event)
96+
->willReturn(['profileId' => '1', 'email' => 'info@patchlevel.de']);
97+
98+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
99+
$normalizer->setHydrator($hydrator);
100+
101+
self::assertEquals(
102+
$normalizer->normalize($event),
103+
['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created'],
104+
);
105+
}
106+
107+
public function testDenormalizeWithValue(): void
108+
{
109+
$hydrator = $this->createMock(Hydrator::class);
110+
111+
$expected = new ProfileCreated(
112+
ProfileId::fromString('1'),
113+
Email::fromString('info@patchlevel.de'),
114+
);
115+
116+
$hydrator
117+
->expects($this->once())
118+
->method('hydrate')
119+
->with(ProfileCreated::class, ['profileId' => '1', 'email' => 'info@patchlevel.de'])
120+
->willReturn($expected);
121+
122+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
123+
$normalizer->setHydrator($hydrator);
124+
125+
$this->assertEquals(
126+
$expected,
127+
$normalizer->denormalize(['profileId' => '1', 'email' => 'info@patchlevel.de', '_type' => 'created']),
128+
);
129+
}
130+
131+
public function testSerialize(): void
132+
{
133+
$hydrator = $this->createStub(Hydrator::class);
134+
135+
$normalizer = new UnionObjectNormalizer([ProfileCreated::class => 'created']);
136+
$normalizer->setHydrator($hydrator);
137+
138+
$serialized = serialize($normalizer);
139+
$normalizer2 = unserialize($serialized);
140+
141+
self::assertInstanceOf(UnionObjectNormalizer::class, $normalizer2);
142+
self::assertEquals(new UnionObjectNormalizer([ProfileCreated::class => 'created']), $normalizer2);
143+
}
144+
}

0 commit comments

Comments
 (0)