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
27 changes: 27 additions & 0 deletions docs/hydrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,33 @@ You can rename a property without a backwards compatibility break in your stored
data by keeping the old serialized name with `NormalizedName`.
:::

### Aliases

If you rename the field itself, already stored data still uses the old name.
List the former names as aliases: while hydrating, the first alias in the data
is used if the field is missing. Extracting always writes the new name, so the
stored data catches up over time.

```php
use Patchlevel\Hydrator\Attribute\NormalizedName;

final class Profile
{
#[NormalizedName('name', aliases: ['profile_name', 'username'])]
public string $name;
}
```
```php
$profile = $hydrator->hydrate(Profile::class, ['profile_name' => 'patchlevel']);
```
:::note
Field names and aliases have to be unique within a class, including its parent
classes. Middlewares, [upcasters](upcasting.md) and
[lifecycle hooks](lifecycle-hooks.md) see the data as it is stored, with the
alias. The [cryptography](cryptography.md) extension knows the aliases and also
decrypts sensitive data stored under a former name.
:::

## Ignore properties

Sometimes it is necessary to exclude properties. You can do that with the
Expand Down
5 changes: 5 additions & 0 deletions docs/upcasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ get renamed, split or merged. Upcasting reshapes the stored array on the fly
while it is hydrated, so old payloads keep loading into your current classes
without a migration of the underlying storage.

:::tip
If a field was only renamed, you don't need an upcaster. List the former name
as an [alias](hydrator.md#aliases) of the field instead.
:::

## Setup

Register the `UpcastExtension` on the builder and pass it a list of upcasters.
Expand Down
8 changes: 8 additions & 0 deletions src/Attribute/NormalizedName.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final class NormalizedName
{
/** @param list<string> $aliases former names of the field, they are read while hydrating if the field is missing */
public function __construct(
private readonly string $name,
private readonly array $aliases = [],
) {
}

public function name(): string
{
return $this->name;
}

/** @return list<string> */
public function aliases(): array
{
return $this->aliases;
}
}
36 changes: 30 additions & 6 deletions src/Extension/Cryptography/CryptographyMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\Hydrator\Mapper\Mapper;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Metadata\PropertyMetadata;
use Patchlevel\Hydrator\Middleware\Skip;
use Patchlevel\Hydrator\Middleware\SkippableMiddleware;
use Stringable;
Expand Down Expand Up @@ -47,9 +48,10 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ma
continue;
}

$value = $data[$propertyMetadata->fieldName] ?? null;
$field = self::field($propertyMetadata, $data);
$value = $field === null ? null : $data[$field];

if ($value === null) {
if ($field === null || $value === null) {
continue;
}

Expand All @@ -60,7 +62,7 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ma
$subjectId = $subjectIds->get($info->subjectIdName);

try {
$data[$propertyMetadata->fieldName] = $this->cryptographer->decrypt($subjectId, $value);
$data[$field] = $this->cryptographer->decrypt($subjectId, $value);
} catch (DecryptionFailed | CipherKeyNotExists) {
$fallback = $info->fallback instanceof Closure
? ($info->fallback)($subjectId)
Expand All @@ -70,7 +72,7 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, Ma
$fallback = $propertyMetadata->normalizer->normalize($fallback, $context);
}

$data[$propertyMetadata->fieldName] = $fallback;
$data[$field] = $fallback;
}
}

Expand Down Expand Up @@ -135,6 +137,26 @@ public function skip(ClassMetadata $metadata): Skip
return Skip::Both;
}

/**
* The key of the property in the data: the field name or, for data stored under a former name, the first alias.
*
* @param array<string, mixed> $data
*/
private static function field(PropertyMetadata $property, array $data): string|null
{
if (array_key_exists($property->fieldName, $data)) {
return $property->fieldName;
}

foreach ($property->aliases as $alias) {
if (array_key_exists($alias, $data)) {
return $alias;
}
}

return null;
}

/**
* @param array<string, mixed>|object $data
* @param array<string, mixed> $context
Expand All @@ -157,11 +179,13 @@ private function resolveSubjectIds(

foreach ($mapping->nameToField as $name => $fieldName) {
if (is_array($data)) {
if (!array_key_exists($fieldName, $data)) {
$field = self::field($metadata->propertyForField($fieldName), $data);

if ($field === null) {
throw new MissingSubjectIdForField($metadata->className, $fieldName);
}

$subjectId = $data[$fieldName];
$subjectId = $data[$field];
} else {
$property = $metadata->propertyForField($fieldName);
$subjectId = $property->getValue($data);
Expand Down
5 changes: 3 additions & 2 deletions src/Extension/Generated/ClassFingerprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
use function substr;

/**
* Fingerprint of everything the generated code of a class depends on: the properties with their field names,
* normalizers, visibility, declaring class and inlined defaults, and the nested classes. It is part of the name of the
* Fingerprint of everything the generated code of a class depends on: the properties with their field names and
* aliases, normalizers, visibility, declaring class and inlined defaults, and the nested classes. It is part of the name of the
* generated mapper, so a changed class has no generated mapper until it is warmed up again.
*
* @internal
Expand Down Expand Up @@ -60,6 +60,7 @@ public static function of(ClassMetadata $metadata): string
$declaringClass === $metadata->className ? null : $declaringClass,
$default,
$nested,
$property->aliases,
];
}

Expand Down
1 change: 1 addition & 0 deletions src/Extension/Generated/ClassPlanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ private function property(ClassMetadata $metadata, PropertyMetadata $property):
defaultSlot: $defaultSlot,
hydrateScope: $inherited && ($reflection->isPrivate() || $reflection->isReadOnly() || self::privateSet($reflection)) ? $declaringClass : null,
extractScope: $inherited && $reflection->isPrivate() ? $declaringClass : null,
aliases: $property->aliases,
);
}

Expand Down
13 changes: 12 additions & 1 deletion src/Extension/Generated/CodeEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,26 @@ private function hydrateBody(): array
private function assignment(PropertyPlan $property): string
{
$template = $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN;
$code = [];

return Templates::render($template, [
// the first alias in the data takes the place of the missing field
foreach ($property->aliases as $alias) {
$code[] = Templates::render(Templates::ALIAS, [
'field' => var_export($property->field, true),
'alias' => var_export($alias, true),
]);
}

$code[] = Templates::render($template, [
'field' => var_export($property->field, true),
'name' => $property->name,
'property' => var_export($property->name, true),
'class' => $this->plan->className(),
'value' => $this->denormalize($property, '$value'),
'default' => $property->default ?? sprintf('$this->d%d->getDefaultValue()', $property->defaultSlot),
]);

return implode("\n\n", $code);
}

/** Code which denormalizes the field into the given variable. */
Expand Down
2 changes: 2 additions & 0 deletions src/Extension/Generated/PropertyPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class PropertyPlan
* @param int|null $defaultSlot slot of a promoted default which is resolved at runtime
* @param class-string|null $hydrateScope class whose scope is needed to write the property
* @param class-string|null $extractScope class whose scope is needed to read the property
* @param list<string> $aliases former field names, read while hydrating if the field is missing
*/
public function __construct(
public readonly string $name,
Expand All @@ -36,6 +37,7 @@ public function __construct(
public readonly int|null $defaultSlot = null,
public readonly string|null $hydrateScope = null,
public readonly string|null $extractScope = null,
public readonly array $aliases = [],
) {
}

Expand Down
6 changes: 6 additions & 0 deletions src/Extension/Generated/Templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ protected function mayReachItself(): bool
$this->ep{{slot}} = Closure::bind(function (object $object, array $context, bool $inline): array {
{{body}}
}, $this, \{{scope}}::class);
PHP;

public const ALIAS = <<<'PHP'
if (!\array_key_exists({{field}}, $data) && \array_key_exists({{alias}}, $data)) {
$data[{{field}}] = $data[{{alias}}];
}
PHP;

public const ASSIGN = <<<'PHP'
Expand Down
46 changes: 35 additions & 11 deletions src/Mapper/ReflectionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Patchlevel\Hydrator\DenormalizationFailure;
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Metadata\PropertyMetadata;
use Patchlevel\Hydrator\NormalizationFailure;
use Patchlevel\Hydrator\Normalizer\ArrayNormalizer;
use Patchlevel\Hydrator\Normalizer\InvalidType;
Expand Down Expand Up @@ -87,22 +88,29 @@

foreach ($metadata->properties as $propertyMetadata) {
if (!array_key_exists($propertyMetadata->fieldName, $data)) {
if (!$propertyMetadata->reflection->isPromoted()) {
continue;
}
$alias = self::alias($propertyMetadata, $data);

if ($alias === null) {
if (!$propertyMetadata->reflection->isPromoted()) {
continue;
}

$constructorParameters ??= $metadata->promotedConstructorDefaults();

Check warning on line 98 in src/Mapper/ReflectionMapper.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "AssignCoalesce": @@ @@ continue; } - $constructorParameters ??= $metadata->promotedConstructorDefaults(); + $constructorParameters = $metadata->promotedConstructorDefaults(); if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) { continue;

$constructorParameters ??= $metadata->promotedConstructorDefaults();
if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) {
continue;
}

$propertyMetadata->reflection->setValue(
$object,
$constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(),
);

if (!array_key_exists($propertyMetadata->propertyName, $constructorParameters)) {
continue;
}

$propertyMetadata->reflection->setValue(
$object,
$constructorParameters[$propertyMetadata->propertyName]->getDefaultValue(),
);

continue;
// the field is stored under a former name
$data[$propertyMetadata->fieldName] = $data[$alias];
}

if ($propertyMetadata->normalizer) {
Expand Down Expand Up @@ -143,6 +151,22 @@
return $object;
}

/**
* The first alias of the property which is in the data.
*
* @param array<string, mixed> $data
*/
private static function alias(PropertyMetadata $property, array $data): string|null
{
foreach ($property->aliases as $alias) {
if (array_key_exists($alias, $data)) {
return $alias;
}
}

return null;
}

/**
* @param T $object
* @param array<string, mixed> $context
Expand Down
Loading
Loading