grifix / normalizer
Normalizer with data version upcasting support
Requires
- php: ^8.1
- grifix/array-wrapper: ^2.0
- grifix/reflection: ^1.0
- justinrainbow/json-schema: ^5.2
Requires (Dev)
- phpunit/phpunit: ^9.5
README
Universal normalizer with upcasting support.
Description
Sometimes it is very convenient to store your entities in JSON instead of creating separated fields for each property, especially if you have a lot of value objects inside your entities. But the problem appears when you want to refactor your entities, and change their internal structure. If you use classical ORM you should write a migration that changes your table structure and it could be not so easy if you store your data as JSON. This library provides another solution. Instead of creating migration, it proposes to create a version converter that changes data structure on the fly when you read data from the repository. It controls the structure of your entities and warns you if the previously saved data structure is incompatible with your changed entity structure.
Installation
composer require grifix/normalizer
Integration with Symfony
Basic usage
Imagine that we want to normalize class User
:
final class User{
public function __construct(
private readonly string $email,
private readonly string $name
){
}
}
We should register the default normalizer for this class:
use Grifix\Normalizer\Normalizer;
use Grifix\Normalizer\SchemaValidator\Repository\Schema\Schema;
$normalizer = Normalizer::create();
$normalizer->registerDefaultObjectNormalizer(
'user',
User::class,
[
Schema::create()
->withStringProperty('email')
->withStringProperty('name'),
]
);
Now we can normalize the User
object to the array and store it somewhere:
$user = new User('user@example.com', 'John Smith');
print_r($normalizer->normalize($user));
will display:
Array
(
[email] => john@example.com
[name] => John Smith
[__normalizer__] => Array
(
[name] => user
[version] => 1
)
)
and we also can denormalize an array into the User
object:
$user = $normalizer->denormalize([
'email' => 'john@example.com',
'name' => 'John Smith',
'__normalizer__' => [
'name' => 'user',
'version' => 1
]
]);
Upcasting
Now imagine that we decided to change the User
object structure,
and we want to separate the name
field into firstName
and lastName
final class User{
public function __construct(
private readonly string $email,
private readonly string $firstName,
private readonly string $lastName
){
}
}
If we try to normalize this new User
:
$user = new User('user@example.com', 'John', 'Smith');
print_r($normalizer->normalize($user));
object normalizer will throw an exception:
Invalid data for normalizer [user] version [1]!
Of course, we can just modify the JSON schema and the problem with normalization will be solved but what about data that were normalized and saved previously?
To solve this problem, we need a little more effort: We should prepare a new version of the JSON schema, and we should prepare a converter that converts the old data structure to the new one:
use Grifix\Normalizer\VersionConverter\Exceptions\UnsupportedVersionException;
$normalizer->registerDefaultObjectNormalizer(
'user',
User::class,
[
Schema::create()
->withStringProperty('email')
->withStringProperty('name'),
Schema::create()
->withStringProperty('email')
->withStringProperty('firstName')
->withStringProperty('lastName'),
],
new class implements \Grifix\Normalizer\VersionConverter\VersionConverterInterface {
/**
* @throws UnsupportedVersionException
*/
public function convert(array $data, int $dataVersion, string $normalizerName): array
{
return match ($dataVersion) {
1 => $this->convertToVersion2($data),
default => throw new UnsupportedVersionException(
$normalizerName,
$dataVersion
)
};
}
private function convertToVersion2($data): array
{
$arr = explode(' ', $data['name']);
$data['firstName'] = $arr[0];
$data['lastName'] = $arr[1];
unset($data['name']);
return $data;
}
}
);
Now we are able to normalize the new version of the User
class:
$user = new User('john@example.com', 'John', 'Smith');
print_r($normalizer->normalize($user));
will return:
Array
(
[email] => john@example.com
[firstName] => John
[lastName] => Smith
[__normalizer__] => Array
(
[name] => user
[version] => 2
)
)
And we also can denormalize the old version data to object:
$normalizer->denormalize([
'email' => 'john@example.com',
'name' => 'John Smith',
'__normalizer__' => [
'name' => 'user',
'version' => 1
]
]);
Custom normalizer
Let's change our user and add him the birthdate:
final class User
{
public function __construct(
private readonly string $email,
private readonly string $firstName,
private readonly string $lastName,
private readonly ?DateTimeImmutable $birthDate = null
) {
}
}
We should also modify the code that register user normalizer and add a json schema for: the new version:
Schema::create()
->withStringProperty('email')
->withStringProperty('firstName')
->withStringProperty('lastName')
->withObjectProperty('birthDate', ['date-time'], true)
And modify the version converter:
new class implements \Grifix\Normalizer\VersionConverter\VersionConverterInterface {
/**
* @throws UnsupportedVersionException
*/
public function convert(array $data, int $dataVersion, string $normalizerName): array
{
return match ($dataVersion) {
1 => $this->convertToVersion2($data),
2 => $this->convertToVersion3($data),
default => throw new UnsupportedVersionException(
$normalizerName,
$dataVersion
)
};
}
private function convertToVersion2($data): array
{
$arr = explode(' ', $data['name']);
$data['firstName'] = $arr[0];
$data['lastName'] = $arr[1];
unset($data['name']);
return $data;
}
private function convertToVersion3($data): array
{
$data['birthDate'] = null;
return $data;
}
}
Ok now it works for the old data formats:
$normalizer->denormalize([
'email' => 'john@example.com',
'name' => 'John Smith',
'__normalizer__' => [
'name' => 'user',
'version' => 1
]
]);
$normalizer->denormalize([
'email' => 'john@example.com',
'firstName' => 'John',
'lastName' => 'Smith',
'__normalizer__' => [
'name' => 'user',
'version' => 2
]
]);
But if we try to normalize the user that has a birthdate:
$normalizer->normalize(
new User(
'john@example.com',
'John',
'Smith',
new DateTimeImmutable()
)
);
we will get an exception:
Normalizer with object class [DateTimeImmutable] does not exist!
So we can try to register the default normalizer for the DateTimeImmutable
class:
$normalizer->registerDefaultObjectNormalizer(
'DateTimeImmutable',
DateTimeImmutable::class,
[
Schema::create()->withStringProperty('value'),
]
);
If we try to normalize our user again:
print_r($normalizer->normalize(
new User(
'john@example.com',
'John',
'Smith',
new DateTimeImmutable()
)
));
We will see something like this:
Array
(
[email] => john@example.com
[firstName] => John
[lastName] => Smith
[birthDate] => Array
(
[__normalizer__] => Array
(
[name] => DateTimeImmutable
[version] => 1
)
)
[__normalizer__] => Array
(
[name] => user
[version] => 3
)
)
As we can see, there is no information about the birthdate in the data array. It happens because the default normalizer cannot handle built-in objects or some library objects, because they may contain some additional objects (like services) or data that we don't want to store.
In this case, we should register a custom normalizer that describes how to normalize and denormalize this type of object:
$normalizer->registerCustomObjectNormalizer(
'date-time',
new class implements \Grifix\Normalizer\ObjectNormalizers\CustomObjectNormalizerInterface{
public function normalize(object $object): array
{
if ( ! ($object instanceof \DateTimeImmutable)) {
throw new \Grifix\Normalizer\ObjectNormalizers\Exceptions\InvalidObjectTypeException(
$object::class,
\DateTimeImmutable::class
);
}
return ['value' => $object->format(DateTimeInterface::ATOM)];
}
public function denormalize(array $data): object
{
return new \DateTimeImmutable($data['value']);
}
public function getObjectClass(): string
{
return \DateTimeImmutable::class;
}
},
[
Schema::create()->withStringProperty('value');
]
);
Now our output will look like this:
Array
(
[email] => john@example.com
[firstName] => John
[lastName] => Smith
[birthDate] => Array
(
[value] => 2022-06-22T20:00:03+00:00
[__normalizer__] => Array
(
[name] => DateTimeImmutable
[version] => 1
)
)
[__normalizer__] => Array
(
[name] => user
[version] => 3
)
)
Dependency injection
Now imagine that we need some service inside our User
object, we don't want to store it, but we want to inject it
automatically after user denormalization:
final class PrinterService
{
public function print(string $value): void
{
echo $value.PHP_EOL;
}
}
final class User
{
public function __construct(
private readonly string $email,
private readonly string $firstName,
private readonly string $lastName,
private readonly ?DateTimeImmutable $birthDate = null,
private readonly PrinterService $printer
) {
$this->printer->print($this->email);
}
public function printName():void{
$this->printer->print($this->firstName.' '.$this->lastName);
}
}
Now we should tell the normalizer that the printer
property is a dependency and it should be injected automatically:
$normalizer->registerDefaultObjectNormalizer(
'user',
User::class,
[
//schemas
],
//converter,
['printer']
);
And we should register printer service as a dependency:
$printer = new PrinterService();
$normalizer->registerDependency($printer);
in real applications, you can implement \Grifix\Normalizer\DependencyProvider\DependencyProviderInterface
by wrapping
IOC container
If we execute this code now:
$user = new User(
'john@example.com',
'John',
'Smith',
new DateTimeImmutable(),
$printer
);
$data = $normalizer->normalize($user);
$user = $normalizer->denormalize($data);
$user->printName();
The output will be:
john@example.com
John Smith
Because the normalizer does not call the constructor during denormalization.