Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 110 additions & 49 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@

# Hydrator

With this library you can hydrate objects from array into objects and back again
with a focus on data processing from and into a database.
It has now been outsourced by the [event-sourcing](https://github.com/patchlevel/event-sourcing) library as a separate library.
This library enables seamless hydration of objects to arrays—and back again.
It’s optimized for both developer experience (DX) and performance.

The library is a core component of [patchlevel/event-sourcing](ttps://github.com/patchlevel/event-sourcing),
where it powers the storage and retrieval of thousands of objects.

Hydration is handled through normalizers, especially for complex data types.
The system can automatically determine the appropriate normalizer based on the data type and PHPStan/Psalm annotations.

In most cases, no manual configuration is needed.
And if customization is required, it can be done easily using attributes.

## Installation

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

$hydrator = new MetadataHydrator();
$hydrator = MetadataHydrator::create();
```

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

```php
final readonly class ProfileCreated
{
/**
* @param list<Skill> $skills
*/
public function __construct(
public string $id,
public string $name
public int $id,
public string $name,
public Role $role, // enum,
public array $skills, // array of objects
public DateTimeImmutable $createdAt,
) {
}
}
```

### Extract Data

To convert objects into serializable arrays, you can use the `extract` method:
To convert objects into serializable arrays, you can use the `extract` method of the hydrator.

```php
$event = new ProfileCreated('1', 'patchlevel');
$event = new ProfileCreated(
1,
'patchlevel',
Role::Admin,
[new Skill('php', 10), new Skill('event-sourcing', 10)],
new DateTimeImmutable('2023-10-01 12:00:00'),
);

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

The result looks like this:

```php
[
'id' => '1',
'name' => 'patchlevel'
'id' => 1,
'name' => 'patchlevel',
'role' => 'admin',
'skills' => [
[
'name' => 'php',
'level' => 10,
],
[
'name' => 'event-sourcing',
'level' => 10,
],
],
'createdAt' => '2023-10-01T12:00:00+00:00',
]
```

We could now convert the whole thing into JSON using `json_encode`.

### Hydrate Object

The process can also be reversed. Hydrate an array back into an object.
To do this, we need to specify the class that should be created
and the data that should then be written into it.

```php
$event = $hydrator->hydrate(
ProfileCreated::class,
[
'id' => '1',
'name' => 'patchlevel'
'id' => 1,
'name' => 'patchlevel',
'role' => 'admin',
'skills' => [
[
'name' => 'php',
'level' => 10,
],
[
'name' => 'event-sourcing',
'level' => 10,
],
],
'createdAt' => '2023-10-01T12:00:00+00:00',
]
);

$oldEvent == $event // true
```

> [!WARNING]
> It is important to know that the constructor is not called!

### Normalizer

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

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

Fortunately, we don't have to do this everywhere.
The library tries to independently recognize which normalizers are needed based on the data type.
For example, if you specify DateTimeImmutable Type, the DateTimeImmutableNormalizer is automatically added.
You can of course override this if you want.
This makes sense, for example, if you want to adjust the format of the normalized string.
You can do this by passing parameters to the normalizer.
1) Does the class property have a normalizer as an attribute? Use this.
2) The data type of the property is determined.
1) If it is a collection, use the ArrayNormalizer (recursive).
2) If it is an object, then look for a normalizer as attribute on the class or interfaces and use this.
3) If it is an object, then guess the normalizer based on the object. Fallback to the object normalizer.

The normalizer is only determined once because it is cached in the metadata.
Below you will find the list of all normalizers and how to set them manually or explicitly.

#### Array

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

```php
use Patchlevel\Hydrator\Normalizer\ArrayNormalizer;
Expand Down Expand Up @@ -183,7 +240,6 @@ final class DTO
#### Enum

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

```php
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
Expand Down Expand Up @@ -252,14 +308,14 @@ final class Name

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

```php
use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\InvalidArgument;

#[Attribute(Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
class NameNormalizer implements Normalizer
{
public function normalize(mixed $value): string
Expand All @@ -286,9 +342,6 @@ class NameNormalizer implements Normalizer
}
```

> [!WARNING]
> The important thing is that the result of Normalize is serializable!

Now we can also use the normalizer directly.

```php
Expand All @@ -301,40 +354,48 @@ final class DTO

### Define normalizer on class level

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

```php
use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\InvalidArgument;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
class NameNormalizer implements Normalizer
#[NameNormalizer]
final class Name
{
// ... same as before
}
```

Then set the attribute on the value object.
### Guess normalizer

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

```php
#[NameNormalizer]
final class Name
use Patchlevel\Hydrator\Guesser\Guesser;
use Symfony\Component\TypeInfo\Type\ObjectType;

class NameGuesser implements Guesser
{
// ... same as before
public function guess(ObjectType $object): Normalizer|null
{
return match($object->getClassName()) {
case Name::class => new NameNormalizer(),
default => null,
};
}
}
```

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

```php
final class DTO
{
public Name $name
}
use Patchlevel\Hydrator\MetadataHydrator;

$hydrator = MetadataHydrator::create([new NameGuesser()]);
```

> [!NOTE]
> The guessers are queried in order, and the first match is returned. Finally, our built-in guesser is executed.

### Normalized Name

By default, the property name is used to name the field in the normalized result.
Expand Down
40 changes: 32 additions & 8 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ parameters:
count: 1
path: src/Cryptography/PersonalDataPayloadCryptographer.php

-
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$#'
identifier: missingType.generics
count: 1
path: src/Guesser/BuiltInGuesser.php

-
message: '#^Parameter \#1 \$className of class Patchlevel\\Hydrator\\Normalizer\\ObjectNormalizer constructor expects class\-string\|null, string given\.$#'
identifier: argument.type
count: 1
path: src/Guesser/BuiltInGuesser.php

-
message: '#^Parameter \#1 \$enum of class Patchlevel\\Hydrator\\Normalizer\\EnumNormalizer constructor expects class\-string\<BackedEnum\>\|null, string given\.$#'
identifier: argument.type
count: 1
path: src/Guesser/BuiltInGuesser.php

-
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$#'
identifier: missingType.generics
count: 1
path: src/Guesser/ChainGuesser.php

-
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$#'
identifier: missingType.generics
count: 1
path: src/Guesser/Guesser.php

-
message: '#^Dead catch \- ReflectionException is never thrown in the try block\.$#'
identifier: catch.neverThrown
Expand All @@ -30,21 +60,15 @@ parameters:
count: 1
path: src/Metadata/AttributeMetadataFactory.php

-
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$#'
identifier: missingType.generics
count: 1
path: src/Metadata/AttributeMetadataFactory.php

-
message: '#^Parameter \#1 \$class of method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:findNormalizerOnClass\(\) expects class\-string, string given\.$#'
identifier: argument.type
count: 2
path: src/Metadata/AttributeMetadataFactory.php

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

Expand Down
39 changes: 39 additions & 0 deletions src/Guesser/BuiltInGuesser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Guesser;

use DateTime;
use DateTimeImmutable;
use DateTimeZone;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer;
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\ObjectNormalizer;
use Symfony\Component\TypeInfo\Type\BackedEnumType;
use Symfony\Component\TypeInfo\Type\ObjectType;

final class BuiltInGuesser implements Guesser
{
public function __construct(
private readonly bool $fallbackObjectNormalizer = true,
) {
}

public function guess(ObjectType $type): Normalizer|null
{
if ($type instanceof BackedEnumType) {
return new EnumNormalizer($type->getClassName());
}

return match ($type->getClassName()) {
DateTimeImmutable::class => new DateTimeImmutableNormalizer(),
DateTime::class => new DateTimeNormalizer(),
DateTimeZone::class => new DateTimeZoneNormalizer(),
default => $this->fallbackObjectNormalizer ? new ObjectNormalizer($type->getClassName()) : null,
};
}
}
Loading
Loading