From 59cc460217430c54424e0b02af4be4cbd7c24167 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 18 Oct 2026 22:30:40 +0200 Subject: [PATCH] Read renamed fields from their former names NormalizedName accepts aliases now. While hydrating, the first alias in the data is used if the field itself is missing, extracting always writes the field name. This covers the most common upcasting case, a renamed field, without writing an upcaster. The generated mappers copy the alias into the field before the assignment, classes without aliases get the same code as before. The cryptography middleware also finds sensitive data and subject ids stored under an alias. --- docs/hydrator.md | 27 ++ docs/upcasting.md | 5 + src/Attribute/NormalizedName.php | 8 + .../Cryptography/CryptographyMiddleware.php | 36 ++- src/Extension/Generated/ClassFingerprint.php | 5 +- src/Extension/Generated/ClassPlanner.php | 1 + src/Extension/Generated/CodeEmitter.php | 13 +- src/Extension/Generated/PropertyPlan.php | 2 + src/Extension/Generated/Templates.php | 6 + src/Mapper/ReflectionMapper.php | 46 +++- src/Metadata/AttributeMetadataFactory.php | 53 ++-- src/Metadata/PropertyMetadata.php | 9 +- .../CryptographyMiddlewareTest.php | 66 +++++ .../RenamedSensitiveDataProfileCreated.php | 28 ++ .../Generated/GeneratedStackHydratorTest.php | 41 +++ .../Generated/MapperGeneratorTest.php | 4 + .../Generated/Snapshot/AliasDtoMapper.php | 239 ++++++++++++++++++ tests/Unit/Fixture/AliasDto.php | 19 ++ tests/Unit/Fixture/AliasParentDto.php | 15 ++ .../Fixture/FingerprintChangedAliasDto.php | 23 ++ .../FingerprintChangedPropertyNameDto.php | 23 ++ .../Metadata/AttributeMetadataFactoryTest.php | 99 ++++++++ tests/Unit/Metadata/PropertyMetadataTest.php | 33 +++ tests/Unit/StackHydratorTest.php | 40 +++ 24 files changed, 803 insertions(+), 38 deletions(-) create mode 100644 tests/Unit/Extension/Cryptography/Fixture/RenamedSensitiveDataProfileCreated.php create mode 100644 tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php create mode 100644 tests/Unit/Fixture/AliasDto.php create mode 100644 tests/Unit/Fixture/AliasParentDto.php create mode 100644 tests/Unit/Fixture/FingerprintChangedAliasDto.php create mode 100644 tests/Unit/Fixture/FingerprintChangedPropertyNameDto.php diff --git a/docs/hydrator.md b/docs/hydrator.md index 4df70d54..4ef2e7db 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -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 diff --git a/docs/upcasting.md b/docs/upcasting.md index 507333b8..f66359a2 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -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. diff --git a/src/Attribute/NormalizedName.php b/src/Attribute/NormalizedName.php index 13c06c89..221507eb 100644 --- a/src/Attribute/NormalizedName.php +++ b/src/Attribute/NormalizedName.php @@ -9,8 +9,10 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class NormalizedName { + /** @param list $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 = [], ) { } @@ -18,4 +20,10 @@ public function name(): string { return $this->name; } + + /** @return list */ + public function aliases(): array + { + return $this->aliases; + } } diff --git a/src/Extension/Cryptography/CryptographyMiddleware.php b/src/Extension/Cryptography/CryptographyMiddleware.php index a2aa64b6..86f01d89 100644 --- a/src/Extension/Cryptography/CryptographyMiddleware.php +++ b/src/Extension/Cryptography/CryptographyMiddleware.php @@ -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; @@ -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; } @@ -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) @@ -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; } } @@ -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 $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|object $data * @param array $context @@ -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); diff --git a/src/Extension/Generated/ClassFingerprint.php b/src/Extension/Generated/ClassFingerprint.php index 24d4def1..78c42d86 100644 --- a/src/Extension/Generated/ClassFingerprint.php +++ b/src/Extension/Generated/ClassFingerprint.php @@ -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 @@ -60,6 +60,7 @@ public static function of(ClassMetadata $metadata): string $declaringClass === $metadata->className ? null : $declaringClass, $default, $nested, + $property->aliases, ]; } diff --git a/src/Extension/Generated/ClassPlanner.php b/src/Extension/Generated/ClassPlanner.php index 66cdd7b8..a92cda1e 100644 --- a/src/Extension/Generated/ClassPlanner.php +++ b/src/Extension/Generated/ClassPlanner.php @@ -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, ); } diff --git a/src/Extension/Generated/CodeEmitter.php b/src/Extension/Generated/CodeEmitter.php index 93bbe392..c7452733 100644 --- a/src/Extension/Generated/CodeEmitter.php +++ b/src/Extension/Generated/CodeEmitter.php @@ -218,8 +218,17 @@ 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), @@ -227,6 +236,8 @@ private function assignment(PropertyPlan $property): string '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. */ diff --git a/src/Extension/Generated/PropertyPlan.php b/src/Extension/Generated/PropertyPlan.php index 47e0dbb5..4dccc95c 100644 --- a/src/Extension/Generated/PropertyPlan.php +++ b/src/Extension/Generated/PropertyPlan.php @@ -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 $aliases former field names, read while hydrating if the field is missing */ public function __construct( public readonly string $name, @@ -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 = [], ) { } diff --git a/src/Extension/Generated/Templates.php b/src/Extension/Generated/Templates.php index 935ce85e..c0317c3b 100644 --- a/src/Extension/Generated/Templates.php +++ b/src/Extension/Generated/Templates.php @@ -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' diff --git a/src/Mapper/ReflectionMapper.php b/src/Mapper/ReflectionMapper.php index 92ef4e21..babcd1c9 100644 --- a/src/Mapper/ReflectionMapper.php +++ b/src/Mapper/ReflectionMapper.php @@ -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; @@ -87,22 +88,29 @@ private function hydrateProperties(object $object, array $data, array $context, 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(); - $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) { @@ -143,6 +151,22 @@ private function hydrateProperties(object $object, array $data, array $context, return $object; } + /** + * The first alias of the property which is in the data. + * + * @param array $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 $context diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index b3d530d1..16cc02f2 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -30,6 +30,7 @@ use function array_key_exists; use function array_merge; use function array_values; +use function in_array; final class AttributeMetadataFactory implements MetadataFactory { @@ -100,6 +101,9 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra { $properties = []; + /** @var array $names field name or alias => property name */ + $names = []; + foreach ($reflectionClass->getProperties() as $reflectionProperty) { if ($reflectionProperty->isStatic()) { continue; @@ -113,15 +117,20 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra continue; } - $fieldName = $this->getFieldName($reflectionProperty); + [$fieldName, $aliases] = $this->getFieldName($reflectionProperty); + + // aliases share the namespace of the field names, otherwise a field could belong to two properties + foreach ([$fieldName, ...$aliases] as $name) { + if (array_key_exists($name, $names)) { + throw DuplicatedFieldNameInMetadata::inClass( + $name, + $reflectionClass->getName(), + $names[$name], + $reflectionProperty->getName(), + ); + } - if (array_key_exists($fieldName, $properties)) { - throw DuplicatedFieldNameInMetadata::inClass( - $fieldName, - $reflectionClass->getName(), - $properties[$fieldName]->propertyName, - $reflectionProperty->getName(), - ); + $names[$name] = $reflectionProperty->getName(); } $type = $this->typeResolver->resolve($reflectionProperty); @@ -131,6 +140,7 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra $type, $fieldName, $this->getNormalizer($reflectionProperty, $type), + aliases: $aliases, ); } @@ -149,15 +159,18 @@ private function getLazy(ReflectionClass $reflection): bool|null return $attributeReflectionList[0]->newInstance()->enabled; } - private function getFieldName(ReflectionProperty $reflectionProperty): string + /** @return array{string, list} field name and its aliases */ + private function getFieldName(ReflectionProperty $reflectionProperty): array { $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); if ($attributeReflectionList === []) { - return $reflectionProperty->getName(); + return [$reflectionProperty->getName(), []]; } - return $attributeReflectionList[0]->newInstance()->name(); + $attribute = $attributeReflectionList[0]->newInstance(); + + return [$attribute->name(), $attribute->aliases()]; } private function hasIgnore(ReflectionProperty $reflectionProperty): bool @@ -176,17 +189,23 @@ private function mergeMetadata(ClassMetadata $parent, ClassMetadata $child): Cla { $properties = []; + /** @var list $names field names and aliases of $parent */ + $names = []; + foreach ($parent->properties as $property) { $properties[$property->fieldName] = $property; + $names = [...$names, $property->fieldName, ...$property->aliases]; } foreach ($child->properties as $property) { - if (array_key_exists($property->fieldName, $properties)) { - throw DuplicatedFieldNameInMetadata::byInheritance( - $property->fieldName, - $parent->className, - $child->className, - ); + foreach ([$property->fieldName, ...$property->aliases] as $name) { + if (in_array($name, $names, true)) { + throw DuplicatedFieldNameInMetadata::byInheritance( + $name, + $parent->className, + $child->className, + ); + } } $properties[$property->fieldName] = $property; diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 3ab336a3..9ba7de78 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -16,19 +16,24 @@ * fieldName: string, * normalizer: Normalizer|null, * extras: array, + * aliases?: list, * } */ final class PropertyMetadata { public readonly string $propertyName; - /** @param array $extras */ + /** + * @param array $extras + * @param list $aliases former field names, read while hydrating if the field is missing + */ public function __construct( public readonly ReflectionProperty $reflection, public readonly Type $type, public string $fieldName, public Normalizer|null $normalizer = null, public array $extras = [], + public array $aliases = [], ) { $this->propertyName = $reflection->getName(); } @@ -53,6 +58,7 @@ public function __serialize(): array 'fieldName' => $this->fieldName, 'normalizer' => $this->normalizer, 'extras' => $this->extras, + 'aliases' => $this->aliases, ]; } @@ -65,5 +71,6 @@ public function __unserialize(array $data): void $this->fieldName = $data['fieldName']; $this->normalizer = $data['normalizer']; $this->extras = $data['extras']; + $this->aliases = $data['aliases'] ?? []; } } diff --git a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php index d18155df..e06f3c52 100644 --- a/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php @@ -17,6 +17,7 @@ use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Skip; +use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\RenamedSensitiveDataProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated; use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreatedFallbackCallback; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; @@ -176,6 +177,71 @@ public function testDecryptWithCipherKeyNotExists(): void self::assertEquals(new Email('unknown'), $result->email); } + public function testDecryptAliasedFields(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->expects($this->once()) + ->method('decrypt') + ->with('foo', 'encrypted') + ->willReturn('info@patchlevel.de'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(RenamedSensitiveDataProfileCreated::class), + ['profileId' => 'foo', 'mail' => 'encrypted'], + [], + new ReflectionMapper($this->metadata(RenamedSensitiveDataProfileCreated::class)), + ); + + self::assertInstanceOf(RenamedSensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('info@patchlevel.de'), $result->email); + } + + public function testDecryptAliasedFieldWithFallback(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(CipherKeyNotExists::forSubjectId('foo')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(RenamedSensitiveDataProfileCreated::class), + ['profileId' => 'foo', 'mail' => 'encrypted'], + [], + new ReflectionMapper($this->metadata(RenamedSensitiveDataProfileCreated::class)), + ); + + self::assertInstanceOf(RenamedSensitiveDataProfileCreated::class, $result); + self::assertEquals(new Email('unknown'), $result->email); + } + + public function testDecryptPrefersTheFieldOverTheAlias(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->expects($this->once()) + ->method('decrypt') + ->with('new', 'encrypted new') + ->willReturn('new@patchlevel.de'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(RenamedSensitiveDataProfileCreated::class), + ['profileId' => 'old', 'id' => 'new', 'mail' => 'encrypted old', 'email' => 'encrypted new'], + [], + new ReflectionMapper($this->metadata(RenamedSensitiveDataProfileCreated::class)), + ); + + self::assertInstanceOf(RenamedSensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('new'), $result->profileId); + self::assertEquals(new Email('new@patchlevel.de'), $result->email); + } + public function testDecryptWithDecryptionFailed(): void { $cryptographer = $this->createMock(Cryptographer::class); diff --git a/tests/Unit/Extension/Cryptography/Fixture/RenamedSensitiveDataProfileCreated.php b/tests/Unit/Extension/Cryptography/Fixture/RenamedSensitiveDataProfileCreated.php new file mode 100644 index 00000000..55ec4adb --- /dev/null +++ b/tests/Unit/Extension/Cryptography/Fixture/RenamedSensitiveDataProfileCreated.php @@ -0,0 +1,28 @@ +email); } + public function testHydrateWithAlias(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel']); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel'), $object); + } + + public function testHydrateWithFurtherAlias(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['e_mail' => 'info@patchlevel.de']); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de')), $object); + } + + public function testHydrateFieldIsPreferredOverAlias(): void + { + $object = $this->hydrator->hydrate( + AliasDto::class, + ['mail' => 'old@patchlevel.de', 'email' => 'new@patchlevel.de', 'username' => 'old', 'name' => 'new'], + ); + + self::assertEquals(new AliasDto(Email::fromString('new@patchlevel.de'), 'new'), $object); + } + + public function testHydrateFirstAliasIsPreferred(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['e_mail' => 'second@patchlevel.de', 'mail' => 'first@patchlevel.de']); + + self::assertEquals(new AliasDto(Email::fromString('first@patchlevel.de')), $object); + } + + public function testExtractWithAlias(): void + { + self::assertSame( + ['email' => 'info@patchlevel.de', 'name' => 'patchlevel'], + $this->hydrator->extract(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel')), + ); + } + public function testHydrateWithTypeMismatch(): void { $this->expectException(TypeMismatch::class); diff --git a/tests/Unit/Extension/Generated/MapperGeneratorTest.php b/tests/Unit/Extension/Generated/MapperGeneratorTest.php index 920efe8a..6bff3094 100644 --- a/tests/Unit/Extension/Generated/MapperGeneratorTest.php +++ b/tests/Unit/Extension/Generated/MapperGeneratorTest.php @@ -22,7 +22,9 @@ use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\StackHydrator; +use Patchlevel\Hydrator\Tests\Unit\Fixture\FingerprintChangedAliasDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\FingerprintChangedDefaultDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\FingerprintChangedPropertyNameDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\FingerprintChangedVisibilityDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\FingerprintDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\LifecycleFixture; @@ -161,6 +163,8 @@ public static function changedClasses(): iterable { yield 'changed default' => [FingerprintChangedDefaultDto::class]; yield 'changed visibility' => [FingerprintChangedVisibilityDto::class]; + yield 'added alias' => [FingerprintChangedAliasDto::class]; + yield 'renamed property' => [FingerprintChangedPropertyNameDto::class]; } public function testClassNormalizerIsNotGeneratable(): void diff --git a/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php new file mode 100644 index 00000000..3b7221d3 --- /dev/null +++ b/tests/Unit/Extension/Generated/Snapshot/AliasDtoMapper.php @@ -0,0 +1,239 @@ +ready) { + $this->init(); + } + + if (isset($context[Hydrator::OBJECT_TO_POPULATE])) { + $object = $context[Hydrator::OBJECT_TO_POPULATE]; + unset($context[Hydrator::OBJECT_TO_POPULATE]); + } else { + $object = $this->reflection->newInstanceWithoutConstructor(); + } + + if (!\array_key_exists('email', $data) && \array_key_exists('mail', $data)) { + $data['email'] = $data['mail']; + } + + if (!\array_key_exists('email', $data) && \array_key_exists('e_mail', $data)) { + $data['email'] = $data['e_mail']; + } + + if (\array_key_exists('email', $data)) { + try { + $value = $this->n0->denormalize($data['email'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + try { + $object->email = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $e); + } + } + + if (!\array_key_exists('name', $data) && \array_key_exists('username', $data)) { + $data['name'] = $data['username']; + } + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'name', $e); + } + } else { + $object->name = 'unknown'; + } + + return $object; + } + + public function extract(object $object, array $context): array + { + if (!$this->ready) { + $this->init(); + } + + if ($this->tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class; + + try { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + return [ + 'email' => $v_email, + 'name' => $object->name, + ]; + } finally { + unset($this->callStack->objects[$id]); + } + } + + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + return [ + 'email' => $v_email, + 'name' => $object->name, + ]; + } + + public function hydrateNested(array $data, array $context): object + { + $object = $this->reflection->newInstanceWithoutConstructor(); + + if (!\array_key_exists('email', $data) && \array_key_exists('mail', $data)) { + $data['email'] = $data['mail']; + } + + if (!\array_key_exists('email', $data) && \array_key_exists('e_mail', $data)) { + $data['email'] = $data['e_mail']; + } + + if (\array_key_exists('email', $data)) { + try { + $value = $this->n0->denormalize($data['email'], $context); + } catch (Throwable $e) { + throw new DenormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + try { + $object->email = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $e); + } + } + + if (!\array_key_exists('name', $data) && \array_key_exists('username', $data)) { + $data['name'] = $data['username']; + } + + if (\array_key_exists('name', $data)) { + $value = $data['name']; + + try { + $object->name = $value; + } catch (TypeError $e) { + throw new TypeMismatch(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'name', $e); + } + } else { + $object->name = 'unknown'; + } + + return $object; + } + + public function extractNested(object $object, array $context): array + { + if ($this->tracked) { + $id = \spl_object_id($object); + + if (isset($this->callStack->objects[$id])) { + $references = \array_values($this->callStack->objects); + $references[] = $object::class; + + throw new CircularReference($references); + } + + $this->callStack->objects[$id] = \Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class; + + try { + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + return [ + 'email' => $v_email, + 'name' => $object->name, + ]; + } finally { + unset($this->callStack->objects[$id]); + } + } + + try { + $v_email = $this->n0->normalize($object->email, $context); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure(\Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto::class, 'email', $this->n0::class, $e); + } + + return [ + 'email' => $v_email, + 'name' => $object->name, + ]; + } + + protected function initialize(): void + { + $this->n0 = $this->normalizer('email'); + } + + protected function mayReachItself(): bool + { + return self::mayRecurse($this->n0); + } +} \ No newline at end of file diff --git a/tests/Unit/Fixture/AliasDto.php b/tests/Unit/Fixture/AliasDto.php new file mode 100644 index 00000000..a3dea895 --- /dev/null +++ b/tests/Unit/Fixture/AliasDto.php @@ -0,0 +1,19 @@ +text; + } +} diff --git a/tests/Unit/Fixture/FingerprintChangedPropertyNameDto.php b/tests/Unit/Fixture/FingerprintChangedPropertyNameDto.php new file mode 100644 index 00000000..160c9e51 --- /dev/null +++ b/tests/Unit/Fixture/FingerprintChangedPropertyNameDto.php @@ -0,0 +1,23 @@ +text; + } +} diff --git a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php index 3486bb7d..f5055be3 100644 --- a/tests/Unit/Metadata/AttributeMetadataFactoryTest.php +++ b/tests/Unit/Metadata/AttributeMetadataFactoryTest.php @@ -12,6 +12,8 @@ use Patchlevel\Hydrator\Metadata\PropertyMetadataNotFound; use Patchlevel\Hydrator\Normalizer\EnumNormalizer; use Patchlevel\Hydrator\Normalizer\ObjectNormalizer; +use Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto; +use Patchlevel\Hydrator\Tests\Unit\Fixture\AliasParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\BrokenParentDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\DistributionCreated; use Patchlevel\Hydrator\Tests\Unit\Fixture\DuplicateFieldNameDto; @@ -30,6 +32,7 @@ use Symfony\Component\TypeInfo\Type; #[CoversClass(AttributeMetadataFactory::class)] +#[CoversClass(NormalizedName::class)] final class AttributeMetadataFactoryTest extends TestCase { public function testEmptyObject(): void @@ -147,6 +150,102 @@ public function __construct( self::assertNull($propertyMetadata->normalizer); } + public function testNormalizedNameWithAliases(): void + { + $metadata = (new AttributeMetadataFactory())->metadata(AliasDto::class); + + self::assertSame(['mail', 'e_mail'], $metadata->properties['email']->aliases); + self::assertSame(['username'], $metadata->properties['name']->aliases); + } + + public function testWithoutAliases(): void + { + $object = new class ('Foo') { + public function __construct( + #[NormalizedName('username')] + public string $name, + public string $email = '', + ) { + } + }; + + $metadata = (new AttributeMetadataFactory())->metadata($object::class); + + self::assertSame([], $metadata->properties['name']->aliases); + self::assertSame([], $metadata->properties['email']->aliases); + } + + public function testAliasDuplicatesFieldName(): void + { + $object = new class { + #[NormalizedName('email', aliases: ['mail'])] + public string $email = ''; + + public string $mail = ''; + }; + + $this->expectException(DuplicatedFieldNameInMetadata::class); + $this->expectExceptionMessage('field name "mail" is duplicated in class'); + + (new AttributeMetadataFactory())->metadata($object::class); + } + + public function testAliasDuplicatesAlias(): void + { + $object = new class { + #[NormalizedName('email', aliases: ['mail'])] + public string $email = ''; + + #[NormalizedName('backupEmail', aliases: ['mail'])] + public string $backupEmail = ''; + }; + + $this->expectException(DuplicatedFieldNameInMetadata::class); + $this->expectExceptionMessage('field name "mail" is duplicated in class'); + + (new AttributeMetadataFactory())->metadata($object::class); + } + + public function testExtendsAliasDuplicatesFieldName(): void + { + $object = new class extends AliasParentDto { + public string $mail = ''; + + public string $phone = ''; + }; + + $this->expectException(DuplicatedFieldNameInMetadata::class); + $this->expectExceptionMessage('field name "mail" is duplicated due to inheritance'); + + (new AttributeMetadataFactory())->metadata($object::class); + } + + public function testExtendsFieldNameDuplicatesAlias(): void + { + $object = new class extends AliasParentDto { + #[NormalizedName('contact', aliases: ['email'])] + public string $contact = ''; + }; + + $this->expectException(DuplicatedFieldNameInMetadata::class); + $this->expectExceptionMessage('field name "email" is duplicated due to inheritance'); + + (new AttributeMetadataFactory())->metadata($object::class); + } + + public function testExtendsWithAliases(): void + { + $object = new class extends AliasParentDto { + #[NormalizedName('name', aliases: ['username'])] + public string $name = ''; + }; + + $metadata = (new AttributeMetadataFactory())->metadata($object::class); + + self::assertSame(['mail'], $metadata->properties['email']->aliases); + self::assertSame(['username'], $metadata->properties['name']->aliases); + } + public function testDefineNormalizer(): void { $object = new class (Email::fromString('info@patchlevel.de')) { diff --git a/tests/Unit/Metadata/PropertyMetadataTest.php b/tests/Unit/Metadata/PropertyMetadataTest.php index 78910607..a654b09a 100644 --- a/tests/Unit/Metadata/PropertyMetadataTest.php +++ b/tests/Unit/Metadata/PropertyMetadataTest.php @@ -10,6 +10,7 @@ use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use ReflectionClass; use ReflectionProperty; use Symfony\Component\TypeInfo\Type; @@ -35,4 +36,36 @@ public function testSerialize(): void self::assertSame('email', $unserialized->propertyName); self::assertEquals($propertyMetadata, $unserialized); } + + public function testSerializeWithAliases(): void + { + $propertyMetadata = new PropertyMetadata( + new ReflectionProperty(ProfileCreated::class, 'email'), + Type::object(Email::class), + 'email', + aliases: ['mail', 'e_mail'], + ); + + $unserialized = unserialize(serialize($propertyMetadata)); + + self::assertInstanceOf(PropertyMetadata::class, $unserialized); + self::assertSame(['mail', 'e_mail'], $unserialized->aliases); + } + + public function testUnserializeWithoutAliases(): void + { + $propertyMetadata = (new ReflectionClass(PropertyMetadata::class))->newInstanceWithoutConstructor(); + + // metadata cached before the aliases were added + $propertyMetadata->__unserialize([ + 'className' => ProfileCreated::class, + 'propertyName' => 'email', + 'type' => Type::object(Email::class), + 'fieldName' => 'email', + 'normalizer' => null, + 'extras' => [], + ]); + + self::assertSame([], $propertyMetadata->aliases); + } } diff --git a/tests/Unit/StackHydratorTest.php b/tests/Unit/StackHydratorTest.php index 318dbed5..53eee9b0 100644 --- a/tests/Unit/StackHydratorTest.php +++ b/tests/Unit/StackHydratorTest.php @@ -25,6 +25,7 @@ use Patchlevel\Hydrator\NormalizationFailure; use Patchlevel\Hydrator\StackHydrator; use Patchlevel\Hydrator\StackHydratorBuilder; +use Patchlevel\Hydrator\Tests\Unit\Fixture\AliasDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; @@ -334,6 +335,45 @@ public function testHydrateWithHydratorAwareNormalizer(): void self::assertEquals($expected, $event); } + public function testHydrateWithAlias(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['mail' => 'info@patchlevel.de', 'username' => 'patchlevel']); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel'), $object); + } + + public function testHydrateWithFurtherAlias(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['e_mail' => 'info@patchlevel.de']); + + self::assertEquals(new AliasDto(Email::fromString('info@patchlevel.de')), $object); + } + + public function testHydrateFieldIsPreferredOverAlias(): void + { + $object = $this->hydrator->hydrate( + AliasDto::class, + ['mail' => 'old@patchlevel.de', 'email' => 'new@patchlevel.de', 'username' => 'old', 'name' => 'new'], + ); + + self::assertEquals(new AliasDto(Email::fromString('new@patchlevel.de'), 'new'), $object); + } + + public function testHydrateFirstAliasIsPreferred(): void + { + $object = $this->hydrator->hydrate(AliasDto::class, ['e_mail' => 'second@patchlevel.de', 'mail' => 'first@patchlevel.de']); + + self::assertEquals(new AliasDto(Email::fromString('first@patchlevel.de')), $object); + } + + public function testExtractWithAlias(): void + { + self::assertSame( + ['email' => 'info@patchlevel.de', 'name' => 'patchlevel'], + $this->hydrator->extract(new AliasDto(Email::fromString('info@patchlevel.de'), 'patchlevel')), + ); + } + public function testHydrateWithTypeMismatch(): void { $this->expectException(TypeMismatch::class);