Skip to content

Commit 8c6390a

Browse files
authored
Merge pull request #91 from patchlevel/guesser-v2
add guesser
2 parents b3f1e0a + 32d1114 commit 8c6390a

12 files changed

+421
-84
lines changed

README.md

Lines changed: 110 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@
55

66
# Hydrator
77

8-
With this library you can hydrate objects from array into objects and back again
9-
with a focus on data processing from and into a database.
10-
It has now been outsourced by the [event-sourcing](https://github.com/patchlevel/event-sourcing) library as a separate library.
8+
This library enables seamless hydration of objects to arrays—and back again.
9+
It’s optimized for both developer experience (DX) and performance.
10+
11+
The library is a core component of [patchlevel/event-sourcing](ttps://github.com/patchlevel/event-sourcing),
12+
where it powers the storage and retrieval of thousands of objects.
13+
14+
Hydration is handled through normalizers, especially for complex data types.
15+
The system can automatically determine the appropriate normalizer based on the data type and PHPStan/Psalm annotations.
16+
17+
In most cases, no manual configuration is needed.
18+
And if customization is required, it can be done easily using attributes.
1119

1220
## Installation
1321

@@ -22,75 +30,124 @@ To use the hydrator you just have to create an instance of it.
2230
```php
2331
use Patchlevel\Hydrator\MetadataHydrator;
2432

25-
$hydrator = new MetadataHydrator();
33+
$hydrator = MetadataHydrator::create();
2634
```
2735

2836
After that you can hydrate any classes or objects. Also `final`, `readonly` classes with `property promotion`.
37+
These objects or classes can have complex structures in the form of value objects, DTOs or collections.
38+
Or all nested together. Here's an example:
2939

3040
```php
3141
final readonly class ProfileCreated
3242
{
43+
/**
44+
* @param list<Skill> $skills
45+
*/
3346
public function __construct(
34-
public string $id,
35-
public string $name
47+
public int $id,
48+
public string $name,
49+
public Role $role, // enum,
50+
public array $skills, // array of objects
51+
public DateTimeImmutable $createdAt,
3652
) {
3753
}
3854
}
3955
```
4056

4157
### Extract Data
4258

43-
To convert objects into serializable arrays, you can use the `extract` method:
59+
To convert objects into serializable arrays, you can use the `extract` method of the hydrator.
4460

4561
```php
46-
$event = new ProfileCreated('1', 'patchlevel');
62+
$event = new ProfileCreated(
63+
1,
64+
'patchlevel',
65+
Role::Admin,
66+
[new Skill('php', 10), new Skill('event-sourcing', 10)],
67+
new DateTimeImmutable('2023-10-01 12:00:00'),
68+
);
4769

4870
$data = $hydrator->extract($event);
4971
```
5072

73+
The result looks like this:
74+
5175
```php
5276
[
53-
'id' => '1',
54-
'name' => 'patchlevel'
77+
'id' => 1,
78+
'name' => 'patchlevel',
79+
'role' => 'admin',
80+
'skills' => [
81+
[
82+
'name' => 'php',
83+
'level' => 10,
84+
],
85+
[
86+
'name' => 'event-sourcing',
87+
'level' => 10,
88+
],
89+
],
90+
'createdAt' => '2023-10-01T12:00:00+00:00',
5591
]
5692
```
5793

94+
We could now convert the whole thing into JSON using `json_encode`.
95+
5896
### Hydrate Object
5997

98+
The process can also be reversed. Hydrate an array back into an object.
99+
To do this, we need to specify the class that should be created
100+
and the data that should then be written into it.
101+
60102
```php
61103
$event = $hydrator->hydrate(
62104
ProfileCreated::class,
63105
[
64-
'id' => '1',
65-
'name' => 'patchlevel'
106+
'id' => 1,
107+
'name' => 'patchlevel',
108+
'role' => 'admin',
109+
'skills' => [
110+
[
111+
'name' => 'php',
112+
'level' => 10,
113+
],
114+
[
115+
'name' => 'event-sourcing',
116+
'level' => 10,
117+
],
118+
],
119+
'createdAt' => '2023-10-01T12:00:00+00:00',
66120
]
67121
);
68122

69123
$oldEvent == $event // true
70124
```
71125

126+
> [!WARNING]
127+
> It is important to know that the constructor is not called!
128+
72129
### Normalizer
73130

74-
For more complex structures, i.e. non-scalar data types, we use normalizers.
75-
We have some built-in normalizers for standard structures such as objects, enums, datetime etc.
131+
For more complex structures, i.e. non-scalar data types, we use normalizers.
132+
We have some built-in normalizers for standard structures such as objects, arrays, enums, datetime etc.
76133
You can find the full list below.
77134

78-
The normalizers can be set on each property by using the specific attribute.
79-
For example, `#[DateTimeImmutableNormalizer]`. This tells the Hydrator to normalize or denormalize this property.
135+
The library attempts to independently determine which normalizers should be used.
136+
For this purpose, normalizers of this order are determined:
80137

81-
Fortunately, we don't have to do this everywhere.
82-
The library tries to independently recognize which normalizers are needed based on the data type.
83-
For example, if you specify DateTimeImmutable Type, the DateTimeImmutableNormalizer is automatically added.
84-
You can of course override this if you want.
85-
This makes sense, for example, if you want to adjust the format of the normalized string.
86-
You can do this by passing parameters to the normalizer.
138+
1) Does the class property have a normalizer as an attribute? Use this.
139+
2) The data type of the property is determined.
140+
1) If it is a collection, use the ArrayNormalizer (recursive).
141+
2) If it is an object, then look for a normalizer as attribute on the class or interfaces and use this.
142+
3) If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer.
143+
144+
The normalizer is only determined once because it is cached in the metadata.
145+
Below you will find the list of all normalizers and how to set them manually or explicitly.
87146

88147
#### Array
89148

90-
If you have a list of objects that you want to normalize, then you must normalize each object individually.
91-
That's what the `ArrayNormalizer` does for you.
92-
In order to use the `ArrayNormaliser`, you still have to specify which normaliser should be applied to the individual
93-
objects. Internally, it basically does an `array_map` and then runs the specified normalizer on each element.
149+
If you have a collection (array, iterable, list) with a data type that needs to be normalized,
150+
you can use the ArrayNormalizer and pass it the required normalizer.
94151

95152
```php
96153
use Patchlevel\Hydrator\Normalizer\ArrayNormalizer;
@@ -183,7 +240,6 @@ final class DTO
183240
#### Enum
184241

185242
Backed enums can also be normalized.
186-
For this, the enum FQCN must also be pass so that the `EnumNormalizer` knows which enum it is.
187243

188244
```php
189245
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
@@ -252,14 +308,14 @@ final class Name
252308

253309
For this we now need a custom normalizer.
254310
This normalizer must implement the `Normalizer` interface.
255-
You also need to implement a `normalize` and `denormalize` method.
256-
Finally, you have to allow the normalizer to be used as an attribute.
311+
Finally, you have to allow the normalizer to be used as an attribute,
312+
best to allow it for properties as well as classes.
257313

258314
```php
259315
use Patchlevel\Hydrator\Normalizer\Normalizer;
260316
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
261317

262-
#[Attribute(Attribute::TARGET_PROPERTY)]
318+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
263319
class NameNormalizer implements Normalizer
264320
{
265321
public function normalize(mixed $value): string
@@ -286,9 +342,6 @@ class NameNormalizer implements Normalizer
286342
}
287343
```
288344

289-
> [!WARNING]
290-
> The important thing is that the result of Normalize is serializable!
291-
292345
Now we can also use the normalizer directly.
293346

294347
```php
@@ -301,40 +354,48 @@ final class DTO
301354

302355
### Define normalizer on class level
303356

304-
You can also set the attribute on the value object on class level.
305-
For that the normalizer needs to allow to be set on class level.
357+
Instead of specifying the normalizer on each property, you can also set the normalizer on the class or on an interface.
306358

307359
```php
308-
use Patchlevel\Hydrator\Normalizer\Normalizer;
309-
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
310-
311-
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
312-
class NameNormalizer implements Normalizer
360+
#[NameNormalizer]
361+
final class Name
313362
{
314363
// ... same as before
315364
}
316365
```
317366

318-
Then set the attribute on the value object.
367+
### Guess normalizer
319368

369+
It's also possible to write your own guesser that finds the correct normalizer based on the object.
370+
This is useful if, for example, setting the normalizer on the class or interface isn't possible.
320371

321372
```php
322-
#[NameNormalizer]
323-
final class Name
373+
use Patchlevel\Hydrator\Guesser\Guesser;
374+
use Symfony\Component\TypeInfo\Type\ObjectType;
375+
376+
class NameGuesser implements Guesser
324377
{
325-
// ... same as before
378+
public function guess(ObjectType $object): Normalizer|null
379+
{
380+
return match($object->getClassName()) {
381+
case Name::class => new NameNormalizer(),
382+
default => null,
383+
};
384+
}
326385
}
327386
```
328387

329-
After that the DTO can then look like this.
388+
To use this Guesser, you must specify it when creating the Hydrator:
330389

331390
```php
332-
final class DTO
333-
{
334-
public Name $name
335-
}
391+
use Patchlevel\Hydrator\MetadataHydrator;
392+
393+
$hydrator = MetadataHydrator::create([new NameGuesser()]);
336394
```
337395

396+
> [!NOTE]
397+
> The guessers are queried in order, and the first match is returned. Finally, our built-in guesser is executed.
398+
338399
### Normalized Name
339400

340401
By default, the property name is used to name the field in the normalized result.

phpstan-baseline.neon

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,36 @@ parameters:
1818
count: 1
1919
path: src/Cryptography/PersonalDataPayloadCryptographer.php
2020

21+
-
22+
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$#'
23+
identifier: missingType.generics
24+
count: 1
25+
path: src/Guesser/BuiltInGuesser.php
26+
27+
-
28+
message: '#^Parameter \#1 \$className of class Patchlevel\\Hydrator\\Normalizer\\ObjectNormalizer constructor expects class\-string\|null, string given\.$#'
29+
identifier: argument.type
30+
count: 1
31+
path: src/Guesser/BuiltInGuesser.php
32+
33+
-
34+
message: '#^Parameter \#1 \$enum of class Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer constructor expects class\-string\<BackedEnum\>\|null, string given\.$#'
35+
identifier: argument.type
36+
count: 1
37+
path: src/Guesser/BuiltInGuesser.php
38+
39+
-
40+
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$#'
41+
identifier: missingType.generics
42+
count: 1
43+
path: src/Guesser/ChainGuesser.php
44+
45+
-
46+
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$#'
47+
identifier: missingType.generics
48+
count: 1
49+
path: src/Guesser/Guesser.php
50+
2151
-
2252
message: '#^Dead catch \- ReflectionException is never thrown in the try block\.$#'
2353
identifier: catch.neverThrown
@@ -30,21 +60,15 @@ parameters:
3060
count: 1
3161
path: src/Metadata/AttributeMetadataFactory.php
3262

33-
-
34-
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$#'
35-
identifier: missingType.generics
36-
count: 1
37-
path: src/Metadata/AttributeMetadataFactory.php
38-
3963
-
4064
message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:findNormalizerOnClass\(\) expects class\-string, string given\.$#'
4165
identifier: argument.type
4266
count: 2
4367
path: src/Metadata/AttributeMetadataFactory.php
4468

4569
-
46-
message: '#^Parameter \#1 \$enum of class Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer constructor expects class\-string\<BackedEnum\>\|null, string given\.$#'
47-
identifier: argument.type
70+
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\.$#'
71+
identifier: property.unusedType
4872
count: 1
4973
path: src/Metadata/AttributeMetadataFactory.php
5074

src/Guesser/BuiltInGuesser.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\Hydrator\Guesser;
6+
7+
use DateTime;
8+
use DateTimeImmutable;
9+
use DateTimeZone;
10+
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
11+
use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer;
12+
use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer;
13+
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
14+
use Patchlevel\Hydrator\Normalizer\Normalizer;
15+
use Patchlevel\Hydrator\Normalizer\ObjectNormalizer;
16+
use Symfony\Component\TypeInfo\Type\BackedEnumType;
17+
use Symfony\Component\TypeInfo\Type\ObjectType;
18+
19+
final class BuiltInGuesser implements Guesser
20+
{
21+
public function __construct(
22+
private readonly bool $fallbackObjectNormalizer = true,
23+
) {
24+
}
25+
26+
public function guess(ObjectType $type): Normalizer|null
27+
{
28+
if ($type instanceof BackedEnumType) {
29+
return new EnumNormalizer($type->getClassName());
30+
}
31+
32+
return match ($type->getClassName()) {
33+
DateTimeImmutable::class => new DateTimeImmutableNormalizer(),
34+
DateTime::class => new DateTimeNormalizer(),
35+
DateTimeZone::class => new DateTimeZoneNormalizer(),
36+
default => $this->fallbackObjectNormalizer ? new ObjectNormalizer($type->getClassName()) : null,
37+
};
38+
}
39+
}

0 commit comments

Comments
 (0)