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);