Skip to content

Commit 1665fae

Browse files
committed
Merge branch '1.10.x' into 2.0.x
2 parents 7fe6d2e + 8c6390a commit 1665fae

26 files changed

Lines changed: 784 additions & 644 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
2+
3+
name: "Mutation tests on diff"
4+
5+
on:
6+
pull_request:
7+
8+
jobs:
9+
mutation-tests-diff:
10+
name: "Mutation tests on diff"
11+
12+
runs-on: ${{ matrix.operating-system }}
13+
14+
strategy:
15+
matrix:
16+
dependencies:
17+
- "locked"
18+
php-version:
19+
- "8.4"
20+
operating-system:
21+
- "ubuntu-latest"
22+
23+
steps:
24+
- name: "Checkout"
25+
uses: actions/checkout@v4
26+
with:
27+
fetch-depth: 0
28+
29+
- name: "Install PHP"
30+
uses: "shivammathur/setup-php@2.33.0"
31+
with:
32+
coverage: "pcov"
33+
php-version: "${{ matrix.php-version }}"
34+
ini-values: memory_limit=-1
35+
extensions: pdo_sqlite
36+
37+
- uses: ramsey/composer-install@3.1.0
38+
with:
39+
dependency-versions: ${{ matrix.dependencies }}
40+
41+
- name: "Infection"
42+
run: "vendor/bin/roave-infection-static-analysis-plugin --threads=max --git-diff-lines --git-diff-base=origin/$GITHUB_BASE_REF --ignore-msi-with-no-mutations --only-covered --min-msi=90 --min-covered-msi=95"

.github/workflows/mutation-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,6 @@ jobs:
4141
dependency-versions: ${{ matrix.dependencies }}
4242

4343
- name: "Infection"
44-
run: "vendor/bin/roave-infection-static-analysis-plugin"
44+
run: "vendor/bin/roave-infection-static-analysis-plugin --threads=max"
4545
env:
4646
STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }}

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ phpunit: vendor
3535

3636
.PHONY: infection
3737
infection: vendor ## run infection
38-
XDEBUG_MODE=coverage vendor/bin/infection
38+
XDEBUG_MODE=coverage vendor/bin/roave-infection-static-analysis-plugin --threads=max
3939

4040
.PHONY: static
4141
static: psalm phpstan phpcs-check ## run static analyser

README.md

Lines changed: 112 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fpatchlevel%2Fhydrator%2F1.0.x)](https://dashboard.stryker-mutator.io/reports/github.com/patchlevel/hydrator/1.0.x)
1+
[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fpatchlevel%2Fhydrator%2F1.10.x)](https://dashboard.stryker-mutator.io/reports/github.com/patchlevel/hydrator/1.10.x)
22
[![Type Coverage](https://shepherd.dev/github/patchlevel/hydrator/coverage.svg)](https://shepherd.dev/github/patchlevel/hydrator)
33
[![Latest Stable Version](https://poser.pugx.org/patchlevel/hydrator/v)](//packagist.org/packages/patchlevel/hydrator)
44
[![License](https://poser.pugx.org/patchlevel/hydrator/license)](//packagist.org/packages/patchlevel/hydrator)
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.
@@ -575,4 +636,4 @@ To remove personal data, you need only remove the key from the store.
575636

576637
```php
577638
$cipherKeyStore->remove('foo-id');
578-
```
639+
```

composer.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,20 @@
2121
"require": {
2222
"php": "~8.2.0 || ~8.3.0 || ~8.4.0",
2323
"ext-openssl": "*",
24-
"symfony/event-dispatcher": "^5.4.29|^6.4.0|^7.0.0",
24+
"psr/cache": "^2.0.0 || ^3.0.0",
25+
"psr/simple-cache": "^2.0.0 || ^3.0.0",
26+
"symfony/event-dispatcher": "^5.4.29 || ^6.4.0 || ^7.0.0",
2527
"symfony/type-info": "^7.2.4"
2628
},
2729
"require-dev": {
2830
"infection/infection": "^0.29.10",
2931
"patchlevel/coding-standard": "^1.3.0",
30-
"psr/cache": "^2.0.0|^3.0.0",
31-
"psr/simple-cache": "^2.0.0|^3.0.0",
3232
"phpbench/phpbench": "^1.2.15",
33-
"phpspec/prophecy-phpunit": "^2.1.0",
3433
"phpstan/phpstan": "^2.1.0",
3534
"phpunit/phpunit": "^11.5.17",
3635
"psalm/plugin-phpunit": "^0.19.2",
3736
"roave/infection-static-analysis-plugin": "^1.36.0",
38-
"symfony/var-dumper": "^5.4.29|^6.4.0|^7.0.0",
37+
"symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0",
3938
"vimeo/psalm": "^6.0.0"
4039
},
4140
"config": {

0 commit comments

Comments
 (0)